Fix USB logging session safety and add hourly GPS sync interval

This commit is contained in:
mikecarper committed 2026-10-03 15:43:35 -07:00
1 parent b4c8c0ab23
commit 65512661c3
55 files changed
+2073 -183

No files matched your search

+4
View File
@@ -190,6 +190,8 @@ jobs:
run: |
python3 -B test/test_gps_status.py
python3 -B test/test_gps_upstream_adaptations.py -v
python3 -B test/test_gps_sync_interval.py -v
python3 -B test/test_gps_sync_preferences.py -v
python3 -B test/test_t1000e_sensor_power.py -v
- name: Verify Companion ASCII defaults and USB session cleanup
@@ -202,6 +204,8 @@ jobs:
python3 -B test/test_nrf52_usb_logging_contract.py
python3 -B test/test_esp32_usb_serial_hygiene.py
python3 -B test/test_esp32_tinyusb_nonblocking.py
python3 -B test/test_esp32_usb_session_fix.py
python3 -B test/test_companion_usb_logging_reply.py
python3 -B test/test_esp32_tinyusb_cooperative_output.py
python3 -B test/test_esp32_tinyusb_role_hygiene.py
python3 -B test/test_stm32_float_conversion.py
+2
View File
@@ -195,6 +195,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
| Retry | [`get/set/clear recent.repeater`; `get recent.repeaters`](cli_commands.md#view-seed-or-clear-the-recent-repeater-table) | Repeater | Yes | Yes | Yes |
| GPS | [`get gps`; `set gps on/off`](cli_commands.md#view-or-change-gps-state) | Compiled GPS | Feature | Feature | Feature |
| GPS | [`gps sync`](cli_commands.md#sync-this-nodes-clock-with-gps-time) | Compiled GPS | Feature | Feature | Feature |
| GPS | [`get/set gps.sync.interval`](cli_commands.md#set-the-automatic-gps-clock-sync-interval) | Compiled GPS and provider; 1–336 hours | Feature | Feature | Feature |
| GPS | [`gps setloc`](cli_commands.md#set-this-nodes-location-based-on-the-gps-coordinates) | Compiled GPS | Feature | Feature | Feature |
| GPS | [`gps advert [none/share/prefs]`](cli_commands.md#view-or-change-the-gps-advert-policy) | Compiled GPS | Feature | Feature | Feature |
| Sensors | [`get/set telemetry.access`](cli_commands.md#view-or-change-telemetry-access-mode) | Sensor-capable role | Feature | Feature | Limited |
@@ -365,6 +366,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
| Retry | [`get/set/clear recent.repeater`; `get recent.repeaters`](cli_commands.md#view-seed-or-clear-the-recent-repeater-table) | Repeater | Yes | Yes | Yes | Yes | Yes |
| GPS | [`get gps`; `set gps on/off`](cli_commands.md#view-or-change-gps-state) | Compiled onboard GPS | Feature | Feature | Feature | Feature | Feature |
| GPS | [`gps sync`](cli_commands.md#sync-this-nodes-clock-with-gps-time) | Compiled onboard GPS | Feature | Feature | Feature | Feature | Feature |
| GPS | [`get/set gps.sync.interval`](cli_commands.md#set-the-automatic-gps-clock-sync-interval) | Compiled GPS and provider; 1–336 hours | Feature | Feature | Feature | Feature | Feature |
| GPS | [`gps setloc`](cli_commands.md#set-this-nodes-location-based-on-the-gps-coordinates) | Compiled onboard GPS | Feature | Feature | Feature | Feature | Feature |
| GPS | [`gps advert [none/share/prefs]`](cli_commands.md#view-or-change-the-gps-advert-policy) | Compiled onboard GPS | Feature | Feature | Feature | Feature | Feature |
| Sensors | [`get/set telemetry.access`](cli_commands.md#view-or-change-telemetry-access-mode) | Sensor-capable full parser | Feature | Feature | Limited | Feature | Feature |
+37
View File
@@ -4439,6 +4439,43 @@ On repeaters with device power saving enabled, the automatic GPS cycle wakes
for up to 10 minutes and repeats seven days after GPS sleeps. A valid GPS time
can correct the RTC either forward or backward. With device power saving off,
an enabled GPS refreshes the RTC every 30 minutes.
These are the legacy defaults; `gps.sync.interval` below overrides them.
---
#### Set the automatic GPS clock-sync interval
```text
get gps.sync.interval
set gps.sync.interval 1
set gps.sync.interval 24
set gps.sync.interval 336
```
The interval is a whole number of **hours**, from **1 to 336** (two weeks).
Larger numbers are capped, not rejected. For example:
```text
set gps.sync.interval 999
OK - GPS sync interval 336 hours (saved)
```
Zero, negative numbers, fractional hours, and nonnumeric values are rejected.
The setting is saved across reboots and supported by GPS-equipped repeater,
room-server, sensor, and Companion builds. Until it is explicitly configured,
the getter reports `default (board GPS sync policy)` and existing board timings
are retained.
The selected interval controls periodic clock acquisitions both with GPS power
saving and with a continuously powered receiver. Changing it reschedules a
sleeping receiver without immediately powering it on. A GPS fix can take time;
an unsuccessful acquisition retains the existing bounded awake window, then
backs off before trying again. Device GPS/power switches still apply.
This does not change position-update `gps_interval`, location telemetry/cache
timings, or mesh/NTP time synchronization. Location requests can still power GPS
for a fix, but do not bypass the configured clock-sync interval. Explicit
`gps sync` remains an immediate acquisition request where that command exists.
---
+6
View File
@@ -118,6 +118,12 @@ The Companion app's `gps=1` / `gps=0` custom setting controls the same GPS.
Only boards with a compiled GPS provider expose this setting. Sharing location
with contacts is a separate setting.
Use `set gps.sync.interval <hours>` to save an automatic clock-sync interval of
1–336 hours (two weeks); `get gps.sync.interval` reports it. Larger inputs are
capped at 336 and the reply shows the applied value. Existing GPS timings are
unchanged until configured. This controls clock synchronization, not the
position-update `gps_interval` or location-sharing policy.
When the exact Full image includes MQTT, use WebConfig's MQTT cards or the
same CLI settings used by infrastructure, such as `set mqtt1.preset custom`
and `set mqtt1.server broker.example.com`. `set mqtt.enabled on|off` controls
@@ -41,12 +41,14 @@ Run only one PlatformIO process in the checkout at a time.
```sh
python test/test_esp32_tinyusb_nonblocking.py
python test/test_esp32_usb_session_fix.py
python test/test_esp32_tinyusb_role_hygiene.py
python test/test_esp32_tinyusb_cooperative_output.py
python test/test_esp32_usb_serial_hygiene.py
python test/test_nrf52_usb_logging_contract.py
python test/test_nrf52_usb_lifecycle.py
python test/test_common_cli_logging_transaction.py
python test/test_companion_usb_logging_reply.py
python test/test_usb_diagnostic_logging.py
pio test -e native -f test_nrf52_debug_output -f test_serial_packet_log -f test_serial_mode_switch -f test_mesh_tables
```
@@ -84,6 +86,28 @@ ownership; USB is log-only during that session. Disabling logging restores USB
traffic without accepting bytes typed during the log-only interval. A dedicated
nRF52 logging port does not park the separate Companion port.
A framed USB logging command retains its original requester and queues its
acknowledgement before changing ownership. The handoff advances without waiting
for the host: live logging stays unchanged while the complete USB reply is
backpressured. Disconnect/reset cancels that session's pending handoff; an
already-saved preference can still take effect at the next boot. BLE/WiFi replies
are never used as a fallback for a refused USB acknowledgement.
On native ESP32 TinyUSB, build-local framework hooks capture DTR and USB session
boundaries synchronously in the USB owner task, before Arduino's delayed events.
They purge old input at the boundary and reject late input while DTR is low,
without deleting a new host's first query during main-loop cleanup. An armed
old IN packet (or a racing writer) triggers immediate soft-disconnect, a minimum
20 ms detached interval, and quarantine until fresh enumeration. Shared SDK
files are not modified; a changed native-CDC descriptor fails the build closed
until the hook is reviewed. This path is specific to the pinned Arduino-ESP32
2.0.17 native CDC implementation; hardware Serial/JTAG and UART are unchanged.
Disabling diagnostics is not itself a protocol change and does not discard
functional replies from the shared ESP32 text queue. Actual Binary/mOTA changes
use the explicit terminal-output barrier. Touch and invalid-Ethernet-hostname
diagnostics also honor the runtime logging gate and bounded USB stream.
Ethernet and enabled MQTT diagnostics honor the runtime logging gate and use
the bounded USB facade. Infrastructure logging setters commit preferences
before changing live output; failed saves leave previous settings unchanged.
@@ -95,6 +119,10 @@ advance one row per service pass, using their existing output buffer. Reconnect
cancels the old listing. The listing is a bounded live view, not an immutable
snapshot of clients changed during output.
Stored dumps make progress even when HWCDC falls back to 256/512-byte TX rings.
Recent-repeater headers/rows and EOF retain every unwritten suffix; a short
write never causes their accepted prefix to be retried.
nRF52 FIFO cleanup also checks for an already-armed IN packet. If one exists
at a session boundary (including a pending zero-length packet), both USB ports
briefly detach and re-enumerate: a FIFO clear alone cannot recall endpoint RAM.
@@ -103,3 +131,27 @@ without blocking the mesh loop. Dedicated CDC descriptors are added while
detached even if the host has not yet completed enumeration. These recovery
paths have native regression coverage; real host/board timing still requires
hardware qualification.
The nRF52 callback only drops the pull-up and closes producer gates. The
application loop sends the stack's unplug event afterward, so a full owner-task
event queue cannot make its consumer wait on itself. The detach interval starts
when that application-side stack detach actually occurs. A regression models a
full finite event queue as well as the separate armed endpoint packet.
## Local qualification (2026-10-03)
- All 112 USB Python regression methods passed, plus nine logging-transaction,
Bluetooth-control, and WiFi-status methods. The native facade includes a
threaded close-versus-writer race and an unmount-with-reset-endpoints check.
- `Xiao_S3_companion_radio_usb` built successfully with its default hardware
Serial/JTAG transport: 880,709 flash bytes and 73,244 static RAM bytes.
- A native-TinyUSB Xiao S3 USB Companion qualification build passed with debug
and packet logging enabled: 928,133 flash bytes and 89,312 static RAM bytes.
Its linked ELF contains the synchronous session hooks and endpoint query.
- A RAK4631 Full Companion qualification build with two CDCs, debug/packet
logging, and USB/BLE mOTA sources passed: 678,092 flash bytes and 154,428
static RAM bytes. The runtime RAM and Bluetooth DFU safety checks also passed.
The two custom qualification builds used the lab profile
`909.5 MHz / 500 kHz / SF5 / CR5`. No board was flashed, and no physical
disconnect/reconnect or hardware endpoint recovery was claimed by these checks.
+12
View File
@@ -1002,6 +1002,7 @@ bool DataStore::loadPrefsInt(const char *filename,
// Files written before this tail existed always started Bluetooth at boot.
// Do not inherit a runtime off value when loading one of those images.
loaded_prefs.bluetooth_enabled = 1;
loaded_prefs.gps_sync_interval_hours = 0;
double loaded_lat = node_lat;
double loaded_lon = node_lon;
// The original image ended after ble_pin at byte 84. Later releases only
@@ -1056,6 +1057,13 @@ bool DataStore::loadPrefsInt(const char *filename,
329,
#else
232,
#endif
#ifdef TBEAM_1W
241, // GPS time-sync cadence in hours, appended after Bluetooth enable
#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
331,
#else
234,
#endif
};
const uint32_t prefs_size = file.size();
@@ -1200,6 +1208,8 @@ bool DataStore::loadPrefsInt(const char *filename,
sizeof(loaded_prefs.one_key_dm_enabled));
readOptionalField(&loaded_prefs.bluetooth_enabled,
sizeof(loaded_prefs.bluetooth_enabled));
readOptionalField(&loaded_prefs.gps_sync_interval_hours,
sizeof(loaded_prefs.gps_sync_interval_hours));
// Any bytes left over form only part of a historically appended field.
// Preserve the file and defaults rather than treating that tail as EOF.
@@ -1363,6 +1373,8 @@ bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, dou
sizeof(_prefs.one_key_dm_enabled)) == sizeof(_prefs.one_key_dm_enabled);
success = success && file.write((uint8_t *)&_prefs.bluetooth_enabled,
sizeof(_prefs.bluetooth_enabled)) == sizeof(_prefs.bluetooth_enabled);
success = success && file.write((uint8_t *)&_prefs.gps_sync_interval_hours,
sizeof(_prefs.gps_sync_interval_hours)) == sizeof(_prefs.gps_sync_interval_hours);
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
success = file.commit(success);
+40 -1
View File
@@ -70,6 +70,7 @@
#endif
#include <helpers/CLICommandUtils.h>
#include <helpers/GpsPowerPolicy.h>
#if COMPANION_FEATURE_TEXT_TERMINAL
#include <helpers/TracePathHelpers.h>
#endif
@@ -2277,6 +2278,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
_prefs.buzzer_quiet = DEFAULT_BUZZER_QUIET ? 1 : 0;
_prefs.gps_enabled = 0; // GPS disabled by default
_prefs.gps_interval = 0; // Use the default 1-second fix-processing interval
_prefs.gps_sync_interval_hours = 0; // Retain the board's default time-sync cadence
_prefs.autoadd_config = DEFAULT_AUTOADD_CONFIG;
_prefs.path_hash_mode = DEFAULT_PATH_HASH_MODE;
#ifdef RADIO_FEM_TXGAIN
@@ -2574,6 +2576,8 @@ void MyMesh::begin(bool has_display, bool radio_available,
_prefs.vibe_quiet = constrain(_prefs.vibe_quiet, 0, 1);
_prefs.gps_enabled = constrain(_prefs.gps_enabled, 0, 1); // Ensure boolean 0 or 1
_prefs.gps_interval = constrain(_prefs.gps_interval, 0, 86400); // Max 24 hours
_prefs.gps_sync_interval_hours = constrain(_prefs.gps_sync_interval_hours,
0, mesh::gps::MAX_SYNC_INTERVAL_HOURS);
_prefs.autoadd_config &= AUTO_ADD_OVERWRITE_OLDEST | AUTO_ADD_CHAT | AUTO_ADD_REPEATER | AUTO_ADD_ROOM_SERVER | AUTO_ADD_SENSOR;
_prefs.path_hash_mode = constrain(_prefs.path_hash_mode, 0, 2);
_prefs.radio_fem_rxgain_override = constrain(_prefs.radio_fem_rxgain_override, 0, 1);
@@ -3181,6 +3185,37 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
}
#if ENV_INCLUDE_GPS == 1
if (strcmp(command, "get gps.sync.interval") == 0) {
auto* provider = sensors.getLocationProvider();
if (provider == nullptr) {
snprintf(reply, reply_size, "Error: GPS unavailable");
} else {
const uint16_t hours = provider->getTimeSyncIntervalHours();
if (hours == 0) snprintf(reply, reply_size, "> default (board GPS sync policy)");
else snprintf(reply, reply_size, "> %u hours", (unsigned)hours);
}
return true;
}
const char* gps_sync_value = nullptr;
if (strncmp(command, "set gps.sync.interval", 21) == 0
&& (command[21] == 0 || command[21] == ' ' || command[21] == '\t')) {
gps_sync_value = command + 21;
while (*gps_sync_value == ' ' || *gps_sync_value == '\t') ++gps_sync_value;
}
if (gps_sync_value) {
uint16_t hours = 0;
if (!mesh::gps::parseSyncIntervalHours(gps_sync_value, hours)) {
snprintf(reply, reply_size, "Error: GPS sync interval must be 1..336 hours");
} else if (sensors.getLocationProvider() == nullptr) {
snprintf(reply, reply_size, "Error: GPS unavailable");
} else if (!savePreference(_prefs.gps_sync_interval_hours, hours)) {
snprintf(reply, reply_size, "Error: GPS sync interval could not be saved");
} else {
sensors.applyGpsTimeSyncInterval(hours);
snprintf(reply, reply_size, "OK - GPS sync interval %u hours (saved)", (unsigned)hours);
}
return true;
}
if (strcmp(command, "get gps") == 0) {
const char* gps = sensors.getSettingByKey("gps");
snprintf(reply, reply_size, gps ? "> %s" : "Error: GPS unavailable",
@@ -5134,6 +5169,8 @@ void MyMesh::handleCmdFrame(size_t len) {
} else {
text[tlen] = 0; // ensure null
BaseSerialInterface* reply_route = _serial->captureReplyRoute();
beginUsbLoggingReplyBarrier(reply_route);
reply_buf[0] = 0;
bool handled = false;
#if defined(MESHCORE_EXTRAFS_HIL)
@@ -5149,7 +5186,9 @@ void MyMesh::handleCmdFrame(size_t len) {
out_frame[0] = RESP_CODE_CLI_REPLY;
int rlen = strlen(reply_buf);
memcpy(&out_frame[1], reply_buf, rlen);
_serial->writeFrame(out_frame, 1 + rlen);
const bool reply_queued = _serial->writeFrameToRoute(
reply_route, out_frame, 1 + rlen) == static_cast<size_t>(1 + rlen);
endUsbLoggingReplyBarrier(reply_queued);
}
} else if (cmd_frame[0] == CMD_SEND_TXT_MSG && len >= 14) {
int i = 1;
+5
View File
@@ -302,6 +302,10 @@ public:
bool handleLocalControlCommand(const char* command, char* reply,
size_t reply_size);
void applyUsbLoggingState(bool enabled);
// A framed USB logging change cannot abandon its requester before the
// acknowledgement has entered the transport. Other transports stay live.
void beginUsbLoggingReplyBarrier(BaseSerialInterface* route);
void endUsbLoggingReplyBarrier(bool reply_queued);
bool handleTxRoutingCommand(const char* command, char* reply, size_t reply_size);
int getRecentlyHeard(AdvertPath dest[], int max_num);
@@ -436,6 +440,7 @@ public:
char interval_str[12]; // Max: 24 hours = 86400 seconds (5 digits + null)
sprintf(interval_str, "%u", _prefs.gps_interval);
sensors.setSettingValue("gps_interval", interval_str);
sensors.applyGpsTimeSyncInterval(_prefs.gps_sync_interval_hours);
}
#endif
+2
View File
@@ -110,6 +110,8 @@ public:
uint8_t one_key_dm_enabled = 0;
// Appended after all existing fields so older images keep Bluetooth enabled.
uint8_t bluetooth_enabled = 1;
// Appended after all existing fields; zero retains the board's legacy cadence.
uint16_t gps_sync_interval_hours = 0;
private:
class RadioPrefs : public CommonRadioPrefs {
+70
View File
@@ -626,6 +626,12 @@ static bool usb_host_session_connected = false;
static bool usb_logging_terminal_mode = false;
static bool usb_logging_network_parked = false;
static bool usb_protocol_initialized = false;
#if MESH_USB_LOGGING_AVAILABLE
static bool usb_logging_reply_hold = false;
static bool usb_logging_reply_staged = false;
static bool usb_logging_reply_pending = false;
static bool usb_logging_reply_state = false;
#endif
static mesh::UsbBinaryStartupProbe usb_binary_startup_probe;
static mesh::UsbAsciiSessionDefault usb_ascii_session_default;
#if COMPANION_FEATURE_USB_MOTA_SOURCE
@@ -739,6 +745,10 @@ static bool hasObservableActiveUsbTerminalClient() {
}
static void cancelUsbSerialOperations() {
#if MESH_USB_LOGGING_AVAILABLE
// A cancelled reply must not enable diagnostics for the next USB host.
usb_logging_reply_pending = false;
#endif
// Contact enumeration uses the manager's pinned streaming route; delayed
// single replies capture their own route inside MyMesh. Cancel only USB's
// ownership so a simultaneous BLE/WiFi operation keeps running.
@@ -1230,6 +1240,16 @@ static void serviceUsbLoggingOwnership(bool logging_enabled) {
}
void MyMesh::applyUsbLoggingState(bool enabled) {
#if MESH_USB_LOGGING_AVAILABLE
if (usb_logging_reply_hold) {
usb_logging_reply_staged = true;
usb_logging_reply_state = enabled;
return;
}
// A newer explicit setting (including one from BLE/WiFi) wins over an
// earlier USB command whose acknowledgement is still backpressured.
usb_logging_reply_pending = false;
#endif
// Park the framed transport before opening the diagnostic gate, including
// TCP/browser commands which execute outside the mesh dispatcher.
if (enabled && usb_protocol_initialized) serviceUsbLoggingOwnership(true);
@@ -1237,6 +1257,52 @@ void MyMesh::applyUsbLoggingState(bool enabled) {
if (usb_protocol_initialized) serviceUsbLoggingOwnership(enabled);
}
void MyMesh::beginUsbLoggingReplyBarrier(BaseSerialInterface* route) {
#if MESH_USB_LOGGING_AVAILABLE
usb_logging_reply_hold = usb_protocol_initialized
&& route == &usb_serial_interface
&& !mesh::hasDedicatedUsbLoggingPort()
&& !usb_serial_interface.isPassthroughMode();
usb_logging_reply_staged = false;
#else
(void)route;
#endif
}
void MyMesh::endUsbLoggingReplyBarrier(bool reply_queued) {
#if MESH_USB_LOGGING_AVAILABLE
usb_logging_reply_hold = false;
if (usb_logging_reply_staged) {
// No response fallback: a full queue leaves logging unchanged for this
// boot. Its saved preference can still take effect at the next boot.
usb_logging_reply_pending = reply_queued;
usb_logging_reply_staged = false;
}
#else
(void)reply_queued;
#endif
}
static void serviceUsbLoggingReplyBarrier() {
#if MESH_USB_LOGGING_AVAILABLE
if (!usb_logging_reply_pending) return;
if (!usb_serial_interface.isConnected()
|| usb_serial_interface.isPassthroughMode()) {
usb_logging_reply_pending = false;
return;
}
// One nonblocking transport pass. Never wait on an unread USB host or
// include RX parser work in this check: a partial next command cannot hold
// the already queued acknowledgement. Once the complete frame is admitted,
// the ordered native FIFO sends it before any new terminal/log bytes.
usb_serial_interface.loop();
if (usb_serial_interface.isWriteBusy()) return;
const bool enabled = usb_logging_reply_state;
usb_logging_reply_pending = false;
the_mesh.applyUsbLoggingState(enabled);
#endif
}
static void expireUsbBinaryStartupProbeBeforeDispatch() {
const uint32_t now = millis();
if (!usb_binary_startup_probe.hasTimedOut(now)) return;
@@ -1257,6 +1323,9 @@ static void expireUsbBinaryStartupProbeBeforeDispatch() {
void MyMesh::applyUsbLoggingState(bool enabled) {
mesh::setUsbLoggingEnabled(enabled);
}
void MyMesh::beginUsbLoggingReplyBarrier(BaseSerialInterface*) {}
void MyMesh::endUsbLoggingReplyBarrier(bool) {}
#endif
#if defined(ENABLE_USB_INTERFACE) && (COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG))
@@ -3367,6 +3436,7 @@ void loop() {
#if defined(ENABLE_USB_INTERFACE)
serviceUsbTerminalHostSessionReset();
serviceUsbAsciiSessionDefault();
serviceUsbLoggingReplyBarrier();
serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled()); // Before framed dispatch.
#endif
// Identify nRF52 CDC 1 when a terminal opens it. Doing this on the connection
+64 -61
View File
@@ -5141,6 +5141,21 @@ void MyMesh::cancelPendingSerialOutput() {
void MyMesh::servicePendingSerialOutput() {
Stream& console = mesh::usbConsolePort();
const auto write_pending = [&]() {
const int available = console.availableForWrite();
if (available <= 0 || serial_log_pending_size == 0) return;
// A low-memory HWCDC setup can have a 256/512-byte ring. Never require a
// whole 640-byte stored line to fit that ring: retain and retry its suffix.
const size_t attempt = serial_log_pending_size < static_cast<size_t>(available)
? serial_log_pending_size : static_cast<size_t>(available);
size_t written = console.write(
reinterpret_cast<const uint8_t*>(serial_log_pending), attempt);
if (written > attempt) written = attempt;
serial_log_pending_size -= written;
if (written > 0 && serial_log_pending_size > 0) {
memmove(serial_log_pending, serial_log_pending + written, serial_log_pending_size);
}
};
if (serial_acl_next >= 0) {
if (serial_log_pending_size == 0) {
@@ -5168,63 +5183,58 @@ void MyMesh::servicePendingSerialOutput() {
}
}
}
if (console.availableForWrite() < static_cast<int>(serial_log_pending_size)) return;
size_t written = console.write(
reinterpret_cast<const uint8_t*>(serial_log_pending), serial_log_pending_size);
if (written > serial_log_pending_size) written = serial_log_pending_size;
serial_log_pending_size -= written;
if (written > 0 && serial_log_pending_size > 0) {
memmove(serial_log_pending, serial_log_pending + written, serial_log_pending_size);
}
write_pending();
return;
}
if (serial_recent_next >= 0) {
char record[64];
int length = 0;
const SimpleMeshTables::RecentRepeaterInfo* next_info = nullptr;
int next_index = -1;
if (serial_recent_header) {
length = snprintf(record, sizeof(record), "Recent repeaters (%d):\n",
serial_recent_count);
} else if (serial_recent_count == 0) {
length = snprintf(record, sizeof(record), "-none-\r\n");
} else if (serial_recent_next < serial_recent_count) {
const auto* tables = static_cast<const SimpleMeshTables*>(getTables());
const auto* info = tables ? tables->getNextRecentRepeaterBySortKey(
serial_recent_has_cursor ? &serial_recent_cursor : nullptr,
serial_recent_cursor_index, next_index) : nullptr;
if (info == nullptr) {
if (serial_log_pending_size == 0) {
char record[64];
int length = 0;
int next_index = -1;
if (serial_recent_header) {
length = snprintf(record, sizeof(record), "Recent repeaters (%d):\n",
serial_recent_count);
} else if (serial_recent_count == 0) {
length = snprintf(record, sizeof(record), "-none-\r\n");
} else if (serial_recent_next < serial_recent_count) {
const auto* tables = static_cast<const SimpleMeshTables*>(getTables());
const auto* info = tables ? tables->getNextRecentRepeaterBySortKey(
serial_recent_has_cursor ? &serial_recent_cursor : nullptr,
serial_recent_cursor_index, next_index) : nullptr;
if (info == nullptr) {
serial_recent_next = -1;
return;
}
char prefix[MAX_ROUTE_HASH_BYTES * 2 + 1];
char snr[12];
mesh::Utils::toHex(prefix, info->prefix, info->prefix_len);
formatLocalSnrX4(snr, sizeof(snr), info->snr_x4);
length = snprintf(record, sizeof(record), "%s,%s%s\n", prefix,
snr[0] == '-' ? "" : " ", snr);
// Snapshot the row before a short write. Its next pass must retry only
// the retained suffix, even if the live recent-repeater table changes.
serial_recent_cursor = *info;
serial_recent_cursor_index = next_index;
serial_recent_has_cursor = true;
} else {
serial_recent_next = -1;
return;
}
next_info = info;
char prefix[MAX_ROUTE_HASH_BYTES * 2 + 1];
char snr[12];
mesh::Utils::toHex(prefix, info->prefix, info->prefix_len);
formatLocalSnrX4(snr, sizeof(snr), info->snr_x4);
length = snprintf(record, sizeof(record), "%s,%s%s\n", prefix,
snr[0] == '-' ? "" : " ", snr);
} else {
serial_recent_next = -1;
return;
// One bounded row per pass. HWCDC can return a short write even after a
// capacity preflight because another diagnostic task shares its TX ring.
if (length <= 0 || static_cast<size_t>(length) >= sizeof(record)) {
serial_recent_next = -1;
return;
}
memcpy(serial_log_pending, record, length);
serial_log_pending_size = static_cast<size_t>(length);
}
// One complete row per pass, admitted atomically only when it fits.
if (length <= 0 || static_cast<size_t>(length) >= sizeof(record)) {
serial_recent_next = -1;
return;
}
if (console.availableForWrite() < length
|| console.write(reinterpret_cast<const uint8_t*>(record), length)
!= static_cast<size_t>(length)) return;
write_pending();
if (serial_log_pending_size != 0) return;
if (serial_recent_header) {
serial_recent_header = false;
} else {
if (next_info != nullptr) {
serial_recent_cursor = *next_info;
serial_recent_cursor_index = next_index;
serial_recent_has_cursor = true;
}
if (serial_recent_count == 0 || ++serial_recent_next >= serial_recent_count) {
serial_recent_next = -1;
}
@@ -5234,11 +5244,13 @@ void MyMesh::servicePendingSerialOutput() {
if (!serial_log_active) {
// CommonCLI's synchronous EOF is suppressed until the queued dump ends.
static const char eof[] = " -> EOF\r\n";
if (serial_log_eof_pending
&& console.availableForWrite() >= static_cast<int>(sizeof(eof) - 1)
&& console.write(reinterpret_cast<const uint8_t*>(eof), sizeof(eof) - 1)
== sizeof(eof) - 1) {
serial_log_eof_pending = false;
if (serial_log_eof_pending) {
if (serial_log_pending_size == 0) {
memcpy(serial_log_pending, eof, sizeof(eof) - 1);
serial_log_pending_size = sizeof(eof) - 1;
}
write_pending();
if (serial_log_pending_size == 0) serial_log_eof_pending = false;
}
return;
}
@@ -5290,16 +5302,7 @@ void MyMesh::servicePendingSerialOutput() {
serial_log_pending[serial_log_pending_size++] = '\n';
}
}
if (serial_log_pending_size > 0
&& console.availableForWrite() >= static_cast<int>(serial_log_pending_size)) {
size_t written = console.write(
reinterpret_cast<const uint8_t*>(serial_log_pending), serial_log_pending_size);
if (written > serial_log_pending_size) written = serial_log_pending_size;
serial_log_pending_size -= written;
if (written > 0 && serial_log_pending_size > 0) {
memmove(serial_log_pending, serial_log_pending + written, serial_log_pending_size);
}
}
write_pending();
if (serial_log_remaining == 0 && serial_log_pending_size == 0) {
serial_log_dump.close();
serial_log_active = false;
+1
View File
@@ -975,6 +975,7 @@ protected:
char interval_str[12];
sprintf(interval_str, "%u", _prefs.gps_interval);
sensors.setSettingValue("gps_interval", interval_str);
sensors.applyGpsTimeSyncInterval(_prefs.gps_sync_interval_hours);
}
#endif
+23 -23
View File
@@ -1712,6 +1712,20 @@ void MyMesh::cancelPendingSerialOutput() {
void MyMesh::servicePendingSerialOutput() {
Stream& console = mesh::usbConsolePort();
const auto write_pending = [&]() {
const int available = console.availableForWrite();
if (available <= 0 || serial_log_pending_size == 0) return;
// Retain a larger stored line across low-memory 256/512-byte HWCDC rings.
const size_t attempt = serial_log_pending_size < static_cast<size_t>(available)
? serial_log_pending_size : static_cast<size_t>(available);
size_t written = console.write(
reinterpret_cast<const uint8_t*>(serial_log_pending), attempt);
if (written > attempt) written = attempt;
serial_log_pending_size -= written;
if (written > 0 && serial_log_pending_size > 0) {
memmove(serial_log_pending, serial_log_pending + written, serial_log_pending_size);
}
};
if (serial_acl_next >= 0) {
if (serial_log_pending_size == 0) {
if (serial_acl_header) {
@@ -1738,24 +1752,19 @@ void MyMesh::servicePendingSerialOutput() {
}
}
}
if (console.availableForWrite() < static_cast<int>(serial_log_pending_size)) return;
size_t written = console.write(
reinterpret_cast<const uint8_t*>(serial_log_pending), serial_log_pending_size);
if (written > serial_log_pending_size) written = serial_log_pending_size;
serial_log_pending_size -= written;
if (written > 0 && serial_log_pending_size > 0) {
memmove(serial_log_pending, serial_log_pending + written, serial_log_pending_size);
}
write_pending();
return;
}
if (!serial_log_active) {
// CommonCLI's synchronous EOF is suppressed until the queued dump ends.
static const char eof[] = " -> EOF\r\n";
if (serial_log_eof_pending
&& console.availableForWrite() >= static_cast<int>(sizeof(eof) - 1)
&& console.write(reinterpret_cast<const uint8_t*>(eof), sizeof(eof) - 1)
== sizeof(eof) - 1) {
serial_log_eof_pending = false;
if (serial_log_eof_pending) {
if (serial_log_pending_size == 0) {
memcpy(serial_log_pending, eof, sizeof(eof) - 1);
serial_log_pending_size = sizeof(eof) - 1;
}
write_pending();
if (serial_log_pending_size == 0) serial_log_eof_pending = false;
}
return;
}
@@ -1807,16 +1816,7 @@ void MyMesh::servicePendingSerialOutput() {
serial_log_pending[serial_log_pending_size++] = '\n';
}
}
if (serial_log_pending_size > 0
&& console.availableForWrite() >= static_cast<int>(serial_log_pending_size)) {
size_t written = console.write(
reinterpret_cast<const uint8_t*>(serial_log_pending), serial_log_pending_size);
if (written > serial_log_pending_size) written = serial_log_pending_size;
serial_log_pending_size -= written;
if (written > 0 && serial_log_pending_size > 0) {
memmove(serial_log_pending, serial_log_pending + written, serial_log_pending_size);
}
}
write_pending();
if (serial_log_remaining == 0 && serial_log_pending_size == 0) {
serial_log_dump.close();
serial_log_active = false;
+1
View File
@@ -408,6 +408,7 @@ protected:
char interval_str[12];
sprintf(interval_str, "%u", _prefs.gps_interval);
sensors.setSettingValue("gps_interval", interval_str);
sensors.applyGpsTimeSyncInterval(_prefs.gps_sync_interval_hours);
}
#endif
+1
View File
@@ -252,6 +252,7 @@ private:
char interval_str[12];
sprintf(interval_str, "%u", _prefs.gps_interval);
sensors.setSettingValue("gps_interval", interval_str);
sensors.applyGpsTimeSyncInterval(_prefs.gps_sync_interval_hours);
}
#endif
};
+1
View File
@@ -88,6 +88,7 @@ extra_scripts =
pre:scripts/ota_device_deflate_role.py
pre:scripts/portable_esp32_link.py
pre:scripts/esp32_ota_heap_context.py
pre:scripts/esp32_usb_session_fix.py
merge-bin.py
post:tools/mota/pio_endf.py
post:scripts/check_esp32_dram.py
+102
View File
@@ -0,0 +1,102 @@
"""Build-local owner-task hooks for Arduino-ESP32 native CDC session cleanup.
Do not patch the shared SDK. Fail closed when the native-CDC implementation
changes: the endpoint query must match the descriptor actually compiled.
"""
from pathlib import Path
GUARD = "#if ARDUINO_USB_CDC_ON_BOOT && !ARDUINO_USB_MODE\n"
CDC_INCLUDE = '#include "esp32-hal-tinyusb.h"\n'
CDC_HOOKS = GUARD + '''#include "device/usbd_pvt.h"
extern "C" void meshEsp32TinyUsbCdcLineState(bool dtr) __attribute__((weak));
extern "C" bool meshEsp32TinyUsbAcceptRx() __attribute__((weak));
extern "C" bool meshEsp32TinyUsbTxPending() {
return usbd_edpt_busy(0, 0x84);
}
#endif
'''
LINE_STATE = "void tud_cdc_line_state_cb(uint8_t itf, bool dtr, bool rts)\n{\n"
LINE_HOOK = GUARD + ''' // Capture the close synchronously, before another host can enqueue RX.
if (itf == 0 && meshEsp32TinyUsbCdcLineState) {
meshEsp32TinyUsbCdcLineState(dtr);
}
#endif
'''
RX = "void USBCDC::_onRX(){\n"
RX_HOOK = GUARD + ''' if (itf == 0 && meshEsp32TinyUsbAcceptRx && !meshEsp32TinyUsbAcceptRx()) {
tud_cdc_n_read_flush(itf);
return;
}
#endif
'''
USB_INCLUDE = '#include "esp32-hal-tinyusb.h"\n'
USB_HOOKS = GUARD + '''extern "C" void meshEsp32TinyUsbDeviceSessionBoundary(bool mounted) __attribute__((weak));
#endif
'''
MOUNT = "void tud_mount_cb(void){\n"
UNMOUNT = "void tud_umount_cb(void){\n"
def patched_cdc_source(source):
source = source.replace("\r\n", "\n")
descriptor = "TUD_CDC_DESCRIPTOR(*itf, str_index, 0x85, 64, 0x03, 0x84, 64)"
if (source.count(CDC_HOOKS) == 1 and source.count(LINE_HOOK) == 1
and source.count(RX_HOOK) == 1 and source.count(descriptor) == 1):
return source
if (source.count(CDC_INCLUDE) != 1 or source.count(LINE_STATE) != 1
or source.count(RX) != 1 or source.count(descriptor) != 1
or "meshEsp32TinyUsb" in source):
raise RuntimeError("ESP32 USB session fix: changed native CDC/endpoint layout; review SDK update")
return (source.replace(CDC_INCLUDE, CDC_INCLUDE + CDC_HOOKS)
.replace(LINE_STATE, LINE_STATE + LINE_HOOK)
.replace(RX, RX + RX_HOOK))
def patched_usb_source(source):
source = source.replace("\r\n", "\n")
mount_hook = GUARD + " if (meshEsp32TinyUsbDeviceSessionBoundary) meshEsp32TinyUsbDeviceSessionBoundary(true);\n#endif\n"
unmount_hook = GUARD + " if (meshEsp32TinyUsbDeviceSessionBoundary) meshEsp32TinyUsbDeviceSessionBoundary(false);\n#endif\n"
if (source.count(USB_HOOKS) == 1 and source.count(mount_hook) == 1
and source.count(unmount_hook) == 1):
return source
if (source.count(USB_INCLUDE) != 1 or source.count(MOUNT) != 1
or source.count(UNMOUNT) != 1 or "meshEsp32TinyUsb" in source):
raise RuntimeError("ESP32 USB session fix: changed USB lifecycle callbacks; review SDK update")
return (source.replace(USB_INCLUDE, USB_INCLUDE + USB_HOOKS)
.replace(MOUNT, MOUNT + mount_hook)
.replace(UNMOUNT, UNMOUNT + unmount_hook))
def native_primary_enabled(build_env):
defines = {}
for definition in build_env.get("CPPDEFINES", []):
if isinstance(definition, (tuple, list)):
defines[definition[0]] = str(definition[1])
else:
defines[str(definition)] = "1"
return (defines.get("ARDUINO_USB_CDC_ON_BOOT") == "1"
and defines.get("ARDUINO_USB_MODE") == "0")
def replace_source(build_env, node):
if not native_primary_enabled(build_env):
return node
source = Path(node.srcnode().get_abspath())
patcher = patched_cdc_source if source.name == "USBCDC.cpp" else patched_usb_source
patched = patcher(source.read_text(encoding="utf-8"))
destination = Path(build_env.subst("$BUILD_DIR")) / "patched-esp32-usb" / source.name
destination.parent.mkdir(parents=True, exist_ok=True)
if not destination.exists() or destination.read_text(encoding="utf-8") != patched:
destination.write_text(patched, encoding="utf-8")
return build_env.File(str(destination))
def install(build_env):
build_env.AddBuildMiddleware(replace_source, "*cores*esp32*USBCDC.cpp")
build_env.AddBuildMiddleware(replace_source, "*cores*esp32*USB.cpp")
if "Import" in globals():
Import("env")
install(env)
+45
View File
@@ -3,6 +3,7 @@
#include "PrefsSaveReplyGuard.h"
#include <helpers/ui/DisplayPowerSettings.h>
#include "CLICommandUtils.h"
#include "GpsPowerPolicy.h"
#include "FloodAdvertCLI.h"
#include "StorageLayout.h"
#include "radiolib/RadioPowerLimits.h"
@@ -768,6 +769,7 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) {
_prefs->bridge_uart = 0;
#endif
_prefs->bridge_format = mesh::bridge::ESPNOW_FORMAT_WRAPPED;
_prefs->gps_sync_interval_hours = 0;
#ifdef WITH_MQTT_BRIDGE
bool node_prefs_needs_migration = false;
@@ -1321,6 +1323,12 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
if (file.available() >= (int)sizeof(_prefs->espnow_bridge_enabled)) {
file.read((uint8_t *)&_prefs->espnow_bridge_enabled,
sizeof(_prefs->espnow_bridge_enabled));
if (file.available() >= (int)sizeof(_prefs->gps_sync_interval_hours)) {
uint16_t hours = 0;
if (file.read((uint8_t *)&hours, sizeof(hours)) == sizeof(hours)) {
_prefs->gps_sync_interval_hours = hours;
}
}
}
}
}
@@ -1427,6 +1435,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
_prefs->reboot_interval = constrain(_prefs->reboot_interval, 0, 255);
_prefs->gps_enabled = constrain(_prefs->gps_enabled, 0, 1);
_prefs->gps_sync_interval_hours = constrain(_prefs->gps_sync_interval_hours,
0, mesh::gps::MAX_SYNC_INTERVAL_HOURS);
_prefs->advert_loc_policy = constrain(_prefs->advert_loc_policy, 0, 2);
_prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean
@@ -1645,6 +1655,7 @@ static bool writeCommonPrefsImage(Writer& writer, NodePrefs* prefs) {
WRITE_COMMON_PREFS(&prefs->bridge_format); // 863
WRITE_COMMON_PREFS(&prefs->primary_radio_preamble); // appended primary tuple field
WRITE_COMMON_PREFS(&prefs->espnow_bridge_enabled); // appended Full ESP-NOW intent
WRITE_COMMON_PREFS(&prefs->gps_sync_interval_hours); // appended GPS sync cadence
#undef WRITE_COMMON_PREFS_BYTES
#undef WRITE_COMMON_PREFS
@@ -1803,6 +1814,7 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) {
file.write((uint8_t *)&_prefs->bridge_format, sizeof(_prefs->bridge_format)); // 863
file.write((uint8_t *)&_prefs->primary_radio_preamble, sizeof(_prefs->primary_radio_preamble)); // appended
file.write((uint8_t *)&_prefs->espnow_bridge_enabled, sizeof(_prefs->espnow_bridge_enabled)); // appended
file.write((uint8_t *)&_prefs->gps_sync_interval_hours, sizeof(_prefs->gps_sync_interval_hours)); // appended GPS sync cadence
_common_save_succeeded = file.commit();
if (!_common_save_succeeded) {
@@ -3343,6 +3355,28 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
return;
}
#if ENV_INCLUDE_GPS == 1
if (strncmp(config, "gps.sync.interval", 17) == 0
&& (config[17] == 0 || config[17] == ' ' || config[17] == '\t')) {
const char* value = config + 17;
while (*value == ' ' || *value == '\t') ++value;
uint16_t hours = 0;
if (!mesh::gps::parseSyncIntervalHours(value, hours)) {
strcpy(reply, "Error: GPS sync interval must be 1..336 hours");
} else if (_sensors->getLocationProvider() == nullptr) {
strcpy(reply, "Error: GPS unavailable");
} else {
const uint16_t previous = _prefs->gps_sync_interval_hours;
_prefs->gps_sync_interval_hours = hours;
if (!trySavePrefs()) {
_prefs->gps_sync_interval_hours = previous;
strcpy(reply, "Error: GPS sync interval could not be saved");
} else {
_sensors->applyGpsTimeSyncInterval(hours);
snprintf(reply, 160, "OK - GPS sync interval %u hours (saved)", (unsigned)hours);
}
}
return;
}
if (strncmp(config, "gps ", 4) == 0) {
if (strcmp(config + 4, "on") != 0 && strcmp(config + 4, "off") != 0) {
strcpy(reply, "Error: use set gps on|off");
@@ -4677,6 +4711,17 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
return;
}
#if ENV_INCLUDE_GPS == 1
if (strcmp(config, "gps.sync.interval") == 0) {
auto* provider = _sensors->getLocationProvider();
if (provider == nullptr) {
strcpy(reply, "Error: GPS unavailable");
} else {
const uint16_t hours = provider->getTimeSyncIntervalHours();
if (hours == 0) strcpy(reply, "> default (board GPS sync policy)");
else snprintf(reply, 160, "> %u hours", (unsigned)hours);
}
return;
}
if (strcmp(config, "gps") == 0) {
char gps_command[] = "gps";
handleCommand(sender_timestamp, gps_command, reply);
+3
View File
@@ -196,6 +196,8 @@ public:
// Old preference images did not have this byte; they default to enabled to
// retain the previous combined-Full behavior after an upgrade.
uint8_t espnow_bridge_enabled = 1;
// Appended after the ESP-NOW intent. Zero keeps the board's legacy cadence.
uint16_t gps_sync_interval_hours = 0;
uint8_t retry_preset = 0;
uint8_t direct_retry_attempts = 0;
uint16_t direct_retry_base_ms = 0;
@@ -345,6 +347,7 @@ private:
void structure() override {
def("en", _parent->gps_enabled);
def("int", _parent->gps_interval);
def("sync_hours", _parent->gps_sync_interval_hours);
def("adv_loc", _parent->advert_loc_policy);
}
+16
View File
@@ -7,6 +7,22 @@ namespace gps {
static const uint32_t DEFAULT_UPDATE_INTERVAL_SEC = 1;
static const uint32_t MAX_UPDATE_INTERVAL_SEC = 24UL * 60UL * 60UL;
static const uint16_t MAX_SYNC_INTERVAL_HOURS = 14U * 24U;
// Saturate while parsing, not after an overflowing atoi/strtoul. Still inspect
// every character so an oversized value with trailing junk is never accepted.
inline bool parseSyncIntervalHours(const char* value, uint16_t& configured_hours) {
if (value == nullptr || *value == 0) return false;
uint16_t parsed = 0;
for (const char* p = value; *p; ++p) {
if (*p < '0' || *p > '9') return false;
const uint16_t next = parsed * 10U + static_cast<uint16_t>(*p - '0');
parsed = next > MAX_SYNC_INTERVAL_HOURS ? MAX_SYNC_INTERVAL_HOURS : next;
}
if (parsed == 0) return false;
configured_hours = parsed;
return true;
}
inline bool parseUpdateInterval(const char* value, uint32_t& configured_sec) {
if (value == nullptr || *value == 0) return false;
+11
View File
@@ -73,6 +73,14 @@ protected:
#endif
public:
void applyGpsTimeSyncInterval(uint16_t hours) {
#if ENV_INCLUDE_GPS
LocationProvider* location = getLocationProvider();
if (location != nullptr) location->setTimeSyncIntervalHours(hours);
#else
(void)hours;
#endif
}
struct VoltageSensorReading {
uint8_t channel;
float voltage;
@@ -108,6 +116,9 @@ public:
// for the manager's established transport ownership.
if (!gps_transport_available || !telemetryGpsDetected()) return false;
LocationProvider* location = getLocationProvider();
if (location != nullptr && location->getTimeSyncIntervalHours() != 0) {
min_interval_secs = static_cast<uint64_t>(location->getTimeSyncIntervalHours()) * 3600UL;
}
return location != nullptr && location->getGPSPowerSaving()
&& location->requestTimeSync(min_interval_secs);
#else
+134 -39
View File
@@ -16,6 +16,8 @@
#endif
#if MESH_ESP32_TINYUSB_NONBLOCKING
#include "esp32-hal-tinyusb.h"
// Build-local Arduino hook queries its actual CDC descriptor's IN endpoint.
extern "C" bool meshEsp32TinyUsbTxPending();
#endif
#if MESH_ESP32_HWCDC_SESSION_GUARD
#include "UsbAsciiBinarySwitch.h"
@@ -25,6 +27,7 @@
(defined(ENABLE_USB_INTERFACE) || defined(OTA_FOLDER_SERIAL))
#define MESH_NRF52_PRIMARY_USB_NONBLOCKING 1
#include <Adafruit_TinyUSB.h>
#include <nrf.h>
// Build-local CDC query installed by scripts/nrf52_usb_power_fix.py. It does
// not clear/cancel an endpoint or change its USB bulk data-toggle state.
extern "C" bool mesh_tud_cdc_n_tx_pending(uint8_t instance);
@@ -90,27 +93,44 @@ static std::atomic<bool> esp32_tinyusb_terminal_discard_pending{false};
static std::atomic<uint32_t> esp32_tinyusb_terminal_dropped_bytes{0};
static uint32_t esp32_tinyusb_terminal_reported_dropped_bytes = 0;
static uint32_t esp32_tinyusb_taken_reset_generation = 0;
static bool esp32_tinyusb_event_handler_registered = false;
static bool esp32_tinyusb_was_connected = false;
static std::atomic<bool> esp32_tinyusb_was_connected{false};
// 0: ordinary; 1: detached; 2: awaiting fresh mount; 3: preparing quarantine.
static std::atomic<uint8_t> esp32_tinyusb_reenumeration_state{0};
static std::atomic<bool> esp32_tinyusb_owner_dtr{false};
static std::atomic<uint32_t> esp32_tinyusb_reattach_after{0};
static constexpr uint32_t esp32_tinyusb_detach_millis = 20;
static bool canAccessEsp32TinyUsb(void*) {
return !xPortInIsrContext() && tud_cdc_n_connected(0)
return !xPortInIsrContext()
&& esp32_tinyusb_reenumeration_state.load(std::memory_order_acquire) == 0
&& tud_cdc_n_connected(0)
&& esp32_tinyusb_clean_generation.load(std::memory_order_acquire)
== esp32_tinyusb_reset_generation.load(std::memory_order_acquire);
}
static void handleEsp32TinyUsbEvent(void*, esp_event_base_t, int32_t event_id,
void* event_data) {
// This is the Arduino event task, not TinyUSB's owner. Only publish an
// epoch: queue cleanup and endpoint access remain in application service.
bool closed = event_id == ARDUINO_USB_CDC_DISCONNECTED_EVENT;
if (event_id == ARDUINO_USB_CDC_LINE_STATE_EVENT && event_data != nullptr) {
const auto* event = static_cast<const arduino_usb_cdc_event_data_t*>(event_data);
closed = !event->line_state.dtr;
}
if (closed) {
esp32_tinyusb_reset_generation.fetch_add(1, std::memory_order_acq_rel);
}
static void requestEsp32TinyUsbReenumeration() {
uint8_t expected = 0;
if (!esp32_tinyusb_reenumeration_state.compare_exchange_strong(
expected, 3, std::memory_order_acq_rel)) return;
// The pinned ESP32 S2/S3 DCD disconnect is one bounded soft-disconnect
// register write, not a send into TinyUSB's owner queue. Isolate an armed
// old packet immediately; only the application loop may reattach later.
(void)tud_disconnect();
esp32_tinyusb_reattach_after.store(millis() + esp32_tinyusb_detach_millis,
std::memory_order_release);
// Publish only a fully prepared detach. The other core must never connect
// before this callback has actually disconnected and started its deadline.
esp32_tinyusb_reenumeration_state.store(1, std::memory_order_release);
}
static void purgeEsp32TinyUsbRx() {
// Owner-task session hooks run before new-host RX callbacks. A fixed snapshot
// bounds this purge even if an application reader consumes bytes concurrently.
int pending = Serial.available();
while (pending-- > 0) (void)Serial.read();
// At unmount the class endpoints have already been reset. read_flush also
// rearms OUT, so it must not address an unopened/reset endpoint there.
if (tud_mounted()) tud_cdc_n_read_flush(0);
}
class Esp32TinyUsbFifoStream : public Stream {
@@ -222,18 +242,54 @@ static Esp32TinyUsbBufferedStream<false> buffered_esp32_tinyusb_terminal_port;
static void clearEsp32TinyUsbTx(void*) {
(void)tud_cdc_n_write_clear(0);
if (tud_mounted() && meshEsp32TinyUsbTxPending()) {
requestEsp32TinyUsbReenumeration();
}
}
static void closeEsp32TinyUsbSession(bool device_boundary) {
esp32_tinyusb_reset_generation.fetch_add(1, std::memory_order_acq_rel);
// Do this synchronously in TinyUSB's owner, never in its delayed Arduino
// event handler or the main-loop cleanup (which could purge fresh RX).
purgeEsp32TinyUsbRx();
if (!device_boundary
&& !nonblocking_esp32_tinyusb_port.tryRunExclusive(clearEsp32TinyUsbTx)) {
// A writer already in flight can still arm old bytes after the close.
// Physically quarantine it instead of racing an endpoint abort/FIFO purge.
requestEsp32TinyUsbReenumeration();
}
}
static void purgeEsp32TinyUsbDetachedQueues(void*) {
purgeEsp32TinyUsbRx();
(void)tud_cdc_n_write_clear(0);
}
static void serviceEsp32TinyUsbPorts() {
if (xPortInIsrContext()) return;
if (esp32_tinyusb_queue_busy.test_and_set(std::memory_order_acquire)) return;
const uint8_t reenumeration =
esp32_tinyusb_reenumeration_state.load(std::memory_order_acquire);
if (reenumeration != 0) {
if (reenumeration == 1
&& (int32_t)(millis() - esp32_tinyusb_reattach_after.load(
std::memory_order_acquire)) >= 0
&& nonblocking_esp32_tinyusb_port.tryRunExclusive(
purgeEsp32TinyUsbDetachedQueues)) {
// Publish before connect: a fast owner mount callback can run immediately.
esp32_tinyusb_reenumeration_state.store(2, std::memory_order_release);
(void)tud_connect();
}
esp32_tinyusb_queue_busy.clear(std::memory_order_release);
return; // Only the fresh owner-task mount hook can reopen this gate.
}
// Polling also catches a physical disconnect without a CDC line-state
// event. Events capture quick close/reopen pairs between service calls.
const bool connected = tud_cdc_n_connected(0);
if (esp32_tinyusb_was_connected && !connected) {
if (esp32_tinyusb_was_connected.exchange(connected,
std::memory_order_acq_rel) && !connected) {
esp32_tinyusb_reset_generation.fetch_add(1, std::memory_order_acq_rel);
}
esp32_tinyusb_was_connected = connected;
const uint32_t generation =
esp32_tinyusb_reset_generation.load(std::memory_order_acquire);
if (generation != esp32_tinyusb_clean_generation.load(std::memory_order_acquire)) {
@@ -244,8 +300,8 @@ static void serviceEsp32TinyUsbPorts() {
esp32_tinyusb_text_queue.discardPending();
esp32_tinyusb_terminal_reported_dropped_bytes =
esp32_tinyusb_terminal_dropped_bytes.load(std::memory_order_relaxed);
// Do not purge Serial's RX queue here: the new host may already have sent
// its first query. Protocol owners reset their partial parser separately.
// The synchronous owner hook already purged old RX. Keep the new host's
// first query, while protocol owners reset their partial parser separately.
esp32_tinyusb_clean_generation.store(generation, std::memory_order_release);
}
if (esp32_tinyusb_terminal_discard_pending.exchange(false,
@@ -442,6 +498,9 @@ static constexpr uint32_t primary_usb_session_settle_millis = 8;
static std::atomic<uint32_t> primary_usb_reset_settle_until{0};
static std::atomic<bool> nrf52_usb_reenumeration_pending{false};
static std::atomic<uint32_t> nrf52_usb_reattach_after{0};
// Only the application service loop owns this phase. USB callbacks merely
// quarantine the pull-up and publish the atomic request below.
static bool nrf52_usb_stack_detach_issued = false;
static constexpr uint32_t nrf52_usb_detach_millis = 20;
static void requestNrf52UsbSessionReenumeration() {
@@ -455,9 +514,14 @@ static void requestNrf52UsbSessionReenumeration() {
std::memory_order_acq_rel)) return;
primary_usb_line_state_dtr.store(false, std::memory_order_release);
primary_usb_reset_generation.fetch_add(1, std::memory_order_acq_rel);
nrf52_usb_reattach_after.store(millis() + nrf52_usb_detach_millis,
std::memory_order_release);
TinyUSBDevice.detach();
// Match the Nordic driver's bounded physical-disconnect operation now so
// an armed packet cannot reach a rapidly reopened host. Do NOT call
// TinyUSBDevice.detach() from this CDC/SOF callback: dcd_disconnect also
// queues UNPLUGGED with an infinite FreeRTOS send timeout. A full event
// queue would then make its sole consumer wait forever on its own queue.
NRF_USBD->USBPULLUP = 0;
__ISB();
__DSB();
}
static void clearNrf52UsbTxForSession(uint8_t instance) {
@@ -468,13 +532,26 @@ static void clearNrf52UsbTxForSession(uint8_t instance) {
}
static void serviceNrf52UsbSessionReenumeration() {
if (!nrf52_usb_reenumeration_pending.load(std::memory_order_acquire)
|| (int32_t)(millis() - nrf52_usb_reattach_after.load(
if (!nrf52_usb_reenumeration_pending.load(std::memory_order_acquire)) return;
if (!nrf52_usb_stack_detach_issued) {
// Called only from the application loop, never a TinyUSB owner callback.
// The owner can therefore drain a full event queue while this supported
// detach operation enqueues the reset, instead of blocking on itself.
TinyUSBDevice.detach();
nrf52_usb_stack_detach_issued = true;
// Start the quiet interval at the actual stack detach, not at a possibly
// much earlier callback request while the application loop was occupied.
nrf52_usb_reattach_after.store(millis() + nrf52_usb_detach_millis,
std::memory_order_release);
return;
}
if ((int32_t)(millis() - nrf52_usb_reattach_after.load(
std::memory_order_acquire)) < 0
|| tud_mounted()) return;
// Waiting for unmount as well as the quiet interval ensures TinyUSB's owner
// has consumed the unplug event. Never reattach with its old class state.
TinyUSBDevice.attach();
nrf52_usb_stack_detach_issued = false;
nrf52_usb_reenumeration_pending.store(false, std::memory_order_release);
}
@@ -825,18 +902,12 @@ bool isUsbLoggingEnabled() {
}
void setUsbLoggingEnabled(bool enabled) {
#if MESH_ESP32_TINYUSB_NONBLOCKING
const bool was_enabled = isUsbLoggingEnabled();
#endif
usb_logging_enabled.store(enabled, std::memory_order_relaxed);
usb_logging_preference_known.store(true, std::memory_order_relaxed);
setPlatformDebugOutputEnabled(enabled);
#if MESH_ESP32_TINYUSB_NONBLOCKING
// Text shares one chronological queue. When leaving logging mode, discard
// its residual application bytes before later Binary/mOTA traffic can start.
// An in-flight producer is gated immediately and cleaned by the next service.
if (was_enabled && !enabled) discardUsbTerminalOutput();
#endif
// Turning diagnostics off is not a protocol switch. Retain functional
// replies in the shared text queue; actual Binary/mOTA ownership changes
// explicitly call discardUsbTerminalOutput() after their bounded barrier.
}
bool saveUsbLoggingBootPreference(bool enabled) {
@@ -875,12 +946,6 @@ void beginUsbLoggingPort() {
// ESP32 Companion stream; setUsbLoggingEnabled() restores them only when the
// saved setting explicitly enables logging.
setPlatformDebugOutputEnabled(isUsbLoggingEnabled());
#if MESH_ESP32_TINYUSB_NONBLOCKING
if (!esp32_tinyusb_event_handler_registered) {
Serial.onEvent(ARDUINO_USB_CDC_ANY_EVENT, handleEsp32TinyUsbEvent);
esp32_tinyusb_event_handler_registered = true;
}
#endif
#if MESH_ESP32_HWCDC_SESSION_GUARD
// If every pre-begin resize failed, begin() may have recovered by allocating
// HWCDC's built-in ring. Discover it without replacing a live ISR-owned ring.
@@ -1332,6 +1397,36 @@ const char* usbLoggingPortDescription() {
} // namespace mesh
#if MESH_ESP32_TINYUSB_NONBLOCKING
// These strong bridges are called synchronously by the build-local framework
// copy, not by Arduino's delayed event queue. Do not include weak declarations
// in this translation unit or these definitions would inherit that attribute.
extern "C" void meshEsp32TinyUsbCdcLineState(bool dtr) {
// Never use USBCDC::dtr here: a delayed framework unplug event can overwrite
// that member after a new host opens. These samples come from the owner.
const bool previous = mesh::esp32_tinyusb_owner_dtr.exchange(
dtr, std::memory_order_acq_rel);
if (previous != dtr) mesh::closeEsp32TinyUsbSession(dtr);
}
extern "C" bool meshEsp32TinyUsbAcceptRx() {
// A late old OUT completion while DTR is low is not fresh-host input.
return mesh::esp32_tinyusb_reenumeration_state.load(std::memory_order_acquire) == 0
&& tud_cdc_n_connected(0);
}
extern "C" void meshEsp32TinyUsbDeviceSessionBoundary(bool mounted) {
mesh::closeEsp32TinyUsbSession(true);
mesh::esp32_tinyusb_owner_dtr.store(false, std::memory_order_release);
mesh::esp32_tinyusb_was_connected.store(false, std::memory_order_release);
if (mounted) {
uint8_t expected = 2;
(void)mesh::esp32_tinyusb_reenumeration_state.compare_exchange_strong(
expected, 0, std::memory_order_acq_rel);
}
}
#endif
#if defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE)
// A protocol-only, weak-declaration-free translation unit supplies strong
// versions of TinyUSB's callbacks and forwards here. Preserve Adafruit's CDC0
+5 -1
View File
@@ -1,6 +1,7 @@
#pragma once
#include <ESP32_CH390.h>
#include <helpers/UsbLogging.h>
/**
* Bring up the repository's CH390 lwIP interface from the board build flags.
@@ -16,7 +17,10 @@ static inline bool beginConfiguredCH390(const char* hostname = nullptr) {
config.int_gpio = ETH_INT_PIN;
if (hostname && hostname[0] != '\0') {
if (!CH390.setHostname(hostname)) {
Serial.printf("Network: invalid Ethernet hostname %s\n", hostname);
if (mesh::isUsbLoggingEnabled()
&& mesh::usbLoggingPort().availableForWrite() > 0) {
mesh::usbLoggingPort().printf("Network: invalid Ethernet hostname %s\n", hostname);
}
}
}
if (!CH390.begin(config)) return false;
@@ -419,7 +419,6 @@ class RAK12500LocationProvider : public LocationProvider {
unsigned long _next_time_check = 0;
unsigned long _last_time_sync = 0;
uint8_t _valid_time_samples = 0;
static const unsigned long TIME_SYNC_INTERVAL = 1800000;
int _pin_en = -1;
public:
void setRTCClock(mesh::RTCClock* clock) { _clock = clock; }
@@ -480,7 +479,8 @@ public:
}
if (!_time_sync_needed && _clock != NULL
&& (unsigned long)(now - _last_time_sync) > TIME_SYNC_INTERVAL) {
&& static_cast<uint32_t>(now - _last_time_sync)
>= periodicTimeSyncIntervalMillis()) {
_time_sync_needed = true;
}
if (_time_sync_needed && _clock != NULL && _valid_time_samples > 2) {
@@ -1401,7 +1401,7 @@ bool EnvironmentSensorManager::gpsIsAwake(uint8_t ioPin,
void EnvironmentSensorManager::armGpsPowerSavingCycle() {
if (!powersaving_enabled || !_location->getGPSPowerSaving()) return;
_location->syncTime();
_location->syncTimeForPowerSavingCycle();
_location->setNextGPSOn(0);
_location->setNextSleep();
}
+39 -1
View File
@@ -1,6 +1,7 @@
#pragma once
#include "Mesh.h"
#include <helpers/GpsPowerPolicy.h>
#include <stddef.h>
#include <stdio.h>
#include <stdint.h>
@@ -22,6 +23,7 @@ protected:
unsigned long _next_gps_on = 0;
unsigned long _gps_on_duration_secs = GPS_POWERSAVING_ON_DURATION_SECS;
unsigned long _gps_off_duration_secs = GPS_POWERSAVING_OFF_DURATION_SECS;
uint16_t _gps_sync_interval_hours = 0; // 0 retains the board's legacy policy
unsigned long _last_valid_time_sync = 0;
uint32_t _last_time_sync_request_ms = 0;
uint32_t _last_time_sync_applied_ms = 0;
@@ -41,6 +43,25 @@ protected:
}
public:
void setTimeSyncIntervalHours(uint16_t hours) {
_gps_sync_interval_hours = hours > mesh::gps::MAX_SYNC_INTERVAL_HOURS
? mesh::gps::MAX_SYNC_INTERVAL_HOURS : hours;
// A sleeping receiver must adopt a shorter/longer setting now, not
// wait for its old (possibly week-long) deadline. No forced GPS wake.
if (_next_gps_on != 0) setNextWake();
}
uint16_t getTimeSyncIntervalHours() const { return _gps_sync_interval_hours; }
uint32_t periodicTimeSyncIntervalMillis() const {
return _gps_sync_interval_hours != 0
? static_cast<uint32_t>(_gps_sync_interval_hours) * 3600000UL
: 1800000UL; // existing always-on receiver policy: 30 minutes
}
// Position telemetry may power the receiver between scheduled clock
// acquisitions. Do not turn that into an early clock-sync request.
void syncTimeForPowerSavingCycle() {
if (_gps_sync_interval_hours == 0) syncTime();
else (void)requestTimeSync(static_cast<uint64_t>(_gps_sync_interval_hours) * 3600UL);
}
virtual void syncTime() { _time_sync_needed = true; }
virtual bool waitingTimeSync() { return _time_sync_needed; }
// Telemetry requests share an in-progress acquisition and rate-limit new
@@ -95,7 +116,24 @@ public:
virtual void enablePowerSaving(bool enabled) { setGPSPowerSaving(enabled); }
virtual bool isPowerSavingEnabled() { return getGPSPowerSaving(); }
virtual void setNextWake() {
setNextGPSOn(millis() + _gps_off_duration_secs * 1000UL);
const uint32_t now = static_cast<uint32_t>(millis());
uint32_t wait_ms = _gps_off_duration_secs * 1000UL;
if (_gps_sync_interval_hours != 0) {
wait_ms = periodicTimeSyncIntervalMillis();
// Anchor to clock acquisition, not an unrelated location query's
// power-off. Repeated position queries must not postpone sync.
uint32_t age = UINT32_MAX;
if (_time_sync_applied_seen) age = now - _last_time_sync_applied_ms;
if (_time_sync_request_seen) {
const uint32_t request_age = now - _last_time_sync_request_ms;
if (request_age < age) age = request_age;
}
if (age < wait_ms) wait_ms -= age;
// A failed/timed-out acquisition gets a full interval backoff,
// rather than immediately powering back on in a retry loop.
}
const uint32_t deadline = now + wait_ms;
setNextGPSOn(deadline == 0 ? 1 : deadline); // 0 means no deadline
}
virtual unsigned long getNextWake() { return getNextGPSOn(); }
virtual void setNextSleep() {
@@ -55,7 +55,6 @@ class MicroNMEALocationProvider : public LocationProvider {
unsigned long next_check = 0;
long time_valid = 0;
unsigned long _last_time_sync = 0;
static const unsigned long TIME_SYNC_INTERVAL = 1800000; // Re-sync every 30 minutes
#ifdef GPS_L76K
void configureL76K() {
@@ -269,7 +268,9 @@ public :
if ((long)(millis() - next_check) > 0) {
next_check = millis() + 1000;
// Re-enable time sync periodically when GPS has valid fix
if (!_time_sync_needed && _clock != NULL && (millis() - _last_time_sync) > TIME_SYNC_INTERVAL) {
if (!_time_sync_needed && _clock != NULL
&& static_cast<uint32_t>(millis() - _last_time_sync)
>= periodicTimeSyncIntervalMillis()) {
_time_sync_needed = true;
}
const long timestamp = getTimestamp();
+41 -11
View File
@@ -2,6 +2,9 @@
#include <Arduino.h>
#include <Wire.h>
#include <stdarg.h>
#include <stdio.h>
#include <helpers/UsbLogging.h>
#include "TouchTapDetector.h"
@@ -36,17 +39,23 @@ public:
#endif
if (_present) {
Serial.printf("Touch: CHSC6X found at 0x%02X\n", CHSC6X_I2C_ADDR);
} else {
logDiagnostic("Touch: CHSC6X found at 0x%02X\n", CHSC6X_I2C_ADDR);
} else if (mesh::isUsbLoggingEnabled()) {
// Report what is actually on the bus, so an unexpected controller or
// address can be identified from a normal boot log.
Serial.printf("Touch: idle or absent at 0x%02X; I2C bus currently answers:",
CHSC6X_I2C_ADDR);
char record[640];
size_t length = snprintf(record, sizeof(record),
"Touch: idle or absent at 0x%02X; I2C bus currently answers:",
CHSC6X_I2C_ADDR);
for (uint8_t addr = 8; addr < 0x78; addr++) {
_wire->beginTransmission(addr);
if (_wire->endTransmission() == 0) Serial.printf(" 0x%02X", addr);
if (_wire->endTransmission() == 0 && length + 6 < sizeof(record)) {
length += snprintf(record + length, sizeof(record) - length,
" 0x%02X", addr);
}
}
Serial.println();
record[length++] = '\n';
writeDiagnostic(record, length);
}
return _present;
}
@@ -58,12 +67,31 @@ public:
bool pressed = readPressed();
if (pressed && !_present) {
_present = true; // answered late; the boot probe caught it mid-idle
Serial.println("Touch: CHSC6X responding");
logDiagnostic("Touch: CHSC6X responding\n");
}
return _detector.update(now_ms, pressed);
}
private:
static void writeDiagnostic(const char* data, size_t length) {
if (!mesh::isUsbLoggingEnabled()) return;
Stream& output = mesh::usbLoggingPort();
if (output.availableForWrite() < static_cast<int>(length)) return;
output.write(reinterpret_cast<const uint8_t*>(data), length);
}
static void logDiagnostic(const char* format, ...) {
if (!mesh::isUsbLoggingEnabled()) return;
char record[128];
va_list args;
va_start(args, format);
const int length = vsnprintf(record, sizeof(record), format, args);
va_end(args);
if (length > 0 && static_cast<size_t>(length) < sizeof(record)) {
writeDiagnostic(record, static_cast<size_t>(length));
}
}
TwoWire* _wire = NULL;
bool _present = false;
TouchTapDetector _detector;
@@ -110,15 +138,17 @@ private:
_logged = key;
if (!buf) {
Serial.printf("Touch: short read (%u of %u bytes)\n", got, CHSC6X_READ_LEN);
logDiagnostic("Touch: short read (%u of %u bytes)\n", got, CHSC6X_READ_LEN);
return;
}
Serial.printf("Touch: raw %02X %02X %02X %02X %02X", buf[0], buf[1], buf[2], buf[3], buf[4]);
#ifdef PIN_TOUCH_INT
// Pulled up, so an unfitted R13 sits steady HIGH and a wired INT pulses LOW.
Serial.printf(" INT=%d", digitalRead(PIN_TOUCH_INT));
logDiagnostic("Touch: raw %02X %02X %02X %02X %02X INT=%d\n",
buf[0], buf[1], buf[2], buf[3], buf[4], digitalRead(PIN_TOUCH_INT));
#else
logDiagnostic("Touch: raw %02X %02X %02X %02X %02X\n",
buf[0], buf[1], buf[2], buf[3], buf[4]);
#endif
Serial.println();
}
#else
void logRaw(uint8_t, const uint8_t*) {}
@@ -7,3 +7,7 @@ uint32_t tud_cdc_n_write_available(uint8_t instance);
uint32_t tud_cdc_n_write(uint8_t instance, const void* data, uint32_t size);
uint32_t tud_cdc_n_write_flush(uint8_t instance);
bool tud_cdc_n_write_clear(uint8_t instance);
void tud_cdc_n_read_flush(uint8_t instance);
bool tud_mounted();
bool tud_connect();
bool tud_disconnect();
@@ -2,11 +2,16 @@
#include <cassert>
#include <iostream>
#include <string>
#include <atomic>
#include <thread>
#include "helpers/UsbLogging.h"
#include "MeshCore.h"
MockSerial Serial;
static bool connected = true;
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;
@@ -14,6 +19,25 @@ 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" bool meshEsp32TinyUsbAcceptRx();
extern "C" void meshEsp32TinyUsbDeviceSessionBoundary(bool mounted);
extern "C" bool meshEsp32TinyUsbTxPending() { return !endpoint.empty(); }
bool tud_mounted() { return mounted; }
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);
@@ -38,6 +62,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;
@@ -69,10 +99,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());
@@ -136,8 +166,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);
@@ -149,6 +188,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() {
auto_drain = true;
const std::string long_text(1000, 'D');
@@ -186,10 +337,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);
@@ -210,8 +362,10 @@ 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());
@@ -219,6 +373,10 @@ int main() {
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();
+2
View File
@@ -48,8 +48,10 @@ extern MockFicr mock_ficr;
#define WB_IO2 2
#define OUTPUT 1
#define HIGH 1
#define INPUT_PULLUP 2
inline void pinMode(int, int) {}
inline void digitalWrite(int, int) {}
inline int digitalRead(int) { return HIGH; }
inline void vTaskDelete(void*) {}
inline void vTaskDelay(uint32_t value) { delay(value); }
inline uint32_t pdMS_TO_TICKS(uint32_t value) { return value; }
@@ -0,0 +1,29 @@
#pragma once
#include <Arduino.h>
#define ETH_MISO_PIN 1
#define ETH_MOSI_PIN 2
#define ETH_SCLK_PIN 3
#define ETH_CS_PIN 4
#define ETH_INT_PIN 5
struct ch390_config_t {
int spi_miso_gpio = 0;
int spi_mosi_gpio = 0;
int spi_sck_gpio = 0;
int spi_cs_gpio = 0;
int int_gpio = 0;
};
#define CH390_DEFAULT_CONFIG() ch390_config_t{}
class MockCh390 {
public:
bool valid_hostname = false;
unsigned begins = 0;
bool setHostname(const char*) const { return valid_hostname; }
bool begin(const ch390_config_t&) { ++begins; return true; }
};
extern MockCh390 CH390;
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <Arduino.h>
#include <array>
class TwoWire {
public:
bool present = true;
bool all_addresses_present = false;
unsigned probes = 0;
unsigned fail_probe_count = 0;
std::array<uint8_t, 5> frame{{0, 1, 2, 3, 4}};
uint8_t reply_length = 5;
uint8_t current_address = 0;
unsigned cursor = 0;
void beginTransmission(uint8_t address) { current_address = address; ++probes; }
uint8_t endTransmission() const {
if (probes <= fail_probe_count) return 2;
return (all_addresses_present || (present && current_address == 0x2E)) ? 0 : 2;
}
uint8_t requestFrom(uint8_t, uint8_t) { cursor = 0; return reply_length; }
int available() const { return static_cast<int>(reply_length) - static_cast<int>(cursor); }
int read() { return cursor < reply_length ? frame[cursor++] : -1; }
};
extern TwoWire Wire;
+77
View File
@@ -1,6 +1,8 @@
#include <helpers/NonBlockingWriteStream.h>
#include <helpers/nrf52/EthernetCLI.h>
#include "NetworkLoggingMacro.h"
#include <helpers/ui/CHSC6XTouch.h>
#include <helpers/ethernet/ch390/CH390Config.h>
#include <iostream>
@@ -8,6 +10,8 @@ uint32_t mock_millis = 0;
RawSerial Serial;
MockFicr mock_ficr;
MockEthernet Ethernet;
TwoWire Wire;
MockCh390 CH390;
std::string ethernet_output;
std::string ethernet_input;
bool accept_client = false;
@@ -138,5 +142,78 @@ 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();
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;
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";
}
+6 -1
View File
@@ -53,6 +53,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);
@@ -78,10 +79,11 @@ int main() {
CommonCLI cli;
assert(cli.saveCommonPrefs());
auto original=cli.fs.files["/com_prefs"];
const size_t preamble_offset=original.size()-3;
const size_t preamble_offset=original.size()-5;
assert(preamble_offset>=864);
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);
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}) {
@@ -109,6 +111,7 @@ 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(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.
@@ -118,6 +121,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);
+8 -7
View File
@@ -32,13 +32,14 @@ int main(){
assert(mesh.savePrefs());
const auto on_disk=mesh.store.fs.files["/new_prefs"];
#if defined(TBEAM_1W)
assert(on_disk.size()==239);
assert(on_disk.size()==241);
#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
assert(on_disk.size()==329);
assert(on_disk.size()==331);
#else
assert(on_disk.size()==232);
assert(on_disk.size()==234);
#endif
assert(on_disk.back()==1);
const size_t bluetooth_offset=on_disk.size()-3;
assert(on_disk[bluetooth_offset]==1);
// Both choices survive a fresh instance reading the actual on-device image.
for(bool enabled : {false,true,false}){
assert(mesh.setBluetoothEnabledPreference(enabled));
@@ -49,7 +50,7 @@ int main(){
assert(prefs.ble_pin==876543&&prefs.freq==910.525f);
assert(prefs.bluetooth_stealth_mode==2&&prefs.one_key_dm_enabled==1);
assert(prefs.wifi_enabled==0&&prefs.usb_logging_enabled==1);
assert(reboot.fs.files["/new_prefs"].back()==enabled);
assert(reboot.fs.files["/new_prefs"][bluetooth_offset]==enabled);
}
const auto off_disk=mesh.store.fs.files["/new_prefs"];
// The public setter must roll live state back when the atomic save fails.
@@ -69,7 +70,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()-1)}){
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;
@@ -80,7 +81,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;
damaged.fs.files["/new_prefs"].back()=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));
+22 -4
View File
@@ -1,5 +1,6 @@
"""Exercise real Companion preferences saves and startup recovery with I/O faults."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
@@ -54,6 +55,7 @@ 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;
#ifdef TBEAM_1W
strcpy(original.fan_mode,"on");original.fan_lo=33;original.fan_hi=42;
#endif
@@ -90,6 +92,7 @@ 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);
#ifdef TBEAM_1W
assert(!strcmp(loaded.fan_mode,"on")&&loaded.fan_lo==33&&loaded.fan_hi==42);
#endif
@@ -194,11 +197,24 @@ 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()-2);
legacy.fs.files["/new_prefs"].resize(disk.size()-4);
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()-2);
CompanionNodePrefs loaded;loaded.gps_sync_interval_hours=24;
double lat=0,lon=0;
assert(legacy.loadPrefs(loaded,lat,lon));
assert(loaded.gps_sync_interval_hours==0 && loaded.one_key_dm_enabled==1);
// 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()-1);
assert(!torn.loadPrefsInt("/new_prefs",loaded,lat,lon));
} else if(scenario==8){
for(uint8_t enabled : {0,1}){
CompanionNodePrefs saved=original;saved.wifi_enabled=enabled;
@@ -288,10 +304,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 +321,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)
@@ -107,6 +107,7 @@ struct MyMesh {
bool applyAndSaveRxPowerSaving(const char*,char*);
bool applyAndSavePowerSaving(const char*,char*);
bool applyAndSaveRxBoostedGain(bool);
bool setGpsEnabled(bool);
bool savePrefs() {
++saves;if(!storage_accepts)return false;
memcpy(&durable,&_prefs,sizeof(Prefs));durable_lat=sensors.node_lat;durable_lon=sensors.node_lon;return true;
@@ -357,6 +358,7 @@ class CompanionPrefsTransactionTests(unittest.TestCase):
'bool MyMesh::applyAndSaveRxPowerSaving(',
'bool MyMesh::applyAndSaveRxBoostedGain(',
'bool MyMesh::applyAndSavePowerSaving(',
'bool MyMesh::setGpsEnabled(',
)),
'@FRAMES@': ' else '.join(extract_braced(text, f'if (cmd_frame[0] == {cmd})') for cmd in frames),
'@WEB@': ' else '.join(extract_braced(text,f'if ({expr})') for expr in web),
+3
View File
@@ -52,6 +52,8 @@ struct Board { bool isUsbHostConnected() const { return data_connected; } } boar
static bool usb_mota_mode = false, usb_logging_terminal_mode = false;
static bool usb_logging_network_parked = false;
static bool usb_protocol_initialized = true;
static bool usb_logging_reply_hold = false, usb_logging_reply_staged = false;
static bool usb_logging_reply_pending = false, usb_logging_reply_state = false;
static bool usb_host_session_connected = false, usb_terminal_discard_line = true;
static bool usb_terminal_host_reset_completion_pending = false;
static uint32_t usb_terminal_host_reset_retry_at = 0;
@@ -293,6 +295,7 @@ class CompanionUsbDefaultTest(unittest.TestCase):
loop = function("\nvoid loop() {")
self.assertLess(loop.index("serviceUsbTerminalHostSessionReset();"), loop.index("serviceUsbAsciiSessionDefault();"))
self.assertLess(loop.index("serviceUsbAsciiSessionDefault();"), loop.index("the_mesh.loop();"))
self.assertLess(loop.index("serviceUsbLoggingReplyBarrier();"), loop.index("the_mesh.loop();"))
self.assertLess(loop.index("serviceUsbLoggingOwnership(mesh::isUsbLoggingEnabled());"), loop.index("the_mesh.loop();"))
self.assertLess(loop.index("expireUsbBinaryStartupProbeBeforeDispatch();"), loop.index("the_mesh.loop();"))
self.assertNotIn("COMPANION_RADIO_FULL", MAIN)
+265
View File
@@ -0,0 +1,265 @@
"""Exercise logging handoffs with real Companion USB parsing and routing."""
from pathlib import Path
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <cassert>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include "helpers/ArduinoSerialInterface.h"
#include "helpers/MultiSerialInterface.h"
#include "helpers/UsbAsciiBinarySwitch.h"
#include "helpers/CLICommandUtils.h"
#include "helpers/CompanionHardwareCommandCompat.h"
#define MESH_USB_LOGGING_AVAILABLE 1
#define COMPANION_FEATURE_USB_MOTA_SOURCE 0
#define COMPANION_FEATURE_NETWORK_TERMINAL 1
static bool connected=true, dedicated=false, logging=false, network=false;
static bool usb_logging_network_parked=false, usb_logging_terminal_mode=false;
static bool usb_protocol_initialized=true, usb_terminal_discard_line=false;
static bool usb_logging_reply_hold=false, usb_logging_reply_staged=false;
static bool usb_logging_reply_pending=false, usb_logging_reply_state=false;
static mesh::UsbBinaryStartupProbe usb_binary_startup_probe;
static mesh::UsbAsciiSessionDefault usb_ascii_session_default;
struct UsbStream : Stream {
std::vector<uint8_t> rx, tx;
size_t cursor=0;
int capacity=4096, max_write=4096;
int available() override { return static_cast<int>(rx.size()-cursor); }
int read() override { return cursor<rx.size()?rx[cursor++]:-1; }
int peek() override { return cursor<rx.size()?rx[cursor]:-1; }
int availableForWrite() override { return capacity; }
size_t write(const uint8_t* p,size_t n) override {
if(n>static_cast<size_t>(capacity)) n=capacity;
if(n>static_cast<size_t>(max_write)) n=max_write;
tx.insert(tx.end(),p,p+n);
return n;
}
void receive(const char* command) {
size_t n=strlen(command)+1;
rx={'<',static_cast<uint8_t>(n),0,mesh::companion::kRunCliCommand};
rx.insert(rx.end(),command,command+n-1);
cursor=0;
}
} stream;
struct Remote : BaseSerialInterface {
bool enabled=false;
std::vector<uint8_t> rx, tx;
void enable() override { enabled=true; }
void disable() override { enabled=false; }
bool isEnabled() const override { return enabled; }
bool isConnected() const override { return true; }
bool isReadBusy() const override { return false; }
bool isWriteBusy() const override { return false; }
size_t writeFrame(const uint8_t* p,size_t n) override {
tx.insert(tx.end(),p,p+n); return n;
}
size_t checkRecvFrame(uint8_t* out) override {
const size_t n=rx.size();
if(n) memcpy(out,rx.data(),n);
rx.clear(); return n;
}
} ble;
ArduinoSerialInterface usb_serial_interface;
MultiSerialInterface interface_manager;
static bool clientConnected() { return connected; }
static Stream& usbTerminalOutput() { return stream; }
static bool isNetworkTerminalActive() { return network; }
static void clearUsbTerminalLine() {}
static unsigned banners=0, cancellations=0;
namespace mesh {
bool hasDedicatedUsbLoggingPort() { return dedicated; }
bool isUsbLoggingEnabled() { return logging; }
void setUsbLoggingEnabled(bool value) { logging=value; }
void discardUsbTerminalOutput() {}
Stream& usbCompanionPort() { return stream; }
bool saveUsbLoggingBootPreference(bool) { return true; }
bool usbLoggingInterfaceRestartRequired() { return false; }
}
namespace CompanionMqttSetupPortal {
static bool saveEnabled(bool) { return true; }
}
static uint32_t futureMillis(uint32_t value) { return millis()+value; }
struct MyMesh {
struct { unsigned usb_logging_enabled=0; } _prefs;
BaseSerialInterface* _serial=&interface_manager;
bool terminal=false, _mqtt_enabled=false;
unsigned _scheduled_reboot_at=0;
uint8_t cmd_frame[MAX_FRAME_SIZE+1]{}, out_frame[MAX_FRAME_SIZE+4]{};
char reply_buf[166]{};
bool isTerminalMode() const { return terminal; }
void cancelSerialResponseStream() { ++cancellations; }
void cancelSerialOperationsForRoute(BaseSerialInterface*) {}
void enterTerminalMode(bool show_banner) {
terminal=true;
if(show_banner) { ++banners; usbTerminalOutput().print("BANNER"); }
}
bool savePrefs() { return true; }
void stopMQTT() {}
void applyUsbLoggingState(bool);
void beginUsbLoggingReplyBarrier(BaseSerialInterface*);
void endUsbLoggingReplyBarrier(bool);
void writeErrFrame(uint8_t) { assert(false); }
bool handleCommand(const char* command,uint32_t,char* reply) {
size_t reply_size=160;
@SETTERS@
strcpy(reply,"query"); return true;
}
void handleCmdFrame(size_t len) {
static constexpr uint8_t RESP_CODE_CLI_REPLY=29, ERR_CODE_ILLEGAL_ARG=1;
@CLI_FRAME@
}
} the_mesh;
static void leaveUsbTerminalMode(bool) { the_mesh.terminal=false; }
@FUNCTIONS@
static void reset() {
connected=true; dedicated=false; logging=false; network=false;
usb_logging_network_parked=usb_logging_terminal_mode=false;
usb_logging_reply_hold=usb_logging_reply_staged=false;
usb_logging_reply_pending=usb_logging_reply_state=false;
stream=UsbStream(); ble=Remote(); the_mesh=MyMesh();
usb_serial_interface=ArduinoSerialInterface();
interface_manager=MultiSerialInterface();
usb_serial_interface.begin(stream);
usb_serial_interface.setConnectedCheck(clientConnected);
usb_serial_interface.enableFlowControl(true);
assert(interface_manager.addInterface(InterfaceType::USB,&usb_serial_interface));
assert(interface_manager.addInterface(InterfaceType::Bluetooth,&ble));
interface_manager.enable(); banners=cancellations=0;
}
static void command(const char* text) {
stream.receive(text);
size_t len=interface_manager.checkRecvFrame(the_mesh.cmd_frame);
assert(len>0 && interface_manager.captureReplyRoute()==&usb_serial_interface);
the_mesh.handleCmdFrame(len);
}
static void checkReply() {
assert(stream.tx.size()>=4 && stream.tx[0]=='>');
const size_t size=stream.tx[1]+256*stream.tx[2];
assert(stream.tx.size()>=size+3 && stream.tx[3]==29);
assert(ble.tx.empty());
const std::string reply(stream.tx.begin()+4,stream.tx.begin()+size+3);
assert(reply.find("saved")!=std::string::npos);
}
int main() {
// Real CLI setters, not a mock apply call: both variants retain the request.
for(const char* setting : {"set usb.logging on","set logging.output both"}) {
reset(); command(setting);
assert(!logging && !the_mesh.terminal && usb_logging_reply_pending);
checkReply();
const size_t frame_bytes=stream.tx.size();
serviceUsbLoggingReplyBarrier();
assert(logging && the_mesh.terminal && !usb_logging_reply_pending);
assert(stream.tx.size()==frame_bytes+6);
assert(std::string(stream.tx.begin()+frame_bytes,stream.tx.end())=="BANNER");
assert(ble.tx.empty());
}
// Stall and partial writes never block the mesh or allow a banner to splice
// into a framed response; the complete reply precedes the logging handoff.
reset(); stream.capacity=0; command("set usb.logging on");
assert(stream.tx.empty() && usb_logging_reply_pending && !logging);
for(unsigned i=0;i<10000;++i) serviceUsbLoggingReplyBarrier();
assert(!logging && !the_mesh.terminal && stream.tx.empty());
stream.capacity=7; stream.max_write=3;
for(unsigned i=0;i<1000 && usb_logging_reply_pending;++i) {
serviceUsbLoggingReplyBarrier();
if(usb_logging_reply_pending) assert(!logging && !the_mesh.terminal);
}
assert(logging); checkReply();
// An incomplete *next* input does not hold the already admitted reply.
reset(); stream.capacity=0; command("set usb.logging on");
stream.rx={'<',5,0,1}; stream.cursor=0;
uint8_t input[176]; assert(usb_serial_interface.checkRecvFrame(input)==0);
assert(usb_serial_interface.isReadBusy());
stream.capacity=4096; serviceUsbLoggingReplyBarrier();
assert(logging && the_mesh.terminal); checkReply();
// A new read-only USB request while stalled must not erase the old intent.
reset(); stream.capacity=0; command("set usb.logging on");
command("get version"); assert(usb_logging_reply_pending && !logging);
stream.capacity=4096; serviceUsbLoggingReplyBarrier(); assert(logging);
// Disconnect/reset/ASCII takeover cancels this session's queued intent.
reset(); stream.capacity=0; command("set usb.logging on");
connected=false; serviceUsbLoggingReplyBarrier();
connected=true; stream.capacity=4096; serviceUsbLoggingReplyBarrier();
assert(!logging && !usb_logging_reply_pending && ble.tx.empty());
reset(); stream.capacity=0; command("set usb.logging on");
cancelUsbSerialOperations(); usb_serial_interface.resetSessionState();
stream.capacity=4096; serviceUsbLoggingReplyBarrier(); assert(!logging);
// A queue unable to accept the acknowledgement cannot start the handoff.
reset(); stream.capacity=0;
const uint8_t required[]={29,1};
for(unsigned i=0;i<4;++i) assert(usb_serial_interface.writeFrame(required,2)==2);
command("set usb.logging on");
assert(!usb_logging_reply_pending && !logging && ble.tx.empty());
// A later explicit setting supersedes an earlier pending on request.
reset(); stream.capacity=0; command("set usb.logging on");
the_mesh.applyUsbLoggingState(false);
stream.capacity=4096; serviceUsbLoggingReplyBarrier(); assert(!logging);
// Dedicated CDC and commands from another interface don't defer its reply.
reset(); dedicated=true; command("set usb.logging on");
assert(logging && !usb_logging_reply_pending && !the_mesh.terminal); checkReply();
reset(); the_mesh.beginUsbLoggingReplyBarrier(&ble);
the_mesh.applyUsbLoggingState(true); the_mesh.endUsbLoggingReplyBarrier(true);
assert(logging && !usb_logging_reply_pending && the_mesh.terminal);
// A real BLE framed command retains its route even when it parks USB.
// Network terminal ownership likewise survives this logging update.
for(bool network_owner : {false,true}) {
reset(); network=network_owner;
const char* setting="set usb.logging on";
ble.rx={mesh::companion::kRunCliCommand};
ble.rx.insert(ble.rx.end(),setting,setting+strlen(setting));
size_t len=interface_manager.checkRecvFrame(the_mesh.cmd_frame);
assert(len>0 && interface_manager.captureReplyRoute()==&ble);
the_mesh.handleCmdFrame(len);
assert(logging && !usb_logging_reply_pending && ble.tx[0]==29);
assert(interface_manager.captureReplyRoute()==&ble && cancellations==0);
assert(the_mesh.terminal!=network_owner && network==network_owner);
assert(usb_serial_interface.isPassthroughMode());
}
}
'''
class CompanionLoggingReplyTest(unittest.TestCase):
def test_logging_acknowledgement_precedes_single_tty_handoff(self):
main = (ROOT / 'examples/companion_radio/main.cpp').read_text()
mesh = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
signatures = (
'static void cancelUsbSerialOperations() {',
'static void enterUsbTerminalMode(bool show_banner = true) {',
'static void enterUsbLoggingTerminalMode() {',
'static void serviceUsbLoggingOwnership(bool logging_enabled) {',
'void MyMesh::applyUsbLoggingState(bool enabled) {',
'void MyMesh::beginUsbLoggingReplyBarrier(BaseSerialInterface* route) {',
'void MyMesh::endUsbLoggingReplyBarrier(bool reply_queued) {',
'static void serviceUsbLoggingReplyBarrier() {',
)
source = HARNESS.replace('@FUNCTIONS@', '\n'.join(
extract_braced(main, signature) for signature in signatures))
setters = '\n'.join(extract_braced(mesh, signature) for signature in (
'if (strncmp(command, "set usb.logging", 15) == 0',
'if (strncmp(command, "set logging.output ", 19) == 0)',
))
source = source.replace('@SETTERS@', setters).replace('@CLI_FRAME@',
extract_braced(mesh, 'if (mesh::companion::isRunCliFrame(cmd_frame[0], len))'))
with tempfile.TemporaryDirectory() as directory:
cpp, exe = Path(directory) / 'reply.cpp', Path(directory) / 'reply.exe'
cpp.write_text(source)
subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', '-Werror',
'-Wno-unused-parameter', '-Wno-sign-compare',
'-I' + str(ROOT / 'test/mocks'), '-I' + str(ROOT / 'src'),
str(cpp), str(ROOT / 'src/helpers/ArduinoSerialInterface.cpp'),
'-o', str(exe)], check=True)
subprocess.run([str(exe)], check=True)
if __name__ == '__main__':
unittest.main()
@@ -222,6 +222,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;
@@ -305,6 +326,47 @@ int main() {
'''
RECENT_TEST = r'''
setupFile("");
{
MyMesh radio;
radio.tables.rows.push_back({{0, 0, 1}, 3, -127});
radio.tables.rows.push_back({{0, 0, 2}, 3, -127});
console.short_limit = 3;
radio.printRecentRepeatersSerial();
require(radio.serial_recent_header, "partial header advanced cursor");
drain(radio, 7);
require(console.output == "Recent repeaters (2):\n000001,-31.75\n000002,-31.75\n",
"short-write recent listing duplicated or lost a prefix");
}
setupFile("");
{
MyMesh radio;
radio.tables.rows.push_back({{0, 0, 1}, 3, -127});
radio.printRecentRepeatersSerial();
console.short_limit = 3;
radio.servicePendingSerialOutput();
require(radio.serial_log_pending_size > 0, "partial recent row was not retained");
radio.tables.rows[0] = {{0, 0, 9}, 3, 0};
console.short_limit = 0;
drain(radio, 64);
require(console.output == "Recent repeaters (1):\n000001,-31.75\n",
"live table update changed a partially emitted recent row");
}
setupFile("");
{
MyMesh radio;
radio.tables.rows.push_back({{0, 0, 1}, 3, -127});
console.short_limit = 3;
radio.printRecentRepeatersSerial();
radio.cancelPendingSerialOutput();
console.output.clear();
console.short_limit = 0;
radio.tables.rows.clear();
radio.printRecentRepeatersSerial();
drain(radio, 64);
require(console.output == "Recent repeaters (0):\n-none-\r\n",
"canceled recent suffix leaked into the next session");
}
setupFile("");
{
MyMesh radio;
+5 -3
View File
@@ -181,7 +181,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)
@@ -192,10 +192,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")
+106
View File
@@ -0,0 +1,106 @@
"""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();
}
'''
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.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() {")):
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):
# CI has no Windows package; the golden anchors above still cover it.
package = Path(os.environ.get("USERPROFILE", "")) / ".platformio/packages/framework-arduinoespressif32/cores/esp32"
if not (package / "USBCDC.cpp").is_file():
self.skipTest("installed Arduino-ESP32 SDK unavailable")
for name, patcher in (("USBCDC.cpp", FIX.patched_cdc_source), ("USB.cpp", FIX.patched_usb_source)):
original = (package / name).read_text(encoding="utf-8")
self.assertIn("meshEsp32TinyUsb", patcher(original))
def test_all_esp32_profiles_inherit_hook(self):
config = (ROOT / "platformio.ini").read_text()
base = config[config.index("[esp32_base]"):config.index("[esp32_ota]")]
self.assertIn("pre:scripts/esp32_usb_session_fix.py", base)
if __name__ == "__main__":
unittest.main()
+296
View File
@@ -0,0 +1,296 @@
"""Execute GPS sync interval policy and both production provider timers."""
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from test_gps_upstream_adaptations import ARDUINO, NMEA, RTC
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
MESH = r'''
#pragma once
#include <Arduino.h>
namespace mesh {
class RTCClock {
uint32_t utc=1790899200U;
public:
unsigned writes=0;
uint32_t getCurrentTime(){return utc;}
void setCurrentTime(uint32_t value){utc=value;++writes;}
};
}
'''
BASE = r'''
#include <cassert>
#include <helpers/GpsPowerPolicy.h>
#include <helpers/sensors/LocationProvider.h>
void LocationProvider::sendSentence(const char*){}
struct Provider:LocationProvider {
using LocationProvider::periodicTimeSyncIntervalMillis;
long getLatitude()override{return 0;}
long getLongitude()override{return 0;}
long getAltitude()override{return 0;}
long satellitesCount()override{return 0;}
bool isValid()override{return false;}
long getTimestamp()override{return 0;}
void reset()override{}void begin()override{}void stop()override{}void loop()override{}
bool isEnabled()override{return true;}
void applied(){markTimeSyncApplied();stopTimeSync();}
};
'''
PARSER = BASE + r'''
int main(){
uint16_t hours=77;
for(const char* value:{"1","01","0001"}){
assert(mesh::gps::parseSyncIntervalHours(value,hours)&&hours==1);
}
for(const char* value:{"336","337","999","4294967296",
"999999999999999999999999999999999999999999999999"}){
assert(mesh::gps::parseSyncIntervalHours(value,hours)&&hours==336);
}
assert(mesh::gps::parseSyncIntervalHours("168",hours)&&hours==168);
for(const char* value:{"","0","000","-1","+1","1.0"," 1","1 ","1h",
"999999999999999999999999x","0x10"}){
hours=77;assert(!mesh::gps::parseSyncIntervalHours(value,hours));assert(hours==77);
}
assert(!mesh::gps::parseSyncIntervalHours(nullptr,hours));
// Position processing keeps its existing seconds semantics and bounds.
uint32_t seconds=77;
assert(mesh::gps::parseUpdateInterval("0",seconds)&&seconds==0);
assert(mesh::gps::updateIntervalMillis(seconds)==1000);
assert(mesh::gps::parseUpdateInterval("86400",seconds)&&seconds==86400);
assert(!mesh::gps::parseUpdateInterval("999999",seconds));
}
'''
POWER = BASE + r'''
int main(){
const uint32_t hour=3600000U;
Provider gps;now_ms=100;
assert(gps.getTimeSyncIntervalHours()==0);
assert(gps.periodicTimeSyncIntervalMillis()==1800000UL);
gps.setNextWake();assert(gps.getNextWake()==100+24UL*hour);
gps.setPowerSavingProfile(600,7UL*24UL*3600UL);
gps.setNextWake();assert(gps.getNextWake()==100+7UL*24UL*hour);
gps.setNextSleep();assert(gps.getNextSleep()==600100UL);
gps.setTimeSyncIntervalHours(1);
assert(gps.getTimeSyncIntervalHours()==1&&gps.periodicTimeSyncIntervalMillis()==hour);
assert(gps.getNextWake()==100+hour); // Replace an already armed legacy wake.
gps.setNextSleep();assert(gps.getNextSleep()==600100UL);
gps.stopTimeSync();gps.setNextWake();assert(gps.getNextWake()==100+hour);
gps.setTimeSyncIntervalHours(336);
assert(gps.periodicTimeSyncIntervalMillis()==1209600000UL);
assert(gps.getNextWake()==100+336UL*hour);
gps.setNextGPSOn(0);gps.setTimeSyncIntervalHours(1);
assert(gps.getNextWake()==0); // Changing policy does not start a disabled GPS.
// Successful time fixes anchor the next scheduled wake; position work cannot
// postpone the long time-sync interval by repeatedly stopping the receiver.
now_ms=1000;gps.applied();now_ms=10000;gps.setNextWake();
assert(gps.getNextWake()==1000+hour);
now_ms=20000;gps.setNextWake();assert(gps.getNextWake()==1000+hour);
gps.syncTimeForPowerSavingCycle();assert(!gps.waitingTimeSync());
now_ms=1000+hour-1;gps.syncTimeForPowerSavingCycle();assert(!gps.waitingTimeSync());
now_ms=1000+hour;gps.syncTimeForPowerSavingCycle();assert(gps.waitingTimeSync());
gps.stopTimeSync();
// An acquisition request without a fix is still paced, including later
// position wake-ups. It cannot cause every telemetry request to force sync.
now_ms=1000+hour+5000;gps.syncTimeForPowerSavingCycle();assert(!gps.waitingTimeSync());
gps.setNextWake();assert(gps.getNextWake()==1000+2UL*hour);
now_ms=1000+2UL*hour;gps.syncTimeForPowerSavingCycle();assert(gps.waitingTimeSync());
gps.applied();
// Direct manual/startup requests always override the configured pace.
++now_ms;gps.syncTime();assert(gps.waitingTimeSync());gps.stopTimeSync();
gps.setTimeSyncIntervalHours(0);
assert(gps.periodicTimeSyncIntervalMillis()==1800000UL);
gps.syncTimeForPowerSavingCycle();assert(gps.waitingTimeSync());
gps.setNextWake();assert(gps.getNextWake()==now_ms+7UL*24UL*hour);
// Deadline/elapsed arithmetic must remain valid through 32-bit millis wrap.
Provider wrap;wrap.setTimeSyncIntervalHours(336);
now_ms=UINT32_MAX-1000U;wrap.applied();
now_ms=1000;wrap.setNextWake();
assert(static_cast<uint32_t>(wrap.getNextWake())==
static_cast<uint32_t>(UINT32_MAX-1000U+336U*hour));
wrap.syncTimeForPowerSavingCycle();assert(!wrap.waitingTimeSync());
now_ms=static_cast<uint32_t>(UINT32_MAX-1000U+336U*hour);
wrap.syncTimeForPowerSavingCycle();assert(wrap.waitingTimeSync());
// Exhausting the acquisition window cannot leave an immediate retry loop.
wrap.stopTimeSync();now_ms+=336U*hour;wrap.setNextWake();
assert(static_cast<uint32_t>(wrap.getNextWake())==now_ms+336U*hour);
// The deadline's zero sentinel must not lose a real wake across rollover.
Provider zero;zero.setTimeSyncIntervalHours(1);now_ms=0U-hour;
zero.setNextWake();assert(zero.getNextWake()==1);
}
'''
PROVIDERS = r'''
#include <cassert>
#include <helpers/sensors/MicroNMEALocationProvider.h>
void LocationProvider::sendSentence(const char*){}
struct Uart:Stream {
std::deque<char> incoming;
int available()override{return static_cast<int>(incoming.size());}
int read()override{char c=incoming.front();incoming.pop_front();return c;}
void fix(){incoming.push_back('\n');}
};
struct Gnss {
bool getGnssFixOk(int){return true;}
long getLatitude(int){return 476100000;}
long getLongitude(int){return -1223300000;}
long getAltitude(int){return 125000;}
int getSIV(int){return 8;}
bool getDateValid(int){return true;}
bool getTimeValid(int){return true;}
long getUnixEpoch(int){return 1790942400;}
} ublox_GNSS;
@RAK@
struct NmeaProvider:MicroNMEALocationProvider {
using MicroNMEALocationProvider::MicroNMEALocationProvider;
uint32_t lastApplied()const{return _last_time_sync_applied_ms;}
};
struct RakProvider:RAK12500LocationProvider {
uint32_t lastApplied()const{return _last_time_sync_applied_ms;}
};
struct NmeaFixture {
Uart uart;mesh::RTCClock clock;NmeaProvider gps;
NmeaFixture():gps(uart,&clock,-1,-1){}
void tick(){uart.fix();gps.loop();}
};
struct RakFixture {
mesh::RTCClock clock;RakProvider gps;
RakFixture(){gps.setRTCClock(&clock);}
void tick(){gps.loop();}
};
template<class Fixture>void interval(uint16_t hours,uint32_t start){
Fixture fixture;
fixture.gps.setTimeSyncIntervalHours(hours);
// A receiver has already been serviced before uptime reaches rollover.
// Advance in gaps below half the target tick range to keep its sample timer
// meaningful, then request the test's reference sync at the selected start.
if(start>INT32_MAX){
for(uint32_t warm:{1U,1073741824U,2147483647U,3221225470U}){
now_ms=warm;fixture.tick();
}
}
const unsigned before=fixture.clock.writes;
fixture.gps.syncTime();
now_ms=start;fixture.tick();
if(hours&&fixture.gps.waitingTimeSync()){
const unsigned clears=MicroNMEA::clears;
fixture.gps.syncTimeForPowerSavingCycle();
assert(MicroNMEA::clears==clears&&fixture.gps.waitingTimeSync());
}
now_ms+=1100;fixture.tick();now_ms+=1100;fixture.tick();
assert(fixture.clock.writes==before+1&&!fixture.gps.waitingTimeSync());
const uint32_t applied=fixture.gps.lastApplied();
const uint32_t interval_ms=hours?static_cast<uint32_t>(hours)*3600000U:1800000U;
now_ms=applied+interval_ms-1U;fixture.tick();assert(fixture.clock.writes==before+1);
now_ms+=1100;fixture.tick();assert(fixture.clock.writes==before+2);
assert(!fixture.gps.waitingTimeSync()&&fixture.gps.consumeTimeSyncApplied());
// Manual sync remains immediate even shortly after a periodic write.
now_ms+=1100;fixture.gps.syncTime();assert(fixture.gps.waitingTimeSync());
fixture.tick();now_ms+=1100;fixture.tick();now_ms+=1100;fixture.tick();
assert(fixture.clock.writes==before+3&&!fixture.gps.waitingTimeSync());
// Unrelated position acquisition reuses the last actual sync rather than
// resetting the clock before the selected interval has elapsed.
now_ms+=1100;fixture.gps.syncTimeForPowerSavingCycle();
if(hours){assert(!fixture.gps.waitingTimeSync());fixture.tick();assert(fixture.clock.writes==before+3);}
else{assert(fixture.gps.waitingTimeSync());}
}
int main(){
for(uint16_t hours:{0U,1U,336U}){
interval<NmeaFixture>(hours,1);interval<RakFixture>(hours,1);
// Host Windows uses the target's 32-bit unsigned long ABI. On a 64-bit
// Unix host base policy rollover remains covered by the separate harness.
if(sizeof(unsigned long)==sizeof(uint32_t)){
interval<NmeaFixture>(hours,UINT32_MAX-3000U);
interval<RakFixture>(hours,UINT32_MAX-3000U);
}
}
}
'''
TELEMETRY = r'''
#include <cassert>
#include <helpers/SensorManager.h>
void LocationProvider::sendSentence(const char*){}
void SensorManager::setTelemetryLocationAccessAvailable(bool){}
struct Provider:LocationProvider {
long getLatitude()override{return 0;}long getLongitude()override{return 0;}
long getAltitude()override{return 0;}long satellitesCount()override{return 0;}
bool isValid()override{return false;}long getTimestamp()override{return 0;}
void reset()override{}void begin()override{}void stop()override{}void loop()override{}
bool isEnabled()override{return true;}
void applied(){markTimeSyncApplied();stopTimeSync();}
};
struct Sensors:SensorManager {
Provider& gps;explicit Sensors(Provider& p):gps(p){}
bool telemetryGpsDetected()const override{return true;}
LocationProvider* getLocationProvider()override{return &gps;}
};
int main(){
Provider gps;Sensors sensors(gps);gps.setGPSPowerSaving(true);
gps.setTimeSyncIntervalHours(336);now_ms=1000;gps.applied();
now_ms=1000+60000;assert(!sensors.requestGpsTelemetryTimeSync(60));
now_ms=1000+336UL*3600000UL-1;assert(!sensors.requestGpsTelemetryTimeSync(60));
++now_ms;assert(sensors.requestGpsTelemetryTimeSync(60));
gps.applied();gps.setTimeSyncIntervalHours(0);
now_ms+=59999;assert(!sensors.requestGpsTelemetryTimeSync(60));
++now_ms;assert(sensors.requestGpsTelemetryTimeSync(60));
}
'''
class GpsSyncIntervalTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.compiler = shutil.which("g++") or shutil.which("clang++")
if cls.compiler is None:
raise unittest.SkipTest("a host C++17 compiler is required")
def compile_run(self, source):
with tempfile.TemporaryDirectory(prefix="meshcore-gps-sync-") as directory:
path = Path(directory)
for name, content in {
"Arduino.h": ARDUINO, "Mesh.h": MESH, "MicroNMEA.h": NMEA,
"RTClib.h": RTC, "CayenneLPP.h": "#pragma once\nclass CayenneLPP {};\n",
"Wire.h": "#pragma once\nclass TwoWire {};\n",
}.items():
(path / name).write_text(content, encoding="utf-8")
cpp = path / "test.cpp"
cpp.write_text(source, encoding="utf-8")
binary = path / "test"
subprocess.run([
self.compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter", "-DENV_INCLUDE_GPS=1",
*(["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []),
"-I", str(path), "-I", str(ROOT / "src"), str(cpp), "-o", str(binary),
], check=True)
subprocess.run([str(binary)], check=True)
def test_positive_whole_hours_clamp_without_overflow(self):
self.compile_run(PARSER)
def test_power_saving_interval_reconfiguration_and_rollover(self):
self.compile_run(POWER)
def test_both_providers_use_hours_keep_legacy_and_allow_manual_sync(self):
source = (ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp").read_text()
rak = extract_braced(source, "class RAK12500LocationProvider") + ";"
self.compile_run(PROVIDERS.replace("@RAK@", rak))
def test_position_telemetry_cannot_bypass_selected_time_interval(self):
self.compile_run(TELEMETRY)
if __name__ == "__main__":
unittest.main()
+133
View File
@@ -0,0 +1,133 @@
"""Run canonical GPS sync CLI branches with durable and live state failures."""
from pathlib import Path
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <initializer_list>
#include <helpers/GpsPowerPolicy.h>
struct Prefs { uint16_t gps_sync_interval_hours = 0; };
struct Provider {
uint16_t hours = 0;
uint16_t getTimeSyncIntervalHours() const { return hours; }
};
struct Sensors {
Provider provider;
bool present = true;
unsigned applications = 0;
Provider* getLocationProvider() { return present ? &provider : nullptr; }
void applyGpsTimeSyncInterval(uint16_t hours) { provider.hours = hours; ++applications; }
};
struct CLI {
Prefs prefs, persisted;
Prefs* _prefs = &prefs;
Sensors sensors;
Sensors* _sensors = &sensors;
bool save_ok = true;
unsigned saves = 0;
bool trySavePrefs() { ++saves; if (save_ok) persisted = prefs; return save_ok; }
void set(const char* config, char* reply) { @COMMON_SET@ }
void get(const char* config, char* reply) { @COMMON_GET@ }
};
struct Companion {
Prefs _prefs, persisted;
Sensors sensors;
bool save_ok = true;
unsigned saves = 0;
bool savePrefs() { ++saves; if (save_ok) persisted = _prefs; return save_ok; }
@SAVE_PREFERENCE@
bool command(const char* command, char* reply, size_t reply_size) {
@COMPANION_GET@
const char* gps_sync_value = nullptr;
@COMPANION_PREFIX@
@COMPANION_SET@
return false;
}
};
int main() {
char reply[160];
CLI common; Companion companion;
common.get("gps.sync.interval", reply);
assert(strstr(reply, "default") && !strstr(reply, "0 hours"));
assert(companion.command("get gps.sync.interval", reply, sizeof(reply)));
assert(strstr(reply, "default") && !strstr(reply, "0 hours"));
for (const char* value : {"1", "24", "336", "337", "4294967296", "999999999999999999999999"}) {
char command[96];
uint16_t expected=0; assert(mesh::gps::parseSyncIntervalHours(value,expected));
snprintf(command,sizeof(command),"gps.sync.interval %s",value);
common.set(command,reply);
assert(common.prefs.gps_sync_interval_hours==expected);
assert(common.persisted.gps_sync_interval_hours==expected && common.sensors.provider.hours==expected);
char actual[32];snprintf(actual,sizeof(actual),"%u hours",unsigned(expected));
assert(strstr(reply,actual) && strstr(reply,"saved"));
snprintf(command,sizeof(command),"set gps.sync.interval %s",value);
assert(companion.command(command,reply,sizeof(reply)));
assert(companion._prefs.gps_sync_interval_hours==expected);
assert(companion.persisted.gps_sync_interval_hours==expected && companion.sensors.provider.hours==expected);
assert(strstr(reply,actual) && strstr(reply,"saved"));
}
for(const char* value : {"", "0", "0000", "-1", "1.5", "5h", "1 2", "337junk"}) {
char command[64];
const unsigned common_saves=common.saves, companion_saves=companion.saves;
snprintf(command,sizeof(command),"gps.sync.interval %s",value);
common.set(command,reply);assert(strstr(reply,"Error:"));
snprintf(command,sizeof(command),"set gps.sync.interval %s",value);
assert(companion.command(command,reply,sizeof(reply)) && strstr(reply,"Error:"));
assert(common.saves==common_saves && companion.saves==companion_saves);
}
common.save_ok=companion.save_ok=false;
const unsigned common_applied=common.sensors.applications, companion_applied=companion.sensors.applications;
common.set("gps.sync.interval 7",reply);assert(strstr(reply,"could not be saved"));
assert(companion.command("set gps.sync.interval 7",reply,sizeof(reply)) && strstr(reply,"could not be saved"));
assert(common.prefs.gps_sync_interval_hours==336 && common.persisted.gps_sync_interval_hours==336);
assert(companion._prefs.gps_sync_interval_hours==336 && companion.persisted.gps_sync_interval_hours==336);
assert(common.sensors.provider.hours==336 && companion.sensors.provider.hours==336);
assert(common.sensors.applications==common_applied && companion.sensors.applications==companion_applied);
common.get("gps.sync.interval",reply);assert(!strcmp(reply,"> 336 hours"));
assert(companion.command("get gps.sync.interval",reply,sizeof(reply)) && !strcmp(reply,"> 336 hours"));
common.sensors.present=companion.sensors.present=false;
const unsigned common_saves=common.saves, companion_saves=companion.saves;
common.set("gps.sync.interval 1",reply);assert(strstr(reply,"unavailable"));
assert(companion.command("set gps.sync.interval 1",reply,sizeof(reply)) && strstr(reply,"unavailable"));
common.get("gps.sync.interval",reply);assert(strstr(reply,"unavailable"));
assert(companion.command("get gps.sync.interval",reply,sizeof(reply)) && strstr(reply,"unavailable"));
assert(common.saves==common_saves && companion.saves==companion_saves);
}
'''
class GpsSyncPreferencesTest(unittest.TestCase):
def test_canonical_cli_transactions(self):
common = (ROOT / 'src/helpers/CommonCLI.cpp').read_text()
companion = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
header = (ROOT / 'examples/companion_radio/MyMesh.h').read_text()
replacements = {
'@COMMON_SET@': extract_braced(common, 'if (strncmp(config, "gps.sync.interval", 17)'),
'@COMMON_GET@': extract_braced(common, 'if (strcmp(config, "gps.sync.interval") == 0)'),
'@COMPANION_GET@': extract_braced(companion, 'if (strcmp(command, "get gps.sync.interval") == 0)'),
'@COMPANION_PREFIX@': extract_braced(companion, 'if (strncmp(command, "set gps.sync.interval", 21)'),
'@COMPANION_SET@': extract_braced(companion, 'if (gps_sync_value)'),
'@SAVE_PREFERENCE@': extract_braced(header, 'template <typename T> bool savePreference('),
}
code = HARNESS
for token, value in replacements.items():
code = code.replace(token, value)
with tempfile.TemporaryDirectory(prefix='meshcore-gps-sync-prefs-') as directory:
cpp, exe = Path(directory) / 'test.cpp', Path(directory) / 'test.exe'
cpp.write_text(code)
subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', '-Werror',
'-I' + str(ROOT / 'src'), str(cpp), '-o', str(exe)], check=True)
subprocess.run([str(exe)], check=True)
if __name__ == '__main__':
unittest.main()
+3 -2
View File
@@ -392,9 +392,10 @@ int main(){
rak_provider = rak[rak.index("class RAK12500LocationProvider"):rak.index("static RAK12500LocationProvider")]
self.assertIn("markTimeSyncApplied();", rak_provider)
self.assertIn("resetTimeSyncRequestState();", rak_provider)
# Preserve deliberate force-sync callers outside telemetry queries.
# Explicit CLI sync remains forced; scheduled power cycles honor the
# configured hourly cadence, with force-sync retained for legacy0.
self.assertIn("void syncTime() override { nmea.clear(); LocationProvider::syncTime(); }", provider)
self.assertIn("_location->syncTime();", extract_braced(rak, "void EnvironmentSensorManager::armGpsPowerSavingCycle()"))
self.assertIn("_location->syncTimeForPowerSavingCycle();", extract_braced(rak, "void EnvironmentSensorManager::armGpsPowerSavingCycle()"))
if __name__ == "__main__":
+10 -3
View File
@@ -35,6 +35,10 @@ static unsigned rx_flushes = 0, tx_clears = 0, log_edges = 0;
// leave an armed packet intact; only a real detach + enumeration retires it.
static bool armed_in[2] = {false, false}, attached = true;
static unsigned detaches = 0, attaches = 0;
struct Registers { unsigned USBPULLUP = 1; } registers;
#define NRF_USBD (&registers)
static void __ISB() {}
static void __DSB() {}
static bool close_during_rx_sample = false;
static uint32_t millis() { return now_ms; }
static bool tud_mounted() { return mounted; }
@@ -52,8 +56,8 @@ static void tud_cdc_n_read_flush(uint8_t) { ++rx_flushes; rx_count = 0; }
static uint32_t tud_cdc_n_write_clear(uint8_t) { ++tx_clears; tx_count = 0; return 0; }
static bool mesh_tud_cdc_n_tx_pending(uint8_t n) { return armed_in[n]; }
struct UsbDevice {
void detach() { ++detaches; attached = false; }
void attach() { ++attaches; attached = true; }
void detach() { ++detaches; attached = false; registers.USBPULLUP = 0; }
void attach() { ++attaches; attached = true; registers.USBPULLUP = 1; }
} TinyUSBDevice;
static uint32_t tud_cdc_n_write_available(uint8_t) { return 64 - tx_count; }
static uint32_t tud_cdc_n_write(uint8_t, const void*, uint32_t size) {
@@ -159,9 +163,12 @@ int main() {
rx_count = 4;
assert(mesh::isUsbCompanionClientConnected());
line(false);
assert(tx_count == 0 && armed_in[0] && !attached && detaches == 1);
assert(tx_count == 0 && armed_in[0] && registers.USBPULLUP == 0);
assert(attached && detaches == 0); // callback cannot enqueue a stack event
assert(!mesh::isUsbCompanionClientConnected());
assert(mesh::writeTinyUsbCdcOnce(nullptr, bytes, 4) == 0);
mesh::serviceNrf52UsbSessionReenumeration();
assert(!attached && detaches == 1); // application loop performs stack detach
// Repeated control callbacks cannot cause a detach/attach storm.
meshTinyUsbCdcLineCodingChanged(0);
line(false);
+113 -4
View File
@@ -127,6 +127,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("\n#endif", start)]
pinned_driver = (ROOT / "test/fixtures/nrf52_usb_power_original.c").read_text()
start = pinned_driver.index("void dcd_disconnect(")
disconnect = pinned_driver[start:pinned_driver.index("\n}\n", start) + 3]
source = r'''
#include <atomic>
#include <cassert>
#include <cstdint>
#define ENABLE_USB_INTERFACE 1
static bool mounted = true, armed[2] = {false, true};
static uint32_t now = 100, fifo[2] = {0, 32};
static bool owner_callback = false;
static constexpr unsigned DCD_EVENT_UNPLUGGED = 1, queue_capacity = 4;
static unsigned events[queue_capacity] = {0, 0, 0, 0};
static unsigned queue_head = 0, queue_tail = 0, queue_count = queue_capacity;
static unsigned event_sends = 0, detach_count = 0, attach_count = 0;
static unsigned owner_resets = 0;
struct Registers { unsigned USBPULLUP = 1; } registers;
#define NRF_USBD (&registers)
static void __ISB() {}
static void __DSB() {}
static uint32_t millis() { return now; }
static bool tud_mounted() { return mounted; }
static uint32_t tud_cdc_n_available(uint8_t) { return 0; }
static void tud_cdc_n_read_flush(uint8_t) {}
static bool tud_cdc_n_write_clear(uint8_t n) { fifo[n] = 0; return true; }
static bool mesh_tud_cdc_n_tx_pending(uint8_t n) { return armed[n]; }
static void owner_dispatch_one() {
assert(!owner_callback && queue_count != 0);
owner_callback = true;
const unsigned event = events[queue_tail];
queue_tail = (queue_tail + 1) % queue_capacity;
--queue_count;
if (event == DCD_EVENT_UNPLUGGED) {
mounted = false;
armed[0] = armed[1] = false;
++owner_resets;
}
owner_callback = false;
}
static void dcd_event_bus_signal(unsigned port, unsigned event, bool isr) {
assert(port == 0 && event == DCD_EVENT_UNPLUGGED && !isr);
// Real osal_queue_send uses an infinite FreeRTOS timeout here. If called
// by its sole consumer while full, it could never return. Fail immediately
// rather than hanging the test on precisely that regression.
assert(!owner_callback);
++event_sends;
if (queue_count == queue_capacity) {
// A blocked application sender yields to the independent USB owner,
// which drains an existing event and releases a slot for UNPLUGGED.
owner_dispatch_one();
}
assert(queue_count < queue_capacity);
events[queue_head] = event;
queue_head = (queue_head + 1) % queue_capacity;
++queue_count;
}
@DISCONNECT@
struct Device {
void detach() { ++detach_count; dcd_disconnect(0); }
void attach() { ++attach_count; registers.USBPULLUP = 1; }
} TinyUSBDevice;
namespace mesh {
@GATES@
}
int main() {
// The owner is servicing a close/SOF callback while IRQ events fill every
// available queue slot. The old data is armed in the controller, not FIFO.
owner_callback = true;
mesh::clearNrf52UsbTxForSession(1);
assert(queue_count == queue_capacity && event_sends == 0);
assert(registers.USBPULLUP == 0 && armed[1] && fifo[1] == 0);
assert(!mesh::canAccessPrimaryUsbSession(nullptr));
mesh::requestNrf52UsbSessionReenumeration(); // repeated callback is harmless
assert(detach_count == 0 && event_sends == 0);
owner_callback = false;
// A busy application loop can service much later. Its first pass only
// issues the stack detach; it must not reuse an expired callback deadline.
now += 400;
mesh::serviceNrf52UsbSessionReenumeration();
assert(detach_count == 1 && event_sends == 1 && attach_count == 0);
mesh::serviceNrf52UsbSessionReenumeration();
assert(detach_count == 1 && attach_count == 0);
now += 20;
mesh::serviceNrf52UsbSessionReenumeration();
assert(attach_count == 0); // owner has not consumed UNPLUGGED yet
while (queue_count != 0) owner_dispatch_one();
assert(owner_resets == 1 && !mounted && !armed[1]);
mesh::serviceNrf52UsbSessionReenumeration();
assert(attach_count == 1 && registers.USBPULLUP == 1);
mesh::serviceNrf52UsbSessionReenumeration();
assert(detach_count == 1 && attach_count == 1); // recovery is one-shot
}
'''
self.run_native(source.replace("@DISCONNECT@", disconnect).replace("@GATES@", gates))
def test_armed_logging_packet_and_racing_primary_packet_force_clean_bus(self):
start = USB.index("static std::atomic<uint32_t> primary_usb_reset_generation")
gates = USB[start:USB.index("\n#endif", start)]
@@ -138,6 +237,10 @@ int main() {
static bool mounted = true, attached = true, armed[2] = {false, true};
static uint32_t fifo[2] = {0, 32}, now = UINT32_MAX - 10;
static unsigned detach_count = 0, attach_count = 0, reset_count = 0;
struct Registers { unsigned USBPULLUP = 1; } registers;
#define NRF_USBD (&registers)
static void __ISB() {}
static void __DSB() {}
static uint32_t millis() { return now; }
static bool tud_mounted() { return mounted; }
static uint32_t tud_cdc_n_available(uint8_t) { return 0; }
@@ -145,8 +248,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 {
@GATES@
@@ -163,9 +266,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;
@@ -182,8 +288,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)
+6 -3
View File
@@ -25,10 +25,12 @@ class UsbDiagnosticLoggingTests(unittest.TestCase):
with tempfile.TemporaryDirectory(prefix="meshcore-usb-diagnostics-") as directory:
temporary = Path(directory)
(temporary / "NetworkLoggingMacro.h").write_text(macro, encoding="utf-8")
for debug in (None, 0, 1):
with self.subTest(mqtt_debug=debug):
for debug, touch_debug in ((None, False), (0, False), (1, False), (1, True)):
with self.subTest(mqtt_debug=debug, touch_debug=touch_debug):
binary = temporary / "usb-diagnostics.exe"
defines = [] if debug is None else [f"-DMQTT_DEBUG={debug}"]
if touch_debug:
defines += ["-DDISPLAY_TOUCH_DEBUG=1", "-DPIN_TOUCH_INT=7"]
compiled = subprocess.run(
[compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-DARDUINO=1", "-DETHERNET_ENABLED=1", *defines,
@@ -45,7 +47,8 @@ class UsbDiagnosticLoggingTests(unittest.TestCase):
self.assertIn("PASS: gated USB diagnostics", checked.stdout)
def test_no_raw_serial_diagnostic_writes(self):
for relative in ("src/helpers/nrf52/EthernetCLI.h", "src/helpers/NetworkLink.cpp"):
for relative in ("src/helpers/nrf52/EthernetCLI.h", "src/helpers/NetworkLink.cpp",
"src/helpers/ui/CHSC6XTouch.h", "src/helpers/ethernet/ch390/CH390Config.h"):
with self.subTest(source=relative):
source = (ROOT / relative).read_text(encoding="utf-8")
self.assertIn("UsbLogging.h", source)
+1 -1
View File
@@ -31,7 +31,7 @@ mesh::LocalIdentity radio_new_identity() {
void SolarSensorManager::armGpsPowerSavingCycle() {
if (!powersaving_enabled || !_location->getGPSPowerSaving()) return;
_location->syncTime();
_location->syncTimeForPowerSavingCycle();
_location->setNextGPSOn(0);
_location->setNextSleep();
}
+1 -1
View File
@@ -44,7 +44,7 @@ mesh::LocalIdentity radio_new_identity() {
void HWTSensorManager::armGpsPowerSavingCycle() {
if (!powersaving_enabled || !_location->getGPSPowerSaving()) return;
_location->syncTime();
_location->syncTimeForPowerSavingCycle();
_location->setNextGPSOn(0);
_location->setNextSleep();
}
+1 -1
View File
@@ -38,7 +38,7 @@ mesh::LocalIdentity radio_new_identity() {
void MASensorManager::armGpsPowerSavingCycle() {
if (!powersaving_enabled || !_location->getGPSPowerSaving()) return;
_location->syncTime();
_location->syncTimeForPowerSavingCycle();
_location->setNextGPSOn(0);
_location->setNextSleep();
}
+1 -1
View File
@@ -28,7 +28,7 @@ mesh::LocalIdentity radio_new_identity() {
void MeshTrackerX1SensorManager::armGpsPowerSavingCycle() {
if (!powersaving_enabled || !_nmea->getGPSPowerSaving()) return;
_nmea->syncTime();
_nmea->syncTimeForPowerSavingCycle();
_nmea->setNextGPSOn(0);
_nmea->setNextSleep();
}
+1 -1
View File
@@ -27,7 +27,7 @@ bool radio_init() {
void NanoG2UltraSensorManager::armGpsPowerSavingCycle() {
if (!powersaving_enabled || !_location->getGPSPowerSaving()) return;
_location->syncTime();
_location->syncTimeForPowerSavingCycle();
_location->setNextGPSOn(0);
_location->setNextSleep();
}
+1 -1
View File
@@ -96,7 +96,7 @@ mesh::LocalIdentity radio_new_identity() {
void T1000SensorManager::armGpsPowerSavingCycle() {
if (!powersaving_enabled || !_nmea->getGPSPowerSaving()) return;
_nmea->syncTime();
_nmea->syncTimeForPowerSavingCycle();
_nmea->setNextGPSOn(0);
_nmea->setNextSleep();
}
+1 -1
View File
@@ -31,7 +31,7 @@ mesh::LocalIdentity radio_new_identity() {
void ThinkNodeM1SensorManager::armGpsPowerSavingCycle() {
if (!powersaving_enabled || !_location->getGPSPowerSaving()) return;
_location->syncTime();
_location->syncTimeForPowerSavingCycle();
_location->setNextGPSOn(0);
_location->setNextSleep();
}