diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 6b5ee0644..41e7fd09b 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -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 diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index 7f78f545a..aff457a1e 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -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 | diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 58a32ac75..44c235b96 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -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. --- diff --git a/docs/full_companion_features.md b/docs/full_companion_features.md index 95071317e..8abe1a917 100644 --- a/docs/full_companion_features.md +++ b/docs/full_companion_features.md @@ -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 ` 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 diff --git a/docs/test-results/usb_serial_backpressure.md b/docs/test-results/usb_serial_backpressure.md index 4928f056d..da24519fc 100644 --- a/docs/test-results/usb_serial_backpressure.md +++ b/docs/test-results/usb_serial_backpressure.md @@ -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. diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index e87c2bfe7..441bcaf2f 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -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); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index b712e9033..5ba5bcac6 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -70,6 +70,7 @@ #endif #include +#include #if COMPANION_FEATURE_TEXT_TERMINAL #include #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(1 + rlen); + endUsbLoggingReplyBarrier(reply_queued); } } else if (cmd_frame[0] == CMD_SEND_TXT_MSG && len >= 14) { int i = 1; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 7ed1f73b4..85334ce7f 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -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 diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index a3350b26c..91ed89b65 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -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 { diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index d82cf9f0c..0a7f56e23 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -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 diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index e3a931af3..462a78f81 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -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(available) + ? serial_log_pending_size : static_cast(available); + size_t written = console.write( + reinterpret_cast(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(serial_log_pending_size)) return; - size_t written = console.write( - reinterpret_cast(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(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(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(length) >= sizeof(record)) { + serial_recent_next = -1; + return; + } + memcpy(serial_log_pending, record, length); + serial_log_pending_size = static_cast(length); } - // One complete row per pass, admitted atomically only when it fits. - if (length <= 0 || static_cast(length) >= sizeof(record)) { - serial_recent_next = -1; - return; - } - if (console.availableForWrite() < length - || console.write(reinterpret_cast(record), length) - != static_cast(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(sizeof(eof) - 1) - && console.write(reinterpret_cast(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(serial_log_pending_size)) { - size_t written = console.write( - reinterpret_cast(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; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 264c000be..833de52d0 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -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 diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 1393ec12e..0c29d13df 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -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(available) + ? serial_log_pending_size : static_cast(available); + size_t written = console.write( + reinterpret_cast(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(serial_log_pending_size)) return; - size_t written = console.write( - reinterpret_cast(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(sizeof(eof) - 1) - && console.write(reinterpret_cast(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(serial_log_pending_size)) { - size_t written = console.write( - reinterpret_cast(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; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index b73b64041..2b42b8df4 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -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 diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index db8a38fe2..3dbf74b6a 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -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 }; diff --git a/platformio.ini b/platformio.ini index a0a9e238d..a69006141 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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 diff --git a/scripts/esp32_usb_session_fix.py b/scripts/esp32_usb_session_fix.py new file mode 100644 index 000000000..7dfd9b9f0 --- /dev/null +++ b/scripts/esp32_usb_session_fix.py @@ -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) diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 132e02528..f318f877a 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -3,6 +3,7 @@ #include "PrefsSaveReplyGuard.h" #include #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); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 65342906c..87b3f66b0 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -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); } diff --git a/src/helpers/GpsPowerPolicy.h b/src/helpers/GpsPowerPolicy.h index 516078da4..ab2ab4df1 100644 --- a/src/helpers/GpsPowerPolicy.h +++ b/src/helpers/GpsPowerPolicy.h @@ -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(*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; diff --git a/src/helpers/SensorManager.h b/src/helpers/SensorManager.h index 7971f29da..e21d29a80 100644 --- a/src/helpers/SensorManager.h +++ b/src/helpers/SensorManager.h @@ -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(location->getTimeSyncIntervalHours()) * 3600UL; + } return location != nullptr && location->getGPSPowerSaving() && location->requestTimeSync(min_interval_secs); #else diff --git a/src/helpers/UsbLogging.cpp b/src/helpers/UsbLogging.cpp index 3ad964ea3..54825b247 100644 --- a/src/helpers/UsbLogging.cpp +++ b/src/helpers/UsbLogging.cpp @@ -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 + #include // 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 esp32_tinyusb_terminal_discard_pending{false}; static std::atomic 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 esp32_tinyusb_was_connected{false}; +// 0: ordinary; 1: detached; 2: awaiting fresh mount; 3: preparing quarantine. +static std::atomic esp32_tinyusb_reenumeration_state{0}; +static std::atomic esp32_tinyusb_owner_dtr{false}; +static std::atomic 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(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 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 primary_usb_reset_settle_until{0}; static std::atomic nrf52_usb_reenumeration_pending{false}; static std::atomic 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 diff --git a/src/helpers/ethernet/ch390/CH390Config.h b/src/helpers/ethernet/ch390/CH390Config.h index 057230677..8bf10b7c2 100644 --- a/src/helpers/ethernet/ch390/CH390Config.h +++ b/src/helpers/ethernet/ch390/CH390Config.h @@ -1,6 +1,7 @@ #pragma once #include +#include /** * 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; diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index 123240b9e..d383b95ac 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -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(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(); } diff --git a/src/helpers/sensors/LocationProvider.h b/src/helpers/sensors/LocationProvider.h index 599255227..592b087f4 100644 --- a/src/helpers/sensors/LocationProvider.h +++ b/src/helpers/sensors/LocationProvider.h @@ -1,6 +1,7 @@ #pragma once #include "Mesh.h" +#include #include #include #include @@ -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(_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(_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(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() { diff --git a/src/helpers/sensors/MicroNMEALocationProvider.h b/src/helpers/sensors/MicroNMEALocationProvider.h index 6374f5f6e..dd6b6ed4b 100644 --- a/src/helpers/sensors/MicroNMEALocationProvider.h +++ b/src/helpers/sensors/MicroNMEALocationProvider.h @@ -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(millis() - _last_time_sync) + >= periodicTimeSyncIntervalMillis()) { _time_sync_needed = true; } const long timestamp = getTimestamp(); diff --git a/src/helpers/ui/CHSC6XTouch.h b/src/helpers/ui/CHSC6XTouch.h index b54a3651d..c370f589d 100644 --- a/src/helpers/ui/CHSC6XTouch.h +++ b/src/helpers/ui/CHSC6XTouch.h @@ -2,6 +2,9 @@ #include #include +#include +#include +#include #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(length)) return; + output.write(reinterpret_cast(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(length) < sizeof(record)) { + writeDiagnostic(record, static_cast(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*) {} diff --git a/test/fixtures/esp32_tinyusb_nonblocking/mocks/esp32-hal-tinyusb.h b/test/fixtures/esp32_tinyusb_nonblocking/mocks/esp32-hal-tinyusb.h index 5ef060ee5..e264d83ac 100644 --- a/test/fixtures/esp32_tinyusb_nonblocking/mocks/esp32-hal-tinyusb.h +++ b/test/fixtures/esp32_tinyusb_nonblocking/mocks/esp32-hal-tinyusb.h @@ -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(); diff --git a/test/fixtures/esp32_tinyusb_nonblocking/test_esp32_tinyusb_nonblocking.cpp b/test/fixtures/esp32_tinyusb_nonblocking/test_esp32_tinyusb_nonblocking.cpp index 16db4d471..d823d416c 100644 --- a/test/fixtures/esp32_tinyusb_nonblocking/test_esp32_tinyusb_nonblocking.cpp +++ b/test/fixtures/esp32_tinyusb_nonblocking/test_esp32_tinyusb_nonblocking.cpp @@ -2,11 +2,16 @@ #include #include #include +#include +#include #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 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(); diff --git a/test/fixtures/usb_diagnostic_logging/mocks/Arduino.h b/test/fixtures/usb_diagnostic_logging/mocks/Arduino.h index 6acf786a1..ca589a106 100644 --- a/test/fixtures/usb_diagnostic_logging/mocks/Arduino.h +++ b/test/fixtures/usb_diagnostic_logging/mocks/Arduino.h @@ -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; } diff --git a/test/fixtures/usb_diagnostic_logging/mocks/ESP32_CH390.h b/test/fixtures/usb_diagnostic_logging/mocks/ESP32_CH390.h new file mode 100644 index 000000000..b7c3e1f87 --- /dev/null +++ b/test/fixtures/usb_diagnostic_logging/mocks/ESP32_CH390.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +#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; diff --git a/test/fixtures/usb_diagnostic_logging/mocks/Wire.h b/test/fixtures/usb_diagnostic_logging/mocks/Wire.h new file mode 100644 index 000000000..1df665a1a --- /dev/null +++ b/test/fixtures/usb_diagnostic_logging/mocks/Wire.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +class TwoWire { + public: + bool present = true; + bool all_addresses_present = false; + unsigned probes = 0; + unsigned fail_probe_count = 0; + std::array 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(reply_length) - static_cast(cursor); } + int read() { return cursor < reply_length ? frame[cursor++] : -1; } +}; + +extern TwoWire Wire; diff --git a/test/fixtures/usb_diagnostic_logging/test.cpp b/test/fixtures/usb_diagnostic_logging/test.cpp index db8d6f5bd..8b989e202 100644 --- a/test/fixtures/usb_diagnostic_logging/test.cpp +++ b/test/fixtures/usb_diagnostic_logging/test.cpp @@ -1,6 +1,8 @@ #include #include #include "NetworkLoggingMacro.h" +#include +#include #include @@ -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"; } diff --git a/test/test_common_radio_persistence.py b/test/test_common_radio_persistence.py index f8c201276..60d251d84 100644 --- a/test/test_common_radio_persistence.py +++ b/test/test_common_radio_persistence.py @@ -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); diff --git a/test/test_companion_bluetooth_persistence.py b/test/test_companion_bluetooth_persistence.py index d5026466c..7ae8dbb9f 100644 --- a/test/test_companion_bluetooth_persistence.py +++ b/test/test_companion_bluetooth_persistence.py @@ -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)); diff --git a/test/test_companion_preferences_transaction.py b/test/test_companion_preferences_transaction.py index 27b519890..11d0c9355 100644 --- a/test/test_companion_preferences_transaction.py +++ b/test/test_companion_preferences_transaction.py @@ -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) diff --git a/test/test_companion_prefs_transactions.py b/test/test_companion_prefs_transactions.py index d465a2d3e..9ab77a71d 100644 --- a/test/test_companion_prefs_transactions.py +++ b/test/test_companion_prefs_transactions.py @@ -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), diff --git a/test/test_companion_usb_default.py b/test/test_companion_usb_default.py index 7e7c01f47..b014fee24 100644 --- a/test/test_companion_usb_default.py +++ b/test/test_companion_usb_default.py @@ -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) diff --git a/test/test_companion_usb_logging_reply.py b/test/test_companion_usb_logging_reply.py new file mode 100644 index 000000000..e8c15c7b7 --- /dev/null +++ b/test/test_companion_usb_logging_reply.py @@ -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 +#include +#include +#include +#include +#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 rx, tx; + size_t cursor=0; + int capacity=4096, max_write=4096; + int available() override { return static_cast(rx.size()-cursor); } + int read() override { return cursorstatic_cast(capacity)) n=capacity; + if(n>static_cast(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(n),0,mesh::companion::kRunCliCommand}; + rx.insert(rx.end(),command,command+n-1); + cursor=0; + } +} stream; +struct Remote : BaseSerialInterface { + bool enabled=false; + std::vector 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() diff --git a/test/test_esp32_tinyusb_cooperative_output.py b/test/test_esp32_tinyusb_cooperative_output.py index 036b23380..204de3439 100644 --- a/test/test_esp32_tinyusb_cooperative_output.py +++ b/test/test_esp32_tinyusb_cooperative_output.py @@ -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; diff --git a/test/test_esp32_tinyusb_role_hygiene.py b/test/test_esp32_tinyusb_role_hygiene.py index 7dc179bc8..101d26ddd 100644 --- a/test/test_esp32_tinyusb_role_hygiene.py +++ b/test/test_esp32_tinyusb_role_hygiene.py @@ -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(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") diff --git a/test/test_esp32_usb_session_fix.py b/test/test_esp32_usb_session_fix.py new file mode 100644 index 000000000..4b170597b --- /dev/null +++ b/test/test_esp32_usb_session_fix.py @@ -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() diff --git a/test/test_gps_sync_interval.py b/test/test_gps_sync_interval.py new file mode 100644 index 000000000..36479c097 --- /dev/null +++ b/test/test_gps_sync_interval.py @@ -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 +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 +#include +#include +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(wrap.getNextWake())== + static_cast(UINT32_MAX-1000U+336U*hour)); + wrap.syncTimeForPowerSavingCycle();assert(!wrap.waitingTimeSync()); + now_ms=static_cast(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(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 +#include +void LocationProvider::sendSentence(const char*){} +struct Uart:Stream { + std::deque incoming; + int available()override{return static_cast(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();} +}; +templatevoid 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(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(hours,1);interval(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(hours,UINT32_MAX-3000U); + interval(hours,UINT32_MAX-3000U); + } + } +} +''' + +TELEMETRY = r''' +#include +#include +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() diff --git a/test/test_gps_sync_preferences.py b/test/test_gps_sync_preferences.py new file mode 100644 index 000000000..8952f8d5c --- /dev/null +++ b/test/test_gps_sync_preferences.py @@ -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 +#include +#include +#include +#include +#include +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 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() diff --git a/test/test_gps_upstream_adaptations.py b/test/test_gps_upstream_adaptations.py index 076ca0a7d..6369a6189 100644 --- a/test/test_gps_upstream_adaptations.py +++ b/test/test_gps_upstream_adaptations.py @@ -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__": diff --git a/test/test_nrf52_usb_client.py b/test/test_nrf52_usb_client.py index 79e5c5d4c..27c3ea1ac 100644 --- a/test/test_nrf52_usb_client.py +++ b/test/test_nrf52_usb_client.py @@ -35,6 +35,10 @@ static unsigned rx_flushes = 0, tx_clears = 0, log_edges = 0; // leave an armed packet intact; only a real detach + enumeration retires it. static bool armed_in[2] = {false, false}, attached = true; static unsigned detaches = 0, attaches = 0; +struct Registers { unsigned USBPULLUP = 1; } registers; +#define NRF_USBD (®isters) +static void __ISB() {} +static void __DSB() {} static bool close_during_rx_sample = false; static uint32_t millis() { return now_ms; } static bool tud_mounted() { return mounted; } @@ -52,8 +56,8 @@ static void tud_cdc_n_read_flush(uint8_t) { ++rx_flushes; rx_count = 0; } static uint32_t tud_cdc_n_write_clear(uint8_t) { ++tx_clears; tx_count = 0; return 0; } static bool mesh_tud_cdc_n_tx_pending(uint8_t n) { return armed_in[n]; } struct UsbDevice { - void detach() { ++detaches; attached = false; } - void attach() { ++attaches; attached = true; } + void detach() { ++detaches; attached = false; registers.USBPULLUP = 0; } + void attach() { ++attaches; attached = true; registers.USBPULLUP = 1; } } TinyUSBDevice; static uint32_t tud_cdc_n_write_available(uint8_t) { return 64 - tx_count; } static uint32_t tud_cdc_n_write(uint8_t, const void*, uint32_t size) { @@ -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); diff --git a/test/test_nrf52_usb_lifecycle.py b/test/test_nrf52_usb_lifecycle.py index b9cd17400..50c3cc703 100644 --- a/test/test_nrf52_usb_lifecycle.py +++ b/test/test_nrf52_usb_lifecycle.py @@ -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 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 +#include +#include +#define ENABLE_USB_INTERFACE 1 +static bool mounted = true, armed[2] = {false, true}; +static uint32_t now = 100, fifo[2] = {0, 32}; +static bool owner_callback = false; +static constexpr unsigned DCD_EVENT_UNPLUGGED = 1, queue_capacity = 4; +static unsigned events[queue_capacity] = {0, 0, 0, 0}; +static unsigned queue_head = 0, queue_tail = 0, queue_count = queue_capacity; +static unsigned event_sends = 0, detach_count = 0, attach_count = 0; +static unsigned owner_resets = 0; +struct Registers { unsigned USBPULLUP = 1; } registers; +#define NRF_USBD (®isters) +static void __ISB() {} +static void __DSB() {} +static uint32_t millis() { return now; } +static bool tud_mounted() { return mounted; } +static uint32_t tud_cdc_n_available(uint8_t) { return 0; } +static void tud_cdc_n_read_flush(uint8_t) {} +static bool tud_cdc_n_write_clear(uint8_t n) { fifo[n] = 0; return true; } +static bool mesh_tud_cdc_n_tx_pending(uint8_t n) { return armed[n]; } +static void owner_dispatch_one() { + assert(!owner_callback && queue_count != 0); + owner_callback = true; + const unsigned event = events[queue_tail]; + queue_tail = (queue_tail + 1) % queue_capacity; + --queue_count; + if (event == DCD_EVENT_UNPLUGGED) { + mounted = false; + armed[0] = armed[1] = false; + ++owner_resets; + } + owner_callback = false; +} +static void dcd_event_bus_signal(unsigned port, unsigned event, bool isr) { + assert(port == 0 && event == DCD_EVENT_UNPLUGGED && !isr); + // Real osal_queue_send uses an infinite FreeRTOS timeout here. If called + // by its sole consumer while full, it could never return. Fail immediately + // rather than hanging the test on precisely that regression. + assert(!owner_callback); + ++event_sends; + if (queue_count == queue_capacity) { + // A blocked application sender yields to the independent USB owner, + // which drains an existing event and releases a slot for UNPLUGGED. + owner_dispatch_one(); + } + assert(queue_count < queue_capacity); + events[queue_head] = event; + queue_head = (queue_head + 1) % queue_capacity; + ++queue_count; +} +@DISCONNECT@ +struct Device { + void detach() { ++detach_count; dcd_disconnect(0); } + void attach() { ++attach_count; registers.USBPULLUP = 1; } +} TinyUSBDevice; +namespace mesh { +@GATES@ +} +int main() { + // The owner is servicing a close/SOF callback while IRQ events fill every + // available queue slot. The old data is armed in the controller, not FIFO. + owner_callback = true; + mesh::clearNrf52UsbTxForSession(1); + assert(queue_count == queue_capacity && event_sends == 0); + assert(registers.USBPULLUP == 0 && armed[1] && fifo[1] == 0); + assert(!mesh::canAccessPrimaryUsbSession(nullptr)); + mesh::requestNrf52UsbSessionReenumeration(); // repeated callback is harmless + assert(detach_count == 0 && event_sends == 0); + owner_callback = false; + + // A busy application loop can service much later. Its first pass only + // issues the stack detach; it must not reuse an expired callback deadline. + now += 400; + mesh::serviceNrf52UsbSessionReenumeration(); + assert(detach_count == 1 && event_sends == 1 && attach_count == 0); + mesh::serviceNrf52UsbSessionReenumeration(); + assert(detach_count == 1 && attach_count == 0); + now += 20; + mesh::serviceNrf52UsbSessionReenumeration(); + assert(attach_count == 0); // owner has not consumed UNPLUGGED yet + while (queue_count != 0) owner_dispatch_one(); + assert(owner_resets == 1 && !mounted && !armed[1]); + mesh::serviceNrf52UsbSessionReenumeration(); + assert(attach_count == 1 && registers.USBPULLUP == 1); + mesh::serviceNrf52UsbSessionReenumeration(); + assert(detach_count == 1 && attach_count == 1); // recovery is one-shot +} +''' + self.run_native(source.replace("@DISCONNECT@", disconnect).replace("@GATES@", gates)) + def test_armed_logging_packet_and_racing_primary_packet_force_clean_bus(self): start = USB.index("static std::atomic 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 (®isters) +static void __ISB() {} +static void __DSB() {} static uint32_t millis() { return now; } static bool tud_mounted() { return mounted; } static uint32_t tud_cdc_n_available(uint8_t) { return 0; } @@ -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) diff --git a/test/test_usb_diagnostic_logging.py b/test/test_usb_diagnostic_logging.py index 7075b9aec..7990f459b 100644 --- a/test/test_usb_diagnostic_logging.py +++ b/test/test_usb_diagnostic_logging.py @@ -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) diff --git a/variants/heltec_mesh_solar/target.cpp b/variants/heltec_mesh_solar/target.cpp index 8a4e48ca3..f24c8c2aa 100644 --- a/variants/heltec_mesh_solar/target.cpp +++ b/variants/heltec_mesh_solar/target.cpp @@ -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(); } diff --git a/variants/heltec_tracker/target.cpp b/variants/heltec_tracker/target.cpp index 256f04d8e..e75f52e31 100644 --- a/variants/heltec_tracker/target.cpp +++ b/variants/heltec_tracker/target.cpp @@ -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(); } diff --git a/variants/meshadventurer/target.cpp b/variants/meshadventurer/target.cpp index aa6b25775..faa879939 100644 --- a/variants/meshadventurer/target.cpp +++ b/variants/meshadventurer/target.cpp @@ -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(); } diff --git a/variants/meshtracker_x1/target.cpp b/variants/meshtracker_x1/target.cpp index d2a14d690..8235ae48a 100644 --- a/variants/meshtracker_x1/target.cpp +++ b/variants/meshtracker_x1/target.cpp @@ -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(); } diff --git a/variants/nano_g2_ultra/target.cpp b/variants/nano_g2_ultra/target.cpp index eab7b8a02..49229790b 100644 --- a/variants/nano_g2_ultra/target.cpp +++ b/variants/nano_g2_ultra/target.cpp @@ -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(); } diff --git a/variants/t1000-e/target.cpp b/variants/t1000-e/target.cpp index 9001a4ffd..54d9ff22d 100644 --- a/variants/t1000-e/target.cpp +++ b/variants/t1000-e/target.cpp @@ -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(); } diff --git a/variants/thinknode_m1/target.cpp b/variants/thinknode_m1/target.cpp index 81c0f7787..ec8e6efe3 100644 --- a/variants/thinknode_m1/target.cpp +++ b/variants/thinknode_m1/target.cpp @@ -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(); }