Fix USB logging session safety and add hourly GPS sync interval
This commit is contained in:
1 parent
b4c8c0ab23
commit
65512661c3
55 files changed
+2073
-183
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -118,6 +118,12 @@ The Companion app's `gps=1` / `gps=0` custom setting controls the same GPS.
|
||||
Only boards with a compiled GPS provider expose this setting. Sharing location
|
||||
with contacts is a separate setting.
|
||||
|
||||
Use `set gps.sync.interval <hours>` to save an automatic clock-sync interval of
|
||||
1–336 hours (two weeks); `get gps.sync.interval` reports it. Larger inputs are
|
||||
capped at 336 and the reply shows the applied value. Existing GPS timings are
|
||||
unchanged until configured. This controls clock synchronization, not the
|
||||
position-update `gps_interval` or location-sharing policy.
|
||||
|
||||
When the exact Full image includes MQTT, use WebConfig's MQTT cards or the
|
||||
same CLI settings used by infrastructure, such as `set mqtt1.preset custom`
|
||||
and `set mqtt1.server broker.example.com`. `set mqtt.enabled on|off` controls
|
||||
|
||||
@@ -41,12 +41,14 @@ Run only one PlatformIO process in the checkout at a time.
|
||||
|
||||
```sh
|
||||
python test/test_esp32_tinyusb_nonblocking.py
|
||||
python test/test_esp32_usb_session_fix.py
|
||||
python test/test_esp32_tinyusb_role_hygiene.py
|
||||
python test/test_esp32_tinyusb_cooperative_output.py
|
||||
python test/test_esp32_usb_serial_hygiene.py
|
||||
python test/test_nrf52_usb_logging_contract.py
|
||||
python test/test_nrf52_usb_lifecycle.py
|
||||
python test/test_common_cli_logging_transaction.py
|
||||
python test/test_companion_usb_logging_reply.py
|
||||
python test/test_usb_diagnostic_logging.py
|
||||
pio test -e native -f test_nrf52_debug_output -f test_serial_packet_log -f test_serial_mode_switch -f test_mesh_tables
|
||||
```
|
||||
@@ -84,6 +86,28 @@ ownership; USB is log-only during that session. Disabling logging restores USB
|
||||
traffic without accepting bytes typed during the log-only interval. A dedicated
|
||||
nRF52 logging port does not park the separate Companion port.
|
||||
|
||||
A framed USB logging command retains its original requester and queues its
|
||||
acknowledgement before changing ownership. The handoff advances without waiting
|
||||
for the host: live logging stays unchanged while the complete USB reply is
|
||||
backpressured. Disconnect/reset cancels that session's pending handoff; an
|
||||
already-saved preference can still take effect at the next boot. BLE/WiFi replies
|
||||
are never used as a fallback for a refused USB acknowledgement.
|
||||
|
||||
On native ESP32 TinyUSB, build-local framework hooks capture DTR and USB session
|
||||
boundaries synchronously in the USB owner task, before Arduino's delayed events.
|
||||
They purge old input at the boundary and reject late input while DTR is low,
|
||||
without deleting a new host's first query during main-loop cleanup. An armed
|
||||
old IN packet (or a racing writer) triggers immediate soft-disconnect, a minimum
|
||||
20 ms detached interval, and quarantine until fresh enumeration. Shared SDK
|
||||
files are not modified; a changed native-CDC descriptor fails the build closed
|
||||
until the hook is reviewed. This path is specific to the pinned Arduino-ESP32
|
||||
2.0.17 native CDC implementation; hardware Serial/JTAG and UART are unchanged.
|
||||
|
||||
Disabling diagnostics is not itself a protocol change and does not discard
|
||||
functional replies from the shared ESP32 text queue. Actual Binary/mOTA changes
|
||||
use the explicit terminal-output barrier. Touch and invalid-Ethernet-hostname
|
||||
diagnostics also honor the runtime logging gate and bounded USB stream.
|
||||
|
||||
Ethernet and enabled MQTT diagnostics honor the runtime logging gate and use
|
||||
the bounded USB facade. Infrastructure logging setters commit preferences
|
||||
before changing live output; failed saves leave previous settings unchanged.
|
||||
@@ -95,6 +119,10 @@ advance one row per service pass, using their existing output buffer. Reconnect
|
||||
cancels the old listing. The listing is a bounded live view, not an immutable
|
||||
snapshot of clients changed during output.
|
||||
|
||||
Stored dumps make progress even when HWCDC falls back to 256/512-byte TX rings.
|
||||
Recent-repeater headers/rows and EOF retain every unwritten suffix; a short
|
||||
write never causes their accepted prefix to be retried.
|
||||
|
||||
nRF52 FIFO cleanup also checks for an already-armed IN packet. If one exists
|
||||
at a session boundary (including a pending zero-length packet), both USB ports
|
||||
briefly detach and re-enumerate: a FIFO clear alone cannot recall endpoint RAM.
|
||||
@@ -103,3 +131,27 @@ without blocking the mesh loop. Dedicated CDC descriptors are added while
|
||||
detached even if the host has not yet completed enumeration. These recovery
|
||||
paths have native regression coverage; real host/board timing still requires
|
||||
hardware qualification.
|
||||
|
||||
The nRF52 callback only drops the pull-up and closes producer gates. The
|
||||
application loop sends the stack's unplug event afterward, so a full owner-task
|
||||
event queue cannot make its consumer wait on itself. The detach interval starts
|
||||
when that application-side stack detach actually occurs. A regression models a
|
||||
full finite event queue as well as the separate armed endpoint packet.
|
||||
|
||||
## Local qualification (2026-10-03)
|
||||
|
||||
- All 112 USB Python regression methods passed, plus nine logging-transaction,
|
||||
Bluetooth-control, and WiFi-status methods. The native facade includes a
|
||||
threaded close-versus-writer race and an unmount-with-reset-endpoints check.
|
||||
- `Xiao_S3_companion_radio_usb` built successfully with its default hardware
|
||||
Serial/JTAG transport: 880,709 flash bytes and 73,244 static RAM bytes.
|
||||
- A native-TinyUSB Xiao S3 USB Companion qualification build passed with debug
|
||||
and packet logging enabled: 928,133 flash bytes and 89,312 static RAM bytes.
|
||||
Its linked ELF contains the synchronous session hooks and endpoint query.
|
||||
- A RAK4631 Full Companion qualification build with two CDCs, debug/packet
|
||||
logging, and USB/BLE mOTA sources passed: 678,092 flash bytes and 154,428
|
||||
static RAM bytes. The runtime RAM and Bluetooth DFU safety checks also passed.
|
||||
|
||||
The two custom qualification builds used the lab profile
|
||||
`909.5 MHz / 500 kHz / SF5 / CR5`. No board was flashed, and no physical
|
||||
disconnect/reconnect or hardware endpoint recovery was claimed by these checks.
|
||||
@@ -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);
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
#endif
|
||||
|
||||
#include <helpers/CLICommandUtils.h>
|
||||
#include <helpers/GpsPowerPolicy.h>
|
||||
#if COMPANION_FEATURE_TEXT_TERMINAL
|
||||
#include <helpers/TracePathHelpers.h>
|
||||
#endif
|
||||
@@ -2277,6 +2278,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
|
||||
_prefs.buzzer_quiet = DEFAULT_BUZZER_QUIET ? 1 : 0;
|
||||
_prefs.gps_enabled = 0; // GPS disabled by default
|
||||
_prefs.gps_interval = 0; // Use the default 1-second fix-processing interval
|
||||
_prefs.gps_sync_interval_hours = 0; // Retain the board's default time-sync cadence
|
||||
_prefs.autoadd_config = DEFAULT_AUTOADD_CONFIG;
|
||||
_prefs.path_hash_mode = DEFAULT_PATH_HASH_MODE;
|
||||
#ifdef RADIO_FEM_TXGAIN
|
||||
@@ -2574,6 +2576,8 @@ void MyMesh::begin(bool has_display, bool radio_available,
|
||||
_prefs.vibe_quiet = constrain(_prefs.vibe_quiet, 0, 1);
|
||||
_prefs.gps_enabled = constrain(_prefs.gps_enabled, 0, 1); // Ensure boolean 0 or 1
|
||||
_prefs.gps_interval = constrain(_prefs.gps_interval, 0, 86400); // Max 24 hours
|
||||
_prefs.gps_sync_interval_hours = constrain(_prefs.gps_sync_interval_hours,
|
||||
0, mesh::gps::MAX_SYNC_INTERVAL_HOURS);
|
||||
_prefs.autoadd_config &= AUTO_ADD_OVERWRITE_OLDEST | AUTO_ADD_CHAT | AUTO_ADD_REPEATER | AUTO_ADD_ROOM_SERVER | AUTO_ADD_SENSOR;
|
||||
_prefs.path_hash_mode = constrain(_prefs.path_hash_mode, 0, 2);
|
||||
_prefs.radio_fem_rxgain_override = constrain(_prefs.radio_fem_rxgain_override, 0, 1);
|
||||
@@ -3181,6 +3185,37 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
|
||||
}
|
||||
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
if (strcmp(command, "get gps.sync.interval") == 0) {
|
||||
auto* provider = sensors.getLocationProvider();
|
||||
if (provider == nullptr) {
|
||||
snprintf(reply, reply_size, "Error: GPS unavailable");
|
||||
} else {
|
||||
const uint16_t hours = provider->getTimeSyncIntervalHours();
|
||||
if (hours == 0) snprintf(reply, reply_size, "> default (board GPS sync policy)");
|
||||
else snprintf(reply, reply_size, "> %u hours", (unsigned)hours);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const char* gps_sync_value = nullptr;
|
||||
if (strncmp(command, "set gps.sync.interval", 21) == 0
|
||||
&& (command[21] == 0 || command[21] == ' ' || command[21] == '\t')) {
|
||||
gps_sync_value = command + 21;
|
||||
while (*gps_sync_value == ' ' || *gps_sync_value == '\t') ++gps_sync_value;
|
||||
}
|
||||
if (gps_sync_value) {
|
||||
uint16_t hours = 0;
|
||||
if (!mesh::gps::parseSyncIntervalHours(gps_sync_value, hours)) {
|
||||
snprintf(reply, reply_size, "Error: GPS sync interval must be 1..336 hours");
|
||||
} else if (sensors.getLocationProvider() == nullptr) {
|
||||
snprintf(reply, reply_size, "Error: GPS unavailable");
|
||||
} else if (!savePreference(_prefs.gps_sync_interval_hours, hours)) {
|
||||
snprintf(reply, reply_size, "Error: GPS sync interval could not be saved");
|
||||
} else {
|
||||
sensors.applyGpsTimeSyncInterval(hours);
|
||||
snprintf(reply, reply_size, "OK - GPS sync interval %u hours (saved)", (unsigned)hours);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (strcmp(command, "get gps") == 0) {
|
||||
const char* gps = sensors.getSettingByKey("gps");
|
||||
snprintf(reply, reply_size, gps ? "> %s" : "Error: GPS unavailable",
|
||||
@@ -5134,6 +5169,8 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
} else {
|
||||
text[tlen] = 0; // ensure null
|
||||
|
||||
BaseSerialInterface* reply_route = _serial->captureReplyRoute();
|
||||
beginUsbLoggingReplyBarrier(reply_route);
|
||||
reply_buf[0] = 0;
|
||||
bool handled = false;
|
||||
#if defined(MESHCORE_EXTRAFS_HIL)
|
||||
@@ -5149,7 +5186,9 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
out_frame[0] = RESP_CODE_CLI_REPLY;
|
||||
int rlen = strlen(reply_buf);
|
||||
memcpy(&out_frame[1], reply_buf, rlen);
|
||||
_serial->writeFrame(out_frame, 1 + rlen);
|
||||
const bool reply_queued = _serial->writeFrameToRoute(
|
||||
reply_route, out_frame, 1 + rlen) == static_cast<size_t>(1 + rlen);
|
||||
endUsbLoggingReplyBarrier(reply_queued);
|
||||
}
|
||||
} else if (cmd_frame[0] == CMD_SEND_TXT_MSG && len >= 14) {
|
||||
int i = 1;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5141,6 +5141,21 @@ void MyMesh::cancelPendingSerialOutput() {
|
||||
|
||||
void MyMesh::servicePendingSerialOutput() {
|
||||
Stream& console = mesh::usbConsolePort();
|
||||
const auto write_pending = [&]() {
|
||||
const int available = console.availableForWrite();
|
||||
if (available <= 0 || serial_log_pending_size == 0) return;
|
||||
// A low-memory HWCDC setup can have a 256/512-byte ring. Never require a
|
||||
// whole 640-byte stored line to fit that ring: retain and retry its suffix.
|
||||
const size_t attempt = serial_log_pending_size < static_cast<size_t>(available)
|
||||
? serial_log_pending_size : static_cast<size_t>(available);
|
||||
size_t written = console.write(
|
||||
reinterpret_cast<const uint8_t*>(serial_log_pending), attempt);
|
||||
if (written > attempt) written = attempt;
|
||||
serial_log_pending_size -= written;
|
||||
if (written > 0 && serial_log_pending_size > 0) {
|
||||
memmove(serial_log_pending, serial_log_pending + written, serial_log_pending_size);
|
||||
}
|
||||
};
|
||||
|
||||
if (serial_acl_next >= 0) {
|
||||
if (serial_log_pending_size == 0) {
|
||||
@@ -5168,63 +5183,58 @@ void MyMesh::servicePendingSerialOutput() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (console.availableForWrite() < static_cast<int>(serial_log_pending_size)) return;
|
||||
size_t written = console.write(
|
||||
reinterpret_cast<const uint8_t*>(serial_log_pending), serial_log_pending_size);
|
||||
if (written > serial_log_pending_size) written = serial_log_pending_size;
|
||||
serial_log_pending_size -= written;
|
||||
if (written > 0 && serial_log_pending_size > 0) {
|
||||
memmove(serial_log_pending, serial_log_pending + written, serial_log_pending_size);
|
||||
}
|
||||
write_pending();
|
||||
return;
|
||||
}
|
||||
|
||||
if (serial_recent_next >= 0) {
|
||||
char record[64];
|
||||
int length = 0;
|
||||
const SimpleMeshTables::RecentRepeaterInfo* next_info = nullptr;
|
||||
int next_index = -1;
|
||||
if (serial_recent_header) {
|
||||
length = snprintf(record, sizeof(record), "Recent repeaters (%d):\n",
|
||||
serial_recent_count);
|
||||
} else if (serial_recent_count == 0) {
|
||||
length = snprintf(record, sizeof(record), "-none-\r\n");
|
||||
} else if (serial_recent_next < serial_recent_count) {
|
||||
const auto* tables = static_cast<const SimpleMeshTables*>(getTables());
|
||||
const auto* info = tables ? tables->getNextRecentRepeaterBySortKey(
|
||||
serial_recent_has_cursor ? &serial_recent_cursor : nullptr,
|
||||
serial_recent_cursor_index, next_index) : nullptr;
|
||||
if (info == nullptr) {
|
||||
if (serial_log_pending_size == 0) {
|
||||
char record[64];
|
||||
int length = 0;
|
||||
int next_index = -1;
|
||||
if (serial_recent_header) {
|
||||
length = snprintf(record, sizeof(record), "Recent repeaters (%d):\n",
|
||||
serial_recent_count);
|
||||
} else if (serial_recent_count == 0) {
|
||||
length = snprintf(record, sizeof(record), "-none-\r\n");
|
||||
} else if (serial_recent_next < serial_recent_count) {
|
||||
const auto* tables = static_cast<const SimpleMeshTables*>(getTables());
|
||||
const auto* info = tables ? tables->getNextRecentRepeaterBySortKey(
|
||||
serial_recent_has_cursor ? &serial_recent_cursor : nullptr,
|
||||
serial_recent_cursor_index, next_index) : nullptr;
|
||||
if (info == nullptr) {
|
||||
serial_recent_next = -1;
|
||||
return;
|
||||
}
|
||||
char prefix[MAX_ROUTE_HASH_BYTES * 2 + 1];
|
||||
char snr[12];
|
||||
mesh::Utils::toHex(prefix, info->prefix, info->prefix_len);
|
||||
formatLocalSnrX4(snr, sizeof(snr), info->snr_x4);
|
||||
length = snprintf(record, sizeof(record), "%s,%s%s\n", prefix,
|
||||
snr[0] == '-' ? "" : " ", snr);
|
||||
// Snapshot the row before a short write. Its next pass must retry only
|
||||
// the retained suffix, even if the live recent-repeater table changes.
|
||||
serial_recent_cursor = *info;
|
||||
serial_recent_cursor_index = next_index;
|
||||
serial_recent_has_cursor = true;
|
||||
} else {
|
||||
serial_recent_next = -1;
|
||||
return;
|
||||
}
|
||||
next_info = info;
|
||||
char prefix[MAX_ROUTE_HASH_BYTES * 2 + 1];
|
||||
char snr[12];
|
||||
mesh::Utils::toHex(prefix, info->prefix, info->prefix_len);
|
||||
formatLocalSnrX4(snr, sizeof(snr), info->snr_x4);
|
||||
length = snprintf(record, sizeof(record), "%s,%s%s\n", prefix,
|
||||
snr[0] == '-' ? "" : " ", snr);
|
||||
} else {
|
||||
serial_recent_next = -1;
|
||||
return;
|
||||
// One bounded row per pass. HWCDC can return a short write even after a
|
||||
// capacity preflight because another diagnostic task shares its TX ring.
|
||||
if (length <= 0 || static_cast<size_t>(length) >= sizeof(record)) {
|
||||
serial_recent_next = -1;
|
||||
return;
|
||||
}
|
||||
memcpy(serial_log_pending, record, length);
|
||||
serial_log_pending_size = static_cast<size_t>(length);
|
||||
}
|
||||
// One complete row per pass, admitted atomically only when it fits.
|
||||
if (length <= 0 || static_cast<size_t>(length) >= sizeof(record)) {
|
||||
serial_recent_next = -1;
|
||||
return;
|
||||
}
|
||||
if (console.availableForWrite() < length
|
||||
|| console.write(reinterpret_cast<const uint8_t*>(record), length)
|
||||
!= static_cast<size_t>(length)) return;
|
||||
write_pending();
|
||||
if (serial_log_pending_size != 0) return;
|
||||
if (serial_recent_header) {
|
||||
serial_recent_header = false;
|
||||
} else {
|
||||
if (next_info != nullptr) {
|
||||
serial_recent_cursor = *next_info;
|
||||
serial_recent_cursor_index = next_index;
|
||||
serial_recent_has_cursor = true;
|
||||
}
|
||||
if (serial_recent_count == 0 || ++serial_recent_next >= serial_recent_count) {
|
||||
serial_recent_next = -1;
|
||||
}
|
||||
@@ -5234,11 +5244,13 @@ void MyMesh::servicePendingSerialOutput() {
|
||||
if (!serial_log_active) {
|
||||
// CommonCLI's synchronous EOF is suppressed until the queued dump ends.
|
||||
static const char eof[] = " -> EOF\r\n";
|
||||
if (serial_log_eof_pending
|
||||
&& console.availableForWrite() >= static_cast<int>(sizeof(eof) - 1)
|
||||
&& console.write(reinterpret_cast<const uint8_t*>(eof), sizeof(eof) - 1)
|
||||
== sizeof(eof) - 1) {
|
||||
serial_log_eof_pending = false;
|
||||
if (serial_log_eof_pending) {
|
||||
if (serial_log_pending_size == 0) {
|
||||
memcpy(serial_log_pending, eof, sizeof(eof) - 1);
|
||||
serial_log_pending_size = sizeof(eof) - 1;
|
||||
}
|
||||
write_pending();
|
||||
if (serial_log_pending_size == 0) serial_log_eof_pending = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -5290,16 +5302,7 @@ void MyMesh::servicePendingSerialOutput() {
|
||||
serial_log_pending[serial_log_pending_size++] = '\n';
|
||||
}
|
||||
}
|
||||
if (serial_log_pending_size > 0
|
||||
&& console.availableForWrite() >= static_cast<int>(serial_log_pending_size)) {
|
||||
size_t written = console.write(
|
||||
reinterpret_cast<const uint8_t*>(serial_log_pending), serial_log_pending_size);
|
||||
if (written > serial_log_pending_size) written = serial_log_pending_size;
|
||||
serial_log_pending_size -= written;
|
||||
if (written > 0 && serial_log_pending_size > 0) {
|
||||
memmove(serial_log_pending, serial_log_pending + written, serial_log_pending_size);
|
||||
}
|
||||
}
|
||||
write_pending();
|
||||
if (serial_log_remaining == 0 && serial_log_pending_size == 0) {
|
||||
serial_log_dump.close();
|
||||
serial_log_active = false;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1712,6 +1712,20 @@ void MyMesh::cancelPendingSerialOutput() {
|
||||
|
||||
void MyMesh::servicePendingSerialOutput() {
|
||||
Stream& console = mesh::usbConsolePort();
|
||||
const auto write_pending = [&]() {
|
||||
const int available = console.availableForWrite();
|
||||
if (available <= 0 || serial_log_pending_size == 0) return;
|
||||
// Retain a larger stored line across low-memory 256/512-byte HWCDC rings.
|
||||
const size_t attempt = serial_log_pending_size < static_cast<size_t>(available)
|
||||
? serial_log_pending_size : static_cast<size_t>(available);
|
||||
size_t written = console.write(
|
||||
reinterpret_cast<const uint8_t*>(serial_log_pending), attempt);
|
||||
if (written > attempt) written = attempt;
|
||||
serial_log_pending_size -= written;
|
||||
if (written > 0 && serial_log_pending_size > 0) {
|
||||
memmove(serial_log_pending, serial_log_pending + written, serial_log_pending_size);
|
||||
}
|
||||
};
|
||||
if (serial_acl_next >= 0) {
|
||||
if (serial_log_pending_size == 0) {
|
||||
if (serial_acl_header) {
|
||||
@@ -1738,24 +1752,19 @@ void MyMesh::servicePendingSerialOutput() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (console.availableForWrite() < static_cast<int>(serial_log_pending_size)) return;
|
||||
size_t written = console.write(
|
||||
reinterpret_cast<const uint8_t*>(serial_log_pending), serial_log_pending_size);
|
||||
if (written > serial_log_pending_size) written = serial_log_pending_size;
|
||||
serial_log_pending_size -= written;
|
||||
if (written > 0 && serial_log_pending_size > 0) {
|
||||
memmove(serial_log_pending, serial_log_pending + written, serial_log_pending_size);
|
||||
}
|
||||
write_pending();
|
||||
return;
|
||||
}
|
||||
if (!serial_log_active) {
|
||||
// CommonCLI's synchronous EOF is suppressed until the queued dump ends.
|
||||
static const char eof[] = " -> EOF\r\n";
|
||||
if (serial_log_eof_pending
|
||||
&& console.availableForWrite() >= static_cast<int>(sizeof(eof) - 1)
|
||||
&& console.write(reinterpret_cast<const uint8_t*>(eof), sizeof(eof) - 1)
|
||||
== sizeof(eof) - 1) {
|
||||
serial_log_eof_pending = false;
|
||||
if (serial_log_eof_pending) {
|
||||
if (serial_log_pending_size == 0) {
|
||||
memcpy(serial_log_pending, eof, sizeof(eof) - 1);
|
||||
serial_log_pending_size = sizeof(eof) - 1;
|
||||
}
|
||||
write_pending();
|
||||
if (serial_log_pending_size == 0) serial_log_eof_pending = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1807,16 +1816,7 @@ void MyMesh::servicePendingSerialOutput() {
|
||||
serial_log_pending[serial_log_pending_size++] = '\n';
|
||||
}
|
||||
}
|
||||
if (serial_log_pending_size > 0
|
||||
&& console.availableForWrite() >= static_cast<int>(serial_log_pending_size)) {
|
||||
size_t written = console.write(
|
||||
reinterpret_cast<const uint8_t*>(serial_log_pending), serial_log_pending_size);
|
||||
if (written > serial_log_pending_size) written = serial_log_pending_size;
|
||||
serial_log_pending_size -= written;
|
||||
if (written > 0 && serial_log_pending_size > 0) {
|
||||
memmove(serial_log_pending, serial_log_pending + written, serial_log_pending_size);
|
||||
}
|
||||
}
|
||||
write_pending();
|
||||
if (serial_log_remaining == 0 && serial_log_pending_size == 0) {
|
||||
serial_log_dump.close();
|
||||
serial_log_active = false;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "PrefsSaveReplyGuard.h"
|
||||
#include <helpers/ui/DisplayPowerSettings.h>
|
||||
#include "CLICommandUtils.h"
|
||||
#include "GpsPowerPolicy.h"
|
||||
#include "FloodAdvertCLI.h"
|
||||
#include "StorageLayout.h"
|
||||
#include "radiolib/RadioPowerLimits.h"
|
||||
@@ -768,6 +769,7 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) {
|
||||
_prefs->bridge_uart = 0;
|
||||
#endif
|
||||
_prefs->bridge_format = mesh::bridge::ESPNOW_FORMAT_WRAPPED;
|
||||
_prefs->gps_sync_interval_hours = 0;
|
||||
|
||||
#ifdef WITH_MQTT_BRIDGE
|
||||
bool node_prefs_needs_migration = false;
|
||||
@@ -1321,6 +1323,12 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
|
||||
if (file.available() >= (int)sizeof(_prefs->espnow_bridge_enabled)) {
|
||||
file.read((uint8_t *)&_prefs->espnow_bridge_enabled,
|
||||
sizeof(_prefs->espnow_bridge_enabled));
|
||||
if (file.available() >= (int)sizeof(_prefs->gps_sync_interval_hours)) {
|
||||
uint16_t hours = 0;
|
||||
if (file.read((uint8_t *)&hours, sizeof(hours)) == sizeof(hours)) {
|
||||
_prefs->gps_sync_interval_hours = hours;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1427,6 +1435,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
|
||||
_prefs->reboot_interval = constrain(_prefs->reboot_interval, 0, 255);
|
||||
|
||||
_prefs->gps_enabled = constrain(_prefs->gps_enabled, 0, 1);
|
||||
_prefs->gps_sync_interval_hours = constrain(_prefs->gps_sync_interval_hours,
|
||||
0, mesh::gps::MAX_SYNC_INTERVAL_HOURS);
|
||||
_prefs->advert_loc_policy = constrain(_prefs->advert_loc_policy, 0, 2);
|
||||
|
||||
_prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean
|
||||
@@ -1645,6 +1655,7 @@ static bool writeCommonPrefsImage(Writer& writer, NodePrefs* prefs) {
|
||||
WRITE_COMMON_PREFS(&prefs->bridge_format); // 863
|
||||
WRITE_COMMON_PREFS(&prefs->primary_radio_preamble); // appended primary tuple field
|
||||
WRITE_COMMON_PREFS(&prefs->espnow_bridge_enabled); // appended Full ESP-NOW intent
|
||||
WRITE_COMMON_PREFS(&prefs->gps_sync_interval_hours); // appended GPS sync cadence
|
||||
|
||||
#undef WRITE_COMMON_PREFS_BYTES
|
||||
#undef WRITE_COMMON_PREFS
|
||||
@@ -1803,6 +1814,7 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) {
|
||||
file.write((uint8_t *)&_prefs->bridge_format, sizeof(_prefs->bridge_format)); // 863
|
||||
file.write((uint8_t *)&_prefs->primary_radio_preamble, sizeof(_prefs->primary_radio_preamble)); // appended
|
||||
file.write((uint8_t *)&_prefs->espnow_bridge_enabled, sizeof(_prefs->espnow_bridge_enabled)); // appended
|
||||
file.write((uint8_t *)&_prefs->gps_sync_interval_hours, sizeof(_prefs->gps_sync_interval_hours)); // appended GPS sync cadence
|
||||
|
||||
_common_save_succeeded = file.commit();
|
||||
if (!_common_save_succeeded) {
|
||||
@@ -3343,6 +3355,28 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
|
||||
return;
|
||||
}
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
if (strncmp(config, "gps.sync.interval", 17) == 0
|
||||
&& (config[17] == 0 || config[17] == ' ' || config[17] == '\t')) {
|
||||
const char* value = config + 17;
|
||||
while (*value == ' ' || *value == '\t') ++value;
|
||||
uint16_t hours = 0;
|
||||
if (!mesh::gps::parseSyncIntervalHours(value, hours)) {
|
||||
strcpy(reply, "Error: GPS sync interval must be 1..336 hours");
|
||||
} else if (_sensors->getLocationProvider() == nullptr) {
|
||||
strcpy(reply, "Error: GPS unavailable");
|
||||
} else {
|
||||
const uint16_t previous = _prefs->gps_sync_interval_hours;
|
||||
_prefs->gps_sync_interval_hours = hours;
|
||||
if (!trySavePrefs()) {
|
||||
_prefs->gps_sync_interval_hours = previous;
|
||||
strcpy(reply, "Error: GPS sync interval could not be saved");
|
||||
} else {
|
||||
_sensors->applyGpsTimeSyncInterval(hours);
|
||||
snprintf(reply, 160, "OK - GPS sync interval %u hours (saved)", (unsigned)hours);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (strncmp(config, "gps ", 4) == 0) {
|
||||
if (strcmp(config + 4, "on") != 0 && strcmp(config + 4, "off") != 0) {
|
||||
strcpy(reply, "Error: use set gps on|off");
|
||||
@@ -4677,6 +4711,17 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
|
||||
return;
|
||||
}
|
||||
#if ENV_INCLUDE_GPS == 1
|
||||
if (strcmp(config, "gps.sync.interval") == 0) {
|
||||
auto* provider = _sensors->getLocationProvider();
|
||||
if (provider == nullptr) {
|
||||
strcpy(reply, "Error: GPS unavailable");
|
||||
} else {
|
||||
const uint16_t hours = provider->getTimeSyncIntervalHours();
|
||||
if (hours == 0) strcpy(reply, "> default (board GPS sync policy)");
|
||||
else snprintf(reply, 160, "> %u hours", (unsigned)hours);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (strcmp(config, "gps") == 0) {
|
||||
char gps_command[] = "gps";
|
||||
handleCommand(sender_timestamp, gps_command, reply);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,22 @@ namespace gps {
|
||||
|
||||
static const uint32_t DEFAULT_UPDATE_INTERVAL_SEC = 1;
|
||||
static const uint32_t MAX_UPDATE_INTERVAL_SEC = 24UL * 60UL * 60UL;
|
||||
static const uint16_t MAX_SYNC_INTERVAL_HOURS = 14U * 24U;
|
||||
|
||||
// Saturate while parsing, not after an overflowing atoi/strtoul. Still inspect
|
||||
// every character so an oversized value with trailing junk is never accepted.
|
||||
inline bool parseSyncIntervalHours(const char* value, uint16_t& configured_hours) {
|
||||
if (value == nullptr || *value == 0) return false;
|
||||
uint16_t parsed = 0;
|
||||
for (const char* p = value; *p; ++p) {
|
||||
if (*p < '0' || *p > '9') return false;
|
||||
const uint16_t next = parsed * 10U + static_cast<uint16_t>(*p - '0');
|
||||
parsed = next > MAX_SYNC_INTERVAL_HOURS ? MAX_SYNC_INTERVAL_HOURS : next;
|
||||
}
|
||||
if (parsed == 0) return false;
|
||||
configured_hours = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
inline bool parseUpdateInterval(const char* value, uint32_t& configured_sec) {
|
||||
if (value == nullptr || *value == 0) return false;
|
||||
|
||||
@@ -73,6 +73,14 @@ protected:
|
||||
#endif
|
||||
|
||||
public:
|
||||
void applyGpsTimeSyncInterval(uint16_t hours) {
|
||||
#if ENV_INCLUDE_GPS
|
||||
LocationProvider* location = getLocationProvider();
|
||||
if (location != nullptr) location->setTimeSyncIntervalHours(hours);
|
||||
#else
|
||||
(void)hours;
|
||||
#endif
|
||||
}
|
||||
struct VoltageSensorReading {
|
||||
uint8_t channel;
|
||||
float voltage;
|
||||
@@ -108,6 +116,9 @@ public:
|
||||
// for the manager's established transport ownership.
|
||||
if (!gps_transport_available || !telemetryGpsDetected()) return false;
|
||||
LocationProvider* location = getLocationProvider();
|
||||
if (location != nullptr && location->getTimeSyncIntervalHours() != 0) {
|
||||
min_interval_secs = static_cast<uint64_t>(location->getTimeSyncIntervalHours()) * 3600UL;
|
||||
}
|
||||
return location != nullptr && location->getGPSPowerSaving()
|
||||
&& location->requestTimeSync(min_interval_secs);
|
||||
#else
|
||||
|
||||
+134
-39
@@ -16,6 +16,8 @@
|
||||
#endif
|
||||
#if MESH_ESP32_TINYUSB_NONBLOCKING
|
||||
#include "esp32-hal-tinyusb.h"
|
||||
// Build-local Arduino hook queries its actual CDC descriptor's IN endpoint.
|
||||
extern "C" bool meshEsp32TinyUsbTxPending();
|
||||
#endif
|
||||
#if MESH_ESP32_HWCDC_SESSION_GUARD
|
||||
#include "UsbAsciiBinarySwitch.h"
|
||||
@@ -25,6 +27,7 @@
|
||||
(defined(ENABLE_USB_INTERFACE) || defined(OTA_FOLDER_SERIAL))
|
||||
#define MESH_NRF52_PRIMARY_USB_NONBLOCKING 1
|
||||
#include <Adafruit_TinyUSB.h>
|
||||
#include <nrf.h>
|
||||
// Build-local CDC query installed by scripts/nrf52_usb_power_fix.py. It does
|
||||
// not clear/cancel an endpoint or change its USB bulk data-toggle state.
|
||||
extern "C" bool mesh_tud_cdc_n_tx_pending(uint8_t instance);
|
||||
@@ -90,27 +93,44 @@ static std::atomic<bool> esp32_tinyusb_terminal_discard_pending{false};
|
||||
static std::atomic<uint32_t> esp32_tinyusb_terminal_dropped_bytes{0};
|
||||
static uint32_t esp32_tinyusb_terminal_reported_dropped_bytes = 0;
|
||||
static uint32_t esp32_tinyusb_taken_reset_generation = 0;
|
||||
static bool esp32_tinyusb_event_handler_registered = false;
|
||||
static bool esp32_tinyusb_was_connected = false;
|
||||
static std::atomic<bool> esp32_tinyusb_was_connected{false};
|
||||
// 0: ordinary; 1: detached; 2: awaiting fresh mount; 3: preparing quarantine.
|
||||
static std::atomic<uint8_t> esp32_tinyusb_reenumeration_state{0};
|
||||
static std::atomic<bool> esp32_tinyusb_owner_dtr{false};
|
||||
static std::atomic<uint32_t> esp32_tinyusb_reattach_after{0};
|
||||
static constexpr uint32_t esp32_tinyusb_detach_millis = 20;
|
||||
|
||||
static bool canAccessEsp32TinyUsb(void*) {
|
||||
return !xPortInIsrContext() && tud_cdc_n_connected(0)
|
||||
return !xPortInIsrContext()
|
||||
&& esp32_tinyusb_reenumeration_state.load(std::memory_order_acquire) == 0
|
||||
&& tud_cdc_n_connected(0)
|
||||
&& esp32_tinyusb_clean_generation.load(std::memory_order_acquire)
|
||||
== esp32_tinyusb_reset_generation.load(std::memory_order_acquire);
|
||||
}
|
||||
|
||||
static void handleEsp32TinyUsbEvent(void*, esp_event_base_t, int32_t event_id,
|
||||
void* event_data) {
|
||||
// This is the Arduino event task, not TinyUSB's owner. Only publish an
|
||||
// epoch: queue cleanup and endpoint access remain in application service.
|
||||
bool closed = event_id == ARDUINO_USB_CDC_DISCONNECTED_EVENT;
|
||||
if (event_id == ARDUINO_USB_CDC_LINE_STATE_EVENT && event_data != nullptr) {
|
||||
const auto* event = static_cast<const arduino_usb_cdc_event_data_t*>(event_data);
|
||||
closed = !event->line_state.dtr;
|
||||
}
|
||||
if (closed) {
|
||||
esp32_tinyusb_reset_generation.fetch_add(1, std::memory_order_acq_rel);
|
||||
}
|
||||
static void requestEsp32TinyUsbReenumeration() {
|
||||
uint8_t expected = 0;
|
||||
if (!esp32_tinyusb_reenumeration_state.compare_exchange_strong(
|
||||
expected, 3, std::memory_order_acq_rel)) return;
|
||||
// The pinned ESP32 S2/S3 DCD disconnect is one bounded soft-disconnect
|
||||
// register write, not a send into TinyUSB's owner queue. Isolate an armed
|
||||
// old packet immediately; only the application loop may reattach later.
|
||||
(void)tud_disconnect();
|
||||
esp32_tinyusb_reattach_after.store(millis() + esp32_tinyusb_detach_millis,
|
||||
std::memory_order_release);
|
||||
// Publish only a fully prepared detach. The other core must never connect
|
||||
// before this callback has actually disconnected and started its deadline.
|
||||
esp32_tinyusb_reenumeration_state.store(1, std::memory_order_release);
|
||||
}
|
||||
|
||||
static void purgeEsp32TinyUsbRx() {
|
||||
// Owner-task session hooks run before new-host RX callbacks. A fixed snapshot
|
||||
// bounds this purge even if an application reader consumes bytes concurrently.
|
||||
int pending = Serial.available();
|
||||
while (pending-- > 0) (void)Serial.read();
|
||||
// At unmount the class endpoints have already been reset. read_flush also
|
||||
// rearms OUT, so it must not address an unopened/reset endpoint there.
|
||||
if (tud_mounted()) tud_cdc_n_read_flush(0);
|
||||
}
|
||||
|
||||
class Esp32TinyUsbFifoStream : public Stream {
|
||||
@@ -222,18 +242,54 @@ static Esp32TinyUsbBufferedStream<false> buffered_esp32_tinyusb_terminal_port;
|
||||
|
||||
static void clearEsp32TinyUsbTx(void*) {
|
||||
(void)tud_cdc_n_write_clear(0);
|
||||
if (tud_mounted() && meshEsp32TinyUsbTxPending()) {
|
||||
requestEsp32TinyUsbReenumeration();
|
||||
}
|
||||
}
|
||||
|
||||
static void closeEsp32TinyUsbSession(bool device_boundary) {
|
||||
esp32_tinyusb_reset_generation.fetch_add(1, std::memory_order_acq_rel);
|
||||
// Do this synchronously in TinyUSB's owner, never in its delayed Arduino
|
||||
// event handler or the main-loop cleanup (which could purge fresh RX).
|
||||
purgeEsp32TinyUsbRx();
|
||||
if (!device_boundary
|
||||
&& !nonblocking_esp32_tinyusb_port.tryRunExclusive(clearEsp32TinyUsbTx)) {
|
||||
// A writer already in flight can still arm old bytes after the close.
|
||||
// Physically quarantine it instead of racing an endpoint abort/FIFO purge.
|
||||
requestEsp32TinyUsbReenumeration();
|
||||
}
|
||||
}
|
||||
|
||||
static void purgeEsp32TinyUsbDetachedQueues(void*) {
|
||||
purgeEsp32TinyUsbRx();
|
||||
(void)tud_cdc_n_write_clear(0);
|
||||
}
|
||||
|
||||
static void serviceEsp32TinyUsbPorts() {
|
||||
if (xPortInIsrContext()) return;
|
||||
if (esp32_tinyusb_queue_busy.test_and_set(std::memory_order_acquire)) return;
|
||||
const uint8_t reenumeration =
|
||||
esp32_tinyusb_reenumeration_state.load(std::memory_order_acquire);
|
||||
if (reenumeration != 0) {
|
||||
if (reenumeration == 1
|
||||
&& (int32_t)(millis() - esp32_tinyusb_reattach_after.load(
|
||||
std::memory_order_acquire)) >= 0
|
||||
&& nonblocking_esp32_tinyusb_port.tryRunExclusive(
|
||||
purgeEsp32TinyUsbDetachedQueues)) {
|
||||
// Publish before connect: a fast owner mount callback can run immediately.
|
||||
esp32_tinyusb_reenumeration_state.store(2, std::memory_order_release);
|
||||
(void)tud_connect();
|
||||
}
|
||||
esp32_tinyusb_queue_busy.clear(std::memory_order_release);
|
||||
return; // Only the fresh owner-task mount hook can reopen this gate.
|
||||
}
|
||||
// Polling also catches a physical disconnect without a CDC line-state
|
||||
// event. Events capture quick close/reopen pairs between service calls.
|
||||
const bool connected = tud_cdc_n_connected(0);
|
||||
if (esp32_tinyusb_was_connected && !connected) {
|
||||
if (esp32_tinyusb_was_connected.exchange(connected,
|
||||
std::memory_order_acq_rel) && !connected) {
|
||||
esp32_tinyusb_reset_generation.fetch_add(1, std::memory_order_acq_rel);
|
||||
}
|
||||
esp32_tinyusb_was_connected = connected;
|
||||
const uint32_t generation =
|
||||
esp32_tinyusb_reset_generation.load(std::memory_order_acquire);
|
||||
if (generation != esp32_tinyusb_clean_generation.load(std::memory_order_acquire)) {
|
||||
@@ -244,8 +300,8 @@ static void serviceEsp32TinyUsbPorts() {
|
||||
esp32_tinyusb_text_queue.discardPending();
|
||||
esp32_tinyusb_terminal_reported_dropped_bytes =
|
||||
esp32_tinyusb_terminal_dropped_bytes.load(std::memory_order_relaxed);
|
||||
// Do not purge Serial's RX queue here: the new host may already have sent
|
||||
// its first query. Protocol owners reset their partial parser separately.
|
||||
// The synchronous owner hook already purged old RX. Keep the new host's
|
||||
// first query, while protocol owners reset their partial parser separately.
|
||||
esp32_tinyusb_clean_generation.store(generation, std::memory_order_release);
|
||||
}
|
||||
if (esp32_tinyusb_terminal_discard_pending.exchange(false,
|
||||
@@ -442,6 +498,9 @@ static constexpr uint32_t primary_usb_session_settle_millis = 8;
|
||||
static std::atomic<uint32_t> primary_usb_reset_settle_until{0};
|
||||
static std::atomic<bool> nrf52_usb_reenumeration_pending{false};
|
||||
static std::atomic<uint32_t> nrf52_usb_reattach_after{0};
|
||||
// Only the application service loop owns this phase. USB callbacks merely
|
||||
// quarantine the pull-up and publish the atomic request below.
|
||||
static bool nrf52_usb_stack_detach_issued = false;
|
||||
static constexpr uint32_t nrf52_usb_detach_millis = 20;
|
||||
|
||||
static void requestNrf52UsbSessionReenumeration() {
|
||||
@@ -455,9 +514,14 @@ static void requestNrf52UsbSessionReenumeration() {
|
||||
std::memory_order_acq_rel)) return;
|
||||
primary_usb_line_state_dtr.store(false, std::memory_order_release);
|
||||
primary_usb_reset_generation.fetch_add(1, std::memory_order_acq_rel);
|
||||
nrf52_usb_reattach_after.store(millis() + nrf52_usb_detach_millis,
|
||||
std::memory_order_release);
|
||||
TinyUSBDevice.detach();
|
||||
// Match the Nordic driver's bounded physical-disconnect operation now so
|
||||
// an armed packet cannot reach a rapidly reopened host. Do NOT call
|
||||
// TinyUSBDevice.detach() from this CDC/SOF callback: dcd_disconnect also
|
||||
// queues UNPLUGGED with an infinite FreeRTOS send timeout. A full event
|
||||
// queue would then make its sole consumer wait forever on its own queue.
|
||||
NRF_USBD->USBPULLUP = 0;
|
||||
__ISB();
|
||||
__DSB();
|
||||
}
|
||||
|
||||
static void clearNrf52UsbTxForSession(uint8_t instance) {
|
||||
@@ -468,13 +532,26 @@ static void clearNrf52UsbTxForSession(uint8_t instance) {
|
||||
}
|
||||
|
||||
static void serviceNrf52UsbSessionReenumeration() {
|
||||
if (!nrf52_usb_reenumeration_pending.load(std::memory_order_acquire)
|
||||
|| (int32_t)(millis() - nrf52_usb_reattach_after.load(
|
||||
if (!nrf52_usb_reenumeration_pending.load(std::memory_order_acquire)) return;
|
||||
if (!nrf52_usb_stack_detach_issued) {
|
||||
// Called only from the application loop, never a TinyUSB owner callback.
|
||||
// The owner can therefore drain a full event queue while this supported
|
||||
// detach operation enqueues the reset, instead of blocking on itself.
|
||||
TinyUSBDevice.detach();
|
||||
nrf52_usb_stack_detach_issued = true;
|
||||
// Start the quiet interval at the actual stack detach, not at a possibly
|
||||
// much earlier callback request while the application loop was occupied.
|
||||
nrf52_usb_reattach_after.store(millis() + nrf52_usb_detach_millis,
|
||||
std::memory_order_release);
|
||||
return;
|
||||
}
|
||||
if ((int32_t)(millis() - nrf52_usb_reattach_after.load(
|
||||
std::memory_order_acquire)) < 0
|
||||
|| tud_mounted()) return;
|
||||
// Waiting for unmount as well as the quiet interval ensures TinyUSB's owner
|
||||
// has consumed the unplug event. Never reattach with its old class state.
|
||||
TinyUSBDevice.attach();
|
||||
nrf52_usb_stack_detach_issued = false;
|
||||
nrf52_usb_reenumeration_pending.store(false, std::memory_order_release);
|
||||
}
|
||||
|
||||
@@ -825,18 +902,12 @@ bool isUsbLoggingEnabled() {
|
||||
}
|
||||
|
||||
void setUsbLoggingEnabled(bool enabled) {
|
||||
#if MESH_ESP32_TINYUSB_NONBLOCKING
|
||||
const bool was_enabled = isUsbLoggingEnabled();
|
||||
#endif
|
||||
usb_logging_enabled.store(enabled, std::memory_order_relaxed);
|
||||
usb_logging_preference_known.store(true, std::memory_order_relaxed);
|
||||
setPlatformDebugOutputEnabled(enabled);
|
||||
#if MESH_ESP32_TINYUSB_NONBLOCKING
|
||||
// Text shares one chronological queue. When leaving logging mode, discard
|
||||
// its residual application bytes before later Binary/mOTA traffic can start.
|
||||
// An in-flight producer is gated immediately and cleaned by the next service.
|
||||
if (was_enabled && !enabled) discardUsbTerminalOutput();
|
||||
#endif
|
||||
// Turning diagnostics off is not a protocol switch. Retain functional
|
||||
// replies in the shared text queue; actual Binary/mOTA ownership changes
|
||||
// explicitly call discardUsbTerminalOutput() after their bounded barrier.
|
||||
}
|
||||
|
||||
bool saveUsbLoggingBootPreference(bool enabled) {
|
||||
@@ -875,12 +946,6 @@ void beginUsbLoggingPort() {
|
||||
// ESP32 Companion stream; setUsbLoggingEnabled() restores them only when the
|
||||
// saved setting explicitly enables logging.
|
||||
setPlatformDebugOutputEnabled(isUsbLoggingEnabled());
|
||||
#if MESH_ESP32_TINYUSB_NONBLOCKING
|
||||
if (!esp32_tinyusb_event_handler_registered) {
|
||||
Serial.onEvent(ARDUINO_USB_CDC_ANY_EVENT, handleEsp32TinyUsbEvent);
|
||||
esp32_tinyusb_event_handler_registered = true;
|
||||
}
|
||||
#endif
|
||||
#if MESH_ESP32_HWCDC_SESSION_GUARD
|
||||
// If every pre-begin resize failed, begin() may have recovered by allocating
|
||||
// HWCDC's built-in ring. Discover it without replacing a live ISR-owned ring.
|
||||
@@ -1332,6 +1397,36 @@ const char* usbLoggingPortDescription() {
|
||||
|
||||
} // namespace mesh
|
||||
|
||||
#if MESH_ESP32_TINYUSB_NONBLOCKING
|
||||
// These strong bridges are called synchronously by the build-local framework
|
||||
// copy, not by Arduino's delayed event queue. Do not include weak declarations
|
||||
// in this translation unit or these definitions would inherit that attribute.
|
||||
extern "C" void meshEsp32TinyUsbCdcLineState(bool dtr) {
|
||||
// Never use USBCDC::dtr here: a delayed framework unplug event can overwrite
|
||||
// that member after a new host opens. These samples come from the owner.
|
||||
const bool previous = mesh::esp32_tinyusb_owner_dtr.exchange(
|
||||
dtr, std::memory_order_acq_rel);
|
||||
if (previous != dtr) mesh::closeEsp32TinyUsbSession(dtr);
|
||||
}
|
||||
|
||||
extern "C" bool meshEsp32TinyUsbAcceptRx() {
|
||||
// A late old OUT completion while DTR is low is not fresh-host input.
|
||||
return mesh::esp32_tinyusb_reenumeration_state.load(std::memory_order_acquire) == 0
|
||||
&& tud_cdc_n_connected(0);
|
||||
}
|
||||
|
||||
extern "C" void meshEsp32TinyUsbDeviceSessionBoundary(bool mounted) {
|
||||
mesh::closeEsp32TinyUsbSession(true);
|
||||
mesh::esp32_tinyusb_owner_dtr.store(false, std::memory_order_release);
|
||||
mesh::esp32_tinyusb_was_connected.store(false, std::memory_order_release);
|
||||
if (mounted) {
|
||||
uint8_t expected = 2;
|
||||
(void)mesh::esp32_tinyusb_reenumeration_state.compare_exchange_strong(
|
||||
expected, 0, std::memory_order_acq_rel);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE)
|
||||
// A protocol-only, weak-declaration-free translation unit supplies strong
|
||||
// versions of TinyUSB's callbacks and forwards here. Preserve Adafruit's CDC0
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <ESP32_CH390.h>
|
||||
#include <helpers/UsbLogging.h>
|
||||
|
||||
/**
|
||||
* Bring up the repository's CH390 lwIP interface from the board build flags.
|
||||
@@ -16,7 +17,10 @@ static inline bool beginConfiguredCH390(const char* hostname = nullptr) {
|
||||
config.int_gpio = ETH_INT_PIN;
|
||||
if (hostname && hostname[0] != '\0') {
|
||||
if (!CH390.setHostname(hostname)) {
|
||||
Serial.printf("Network: invalid Ethernet hostname %s\n", hostname);
|
||||
if (mesh::isUsbLoggingEnabled()
|
||||
&& mesh::usbLoggingPort().availableForWrite() > 0) {
|
||||
mesh::usbLoggingPort().printf("Network: invalid Ethernet hostname %s\n", hostname);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!CH390.begin(config)) return false;
|
||||
|
||||
@@ -419,7 +419,6 @@ class RAK12500LocationProvider : public LocationProvider {
|
||||
unsigned long _next_time_check = 0;
|
||||
unsigned long _last_time_sync = 0;
|
||||
uint8_t _valid_time_samples = 0;
|
||||
static const unsigned long TIME_SYNC_INTERVAL = 1800000;
|
||||
int _pin_en = -1;
|
||||
public:
|
||||
void setRTCClock(mesh::RTCClock* clock) { _clock = clock; }
|
||||
@@ -480,7 +479,8 @@ public:
|
||||
}
|
||||
|
||||
if (!_time_sync_needed && _clock != NULL
|
||||
&& (unsigned long)(now - _last_time_sync) > TIME_SYNC_INTERVAL) {
|
||||
&& static_cast<uint32_t>(now - _last_time_sync)
|
||||
>= periodicTimeSyncIntervalMillis()) {
|
||||
_time_sync_needed = true;
|
||||
}
|
||||
if (_time_sync_needed && _clock != NULL && _valid_time_samples > 2) {
|
||||
@@ -1401,7 +1401,7 @@ bool EnvironmentSensorManager::gpsIsAwake(uint8_t ioPin,
|
||||
|
||||
void EnvironmentSensorManager::armGpsPowerSavingCycle() {
|
||||
if (!powersaving_enabled || !_location->getGPSPowerSaving()) return;
|
||||
_location->syncTime();
|
||||
_location->syncTimeForPowerSavingCycle();
|
||||
_location->setNextGPSOn(0);
|
||||
_location->setNextSleep();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "Mesh.h"
|
||||
#include <helpers/GpsPowerPolicy.h>
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <stdint.h>
|
||||
@@ -22,6 +23,7 @@ protected:
|
||||
unsigned long _next_gps_on = 0;
|
||||
unsigned long _gps_on_duration_secs = GPS_POWERSAVING_ON_DURATION_SECS;
|
||||
unsigned long _gps_off_duration_secs = GPS_POWERSAVING_OFF_DURATION_SECS;
|
||||
uint16_t _gps_sync_interval_hours = 0; // 0 retains the board's legacy policy
|
||||
unsigned long _last_valid_time_sync = 0;
|
||||
uint32_t _last_time_sync_request_ms = 0;
|
||||
uint32_t _last_time_sync_applied_ms = 0;
|
||||
@@ -41,6 +43,25 @@ protected:
|
||||
}
|
||||
|
||||
public:
|
||||
void setTimeSyncIntervalHours(uint16_t hours) {
|
||||
_gps_sync_interval_hours = hours > mesh::gps::MAX_SYNC_INTERVAL_HOURS
|
||||
? mesh::gps::MAX_SYNC_INTERVAL_HOURS : hours;
|
||||
// A sleeping receiver must adopt a shorter/longer setting now, not
|
||||
// wait for its old (possibly week-long) deadline. No forced GPS wake.
|
||||
if (_next_gps_on != 0) setNextWake();
|
||||
}
|
||||
uint16_t getTimeSyncIntervalHours() const { return _gps_sync_interval_hours; }
|
||||
uint32_t periodicTimeSyncIntervalMillis() const {
|
||||
return _gps_sync_interval_hours != 0
|
||||
? static_cast<uint32_t>(_gps_sync_interval_hours) * 3600000UL
|
||||
: 1800000UL; // existing always-on receiver policy: 30 minutes
|
||||
}
|
||||
// Position telemetry may power the receiver between scheduled clock
|
||||
// acquisitions. Do not turn that into an early clock-sync request.
|
||||
void syncTimeForPowerSavingCycle() {
|
||||
if (_gps_sync_interval_hours == 0) syncTime();
|
||||
else (void)requestTimeSync(static_cast<uint64_t>(_gps_sync_interval_hours) * 3600UL);
|
||||
}
|
||||
virtual void syncTime() { _time_sync_needed = true; }
|
||||
virtual bool waitingTimeSync() { return _time_sync_needed; }
|
||||
// Telemetry requests share an in-progress acquisition and rate-limit new
|
||||
@@ -95,7 +116,24 @@ public:
|
||||
virtual void enablePowerSaving(bool enabled) { setGPSPowerSaving(enabled); }
|
||||
virtual bool isPowerSavingEnabled() { return getGPSPowerSaving(); }
|
||||
virtual void setNextWake() {
|
||||
setNextGPSOn(millis() + _gps_off_duration_secs * 1000UL);
|
||||
const uint32_t now = static_cast<uint32_t>(millis());
|
||||
uint32_t wait_ms = _gps_off_duration_secs * 1000UL;
|
||||
if (_gps_sync_interval_hours != 0) {
|
||||
wait_ms = periodicTimeSyncIntervalMillis();
|
||||
// Anchor to clock acquisition, not an unrelated location query's
|
||||
// power-off. Repeated position queries must not postpone sync.
|
||||
uint32_t age = UINT32_MAX;
|
||||
if (_time_sync_applied_seen) age = now - _last_time_sync_applied_ms;
|
||||
if (_time_sync_request_seen) {
|
||||
const uint32_t request_age = now - _last_time_sync_request_ms;
|
||||
if (request_age < age) age = request_age;
|
||||
}
|
||||
if (age < wait_ms) wait_ms -= age;
|
||||
// A failed/timed-out acquisition gets a full interval backoff,
|
||||
// rather than immediately powering back on in a retry loop.
|
||||
}
|
||||
const uint32_t deadline = now + wait_ms;
|
||||
setNextGPSOn(deadline == 0 ? 1 : deadline); // 0 means no deadline
|
||||
}
|
||||
virtual unsigned long getNextWake() { return getNextGPSOn(); }
|
||||
virtual void setNextSleep() {
|
||||
|
||||
@@ -55,7 +55,6 @@ class MicroNMEALocationProvider : public LocationProvider {
|
||||
unsigned long next_check = 0;
|
||||
long time_valid = 0;
|
||||
unsigned long _last_time_sync = 0;
|
||||
static const unsigned long TIME_SYNC_INTERVAL = 1800000; // Re-sync every 30 minutes
|
||||
|
||||
#ifdef GPS_L76K
|
||||
void configureL76K() {
|
||||
@@ -269,7 +268,9 @@ public :
|
||||
if ((long)(millis() - next_check) > 0) {
|
||||
next_check = millis() + 1000;
|
||||
// Re-enable time sync periodically when GPS has valid fix
|
||||
if (!_time_sync_needed && _clock != NULL && (millis() - _last_time_sync) > TIME_SYNC_INTERVAL) {
|
||||
if (!_time_sync_needed && _clock != NULL
|
||||
&& static_cast<uint32_t>(millis() - _last_time_sync)
|
||||
>= periodicTimeSyncIntervalMillis()) {
|
||||
_time_sync_needed = true;
|
||||
}
|
||||
const long timestamp = getTimestamp();
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <helpers/UsbLogging.h>
|
||||
|
||||
#include "TouchTapDetector.h"
|
||||
|
||||
@@ -36,17 +39,23 @@ public:
|
||||
#endif
|
||||
|
||||
if (_present) {
|
||||
Serial.printf("Touch: CHSC6X found at 0x%02X\n", CHSC6X_I2C_ADDR);
|
||||
} else {
|
||||
logDiagnostic("Touch: CHSC6X found at 0x%02X\n", CHSC6X_I2C_ADDR);
|
||||
} else if (mesh::isUsbLoggingEnabled()) {
|
||||
// Report what is actually on the bus, so an unexpected controller or
|
||||
// address can be identified from a normal boot log.
|
||||
Serial.printf("Touch: idle or absent at 0x%02X; I2C bus currently answers:",
|
||||
CHSC6X_I2C_ADDR);
|
||||
char record[640];
|
||||
size_t length = snprintf(record, sizeof(record),
|
||||
"Touch: idle or absent at 0x%02X; I2C bus currently answers:",
|
||||
CHSC6X_I2C_ADDR);
|
||||
for (uint8_t addr = 8; addr < 0x78; addr++) {
|
||||
_wire->beginTransmission(addr);
|
||||
if (_wire->endTransmission() == 0) Serial.printf(" 0x%02X", addr);
|
||||
if (_wire->endTransmission() == 0 && length + 6 < sizeof(record)) {
|
||||
length += snprintf(record + length, sizeof(record) - length,
|
||||
" 0x%02X", addr);
|
||||
}
|
||||
}
|
||||
Serial.println();
|
||||
record[length++] = '\n';
|
||||
writeDiagnostic(record, length);
|
||||
}
|
||||
return _present;
|
||||
}
|
||||
@@ -58,12 +67,31 @@ public:
|
||||
bool pressed = readPressed();
|
||||
if (pressed && !_present) {
|
||||
_present = true; // answered late; the boot probe caught it mid-idle
|
||||
Serial.println("Touch: CHSC6X responding");
|
||||
logDiagnostic("Touch: CHSC6X responding\n");
|
||||
}
|
||||
return _detector.update(now_ms, pressed);
|
||||
}
|
||||
|
||||
private:
|
||||
static void writeDiagnostic(const char* data, size_t length) {
|
||||
if (!mesh::isUsbLoggingEnabled()) return;
|
||||
Stream& output = mesh::usbLoggingPort();
|
||||
if (output.availableForWrite() < static_cast<int>(length)) return;
|
||||
output.write(reinterpret_cast<const uint8_t*>(data), length);
|
||||
}
|
||||
|
||||
static void logDiagnostic(const char* format, ...) {
|
||||
if (!mesh::isUsbLoggingEnabled()) return;
|
||||
char record[128];
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
const int length = vsnprintf(record, sizeof(record), format, args);
|
||||
va_end(args);
|
||||
if (length > 0 && static_cast<size_t>(length) < sizeof(record)) {
|
||||
writeDiagnostic(record, static_cast<size_t>(length));
|
||||
}
|
||||
}
|
||||
|
||||
TwoWire* _wire = NULL;
|
||||
bool _present = false;
|
||||
TouchTapDetector _detector;
|
||||
@@ -110,15 +138,17 @@ private:
|
||||
_logged = key;
|
||||
|
||||
if (!buf) {
|
||||
Serial.printf("Touch: short read (%u of %u bytes)\n", got, CHSC6X_READ_LEN);
|
||||
logDiagnostic("Touch: short read (%u of %u bytes)\n", got, CHSC6X_READ_LEN);
|
||||
return;
|
||||
}
|
||||
Serial.printf("Touch: raw %02X %02X %02X %02X %02X", buf[0], buf[1], buf[2], buf[3], buf[4]);
|
||||
#ifdef PIN_TOUCH_INT
|
||||
// Pulled up, so an unfitted R13 sits steady HIGH and a wired INT pulses LOW.
|
||||
Serial.printf(" INT=%d", digitalRead(PIN_TOUCH_INT));
|
||||
logDiagnostic("Touch: raw %02X %02X %02X %02X %02X INT=%d\n",
|
||||
buf[0], buf[1], buf[2], buf[3], buf[4], digitalRead(PIN_TOUCH_INT));
|
||||
#else
|
||||
logDiagnostic("Touch: raw %02X %02X %02X %02X %02X\n",
|
||||
buf[0], buf[1], buf[2], buf[3], buf[4]);
|
||||
#endif
|
||||
Serial.println();
|
||||
}
|
||||
#else
|
||||
void logRaw(uint8_t, const uint8_t*) {}
|
||||
|
||||
@@ -7,3 +7,7 @@ uint32_t tud_cdc_n_write_available(uint8_t instance);
|
||||
uint32_t tud_cdc_n_write(uint8_t instance, const void* data, uint32_t size);
|
||||
uint32_t tud_cdc_n_write_flush(uint8_t instance);
|
||||
bool tud_cdc_n_write_clear(uint8_t instance);
|
||||
void tud_cdc_n_read_flush(uint8_t instance);
|
||||
bool tud_mounted();
|
||||
bool tud_connect();
|
||||
bool tud_disconnect();
|
||||
+165
-7
@@ -2,11 +2,16 @@
|
||||
#include <cassert>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <atomic>
|
||||
#include <thread>
|
||||
#include "helpers/UsbLogging.h"
|
||||
#include "MeshCore.h"
|
||||
|
||||
MockSerial Serial;
|
||||
static bool connected = true;
|
||||
static bool mounted = true, attached = true, arm_packets = false;
|
||||
static std::string endpoint;
|
||||
static unsigned detach_calls = 0, attach_calls = 0;
|
||||
static bool auto_drain = false;
|
||||
static std::string fifo;
|
||||
static std::string host;
|
||||
@@ -14,6 +19,25 @@ static unsigned write_calls = 0;
|
||||
static unsigned flush_calls = 0;
|
||||
static unsigned clear_calls = 0;
|
||||
static void (*during_write)() = nullptr;
|
||||
static void (*during_disconnect)() = nullptr;
|
||||
|
||||
extern "C" void meshEsp32TinyUsbCdcLineState(bool dtr);
|
||||
extern "C" bool meshEsp32TinyUsbAcceptRx();
|
||||
extern "C" void meshEsp32TinyUsbDeviceSessionBoundary(bool mounted);
|
||||
extern "C" bool meshEsp32TinyUsbTxPending() { return !endpoint.empty(); }
|
||||
bool tud_mounted() { return mounted; }
|
||||
bool tud_disconnect() {
|
||||
++detach_calls;
|
||||
if (during_disconnect) during_disconnect();
|
||||
attached = false;
|
||||
connected = false;
|
||||
return true;
|
||||
}
|
||||
bool tud_connect() { ++attach_calls; attached = true; return true; }
|
||||
void tud_cdc_n_read_flush(uint8_t instance) {
|
||||
assert(instance == 0);
|
||||
assert(mounted && "RX flush must not rearm a reset/unopened OUT endpoint");
|
||||
}
|
||||
|
||||
bool tud_cdc_n_connected(uint8_t instance) {
|
||||
assert(!mock_isr);
|
||||
@@ -38,6 +62,12 @@ uint32_t tud_cdc_n_write_flush(uint8_t instance) {
|
||||
assert(!mock_isr);
|
||||
assert(instance == 0);
|
||||
++flush_calls;
|
||||
if (arm_packets) {
|
||||
if (!endpoint.empty()) return 0;
|
||||
const auto size = fifo.size();
|
||||
endpoint.swap(fifo);
|
||||
return size;
|
||||
}
|
||||
if (!auto_drain) return 0;
|
||||
const auto size = fifo.size();
|
||||
host += fifo;
|
||||
@@ -69,10 +99,10 @@ static void drain_all() {
|
||||
|
||||
static void fresh_session() {
|
||||
connected = false;
|
||||
arduino_usb_cdc_event_data_t event;
|
||||
event.line_state.dtr = false;
|
||||
Serial.callback(nullptr, nullptr, ARDUINO_USB_CDC_LINE_STATE_EVENT, &event);
|
||||
meshEsp32TinyUsbCdcLineState(false); // synchronous TinyUSB owner hook
|
||||
assert(!meshEsp32TinyUsbAcceptRx()); // late old RX cannot refill Arduino queue
|
||||
connected = true; // fast close/reopen, without an intervening service poll
|
||||
meshEsp32TinyUsbCdcLineState(true);
|
||||
mesh::serviceUsbLoggingPort();
|
||||
assert(mesh::takeUsbTerminalSessionReset());
|
||||
assert(!mesh::takeUsbTerminalSessionReset());
|
||||
@@ -136,8 +166,17 @@ static void check_stalled_host_and_visible_overflow() {
|
||||
static void check_disconnect_cleanup_keeps_new_host_input() {
|
||||
assert(put(mesh::usbConsolePort(), std::string(1000, 'O')) == 1000);
|
||||
const auto before = clear_calls;
|
||||
Serial.rx_count = 4;
|
||||
fresh_session();
|
||||
Serial.rx_count = 12; // old owner queued commands while output was paused
|
||||
connected = false;
|
||||
meshEsp32TinyUsbCdcLineState(false);
|
||||
assert(Serial.rx_count == 0);
|
||||
connected = true;
|
||||
meshEsp32TinyUsbCdcLineState(true);
|
||||
Serial.rx_count = 4; // new host sends its first query BEFORE main-loop cleanup
|
||||
mesh::serviceUsbLoggingPort();
|
||||
assert(mesh::takeUsbTerminalSessionReset());
|
||||
assert(mesh::tryCompleteUsbTerminalSessionReset());
|
||||
host.clear();
|
||||
assert(clear_calls > before);
|
||||
assert(!mesh::hasPendingUsbTerminalOutput());
|
||||
assert(mesh::usbConsolePort().available() == 4);
|
||||
@@ -149,6 +188,118 @@ static void check_disconnect_cleanup_keeps_new_host_input() {
|
||||
fresh_session();
|
||||
}
|
||||
|
||||
static void check_armed_endpoint_reenumerates_without_leaking() {
|
||||
fresh_session();
|
||||
arm_packets = true;
|
||||
const unsigned disconnected_before = detach_calls, connected_before = attach_calls;
|
||||
assert(put(mesh::usbConsolePort(), "old packet") == 10);
|
||||
assert(endpoint == "old packet" && fifo.empty());
|
||||
Serial.rx_count = 12;
|
||||
connected = false;
|
||||
meshEsp32TinyUsbCdcLineState(false);
|
||||
assert(detach_calls == disconnected_before + 1 && !attached);
|
||||
assert(Serial.rx_count == 0 && !meshEsp32TinyUsbAcceptRx());
|
||||
assert(put(mesh::usbCompanionPort(), "racing old writer") == 0);
|
||||
mesh::serviceUsbLoggingPort();
|
||||
assert(attach_calls == connected_before); // real detach interval, no wait loop
|
||||
delay(21);
|
||||
mesh::serviceUsbLoggingPort();
|
||||
assert(attach_calls == connected_before + 1 && attached);
|
||||
assert(!meshEsp32TinyUsbAcceptRx()); // retain quarantine until reset/mount
|
||||
endpoint.clear(); // actual bus reset cancels controller packet, not write_clear
|
||||
fifo.clear();
|
||||
meshEsp32TinyUsbDeviceSessionBoundary(true);
|
||||
connected = true;
|
||||
meshEsp32TinyUsbCdcLineState(true);
|
||||
Serial.rx_count = 4;
|
||||
assert(meshEsp32TinyUsbAcceptRx());
|
||||
mesh::serviceUsbLoggingPort();
|
||||
assert(mesh::takeUsbTerminalSessionReset());
|
||||
assert(mesh::tryCompleteUsbTerminalSessionReset());
|
||||
assert(mesh::usbConsolePort().available() == 4);
|
||||
arm_packets = false;
|
||||
drain_all();
|
||||
assert(host.find("old packet") == std::string::npos);
|
||||
fresh_session();
|
||||
}
|
||||
|
||||
static void check_unmount_purges_rx_without_rearming_endpoint() {
|
||||
fresh_session();
|
||||
Serial.rx_count = 12;
|
||||
mounted = false;
|
||||
connected = false;
|
||||
meshEsp32TinyUsbDeviceSessionBoundary(false);
|
||||
assert(Serial.rx_count == 0);
|
||||
assert(!meshEsp32TinyUsbAcceptRx());
|
||||
mesh::serviceUsbLoggingPort();
|
||||
mounted = true;
|
||||
meshEsp32TinyUsbDeviceSessionBoundary(true);
|
||||
connected = true;
|
||||
meshEsp32TinyUsbCdcLineState(true);
|
||||
Serial.rx_count = 4;
|
||||
mesh::serviceUsbLoggingPort();
|
||||
assert(mesh::takeUsbTerminalSessionReset());
|
||||
assert(mesh::tryCompleteUsbTerminalSessionReset());
|
||||
assert(mesh::usbConsolePort().available() == 4);
|
||||
fresh_session();
|
||||
}
|
||||
|
||||
static void check_logging_disable_retains_functional_reply() {
|
||||
fresh_session();
|
||||
const std::string reply(500, 'R');
|
||||
assert(put(mesh::usbConsolePort(), reply) == reply.size());
|
||||
mesh::setUsbLoggingEnabled(false);
|
||||
assert(mesh::hasPendingUsbTerminalOutput());
|
||||
assert(put(mesh::usbConsolePort(), "next reply") == 10);
|
||||
drain_all();
|
||||
assert(host == reply + "next reply");
|
||||
mesh::setUsbLoggingEnabled(true);
|
||||
fresh_session();
|
||||
}
|
||||
|
||||
static std::atomic<bool> writer_started{false}, writer_release{false};
|
||||
static std::thread* racing_writer = nullptr;
|
||||
static unsigned race_attach_baseline = 0;
|
||||
static void check_detach_publication_waits_for_physical_disconnect() {
|
||||
fresh_session();
|
||||
writer_started = false;
|
||||
writer_release = false;
|
||||
during_write = [] {
|
||||
writer_started.store(true, std::memory_order_release);
|
||||
while (!writer_release.load(std::memory_order_acquire)) std::this_thread::yield();
|
||||
};
|
||||
std::thread writer([] { assert(put(mesh::usbCompanionPort(), "racing old") == 10); });
|
||||
racing_writer = &writer;
|
||||
while (!writer_started.load(std::memory_order_acquire)) std::this_thread::yield();
|
||||
race_attach_baseline = attach_calls;
|
||||
during_disconnect = [] {
|
||||
// The writer finishes just after the owner's failed exclusive attempt.
|
||||
writer_release.store(true, std::memory_order_release);
|
||||
racing_writer->join();
|
||||
delay(25); // stale deadline would now allow the other core to reconnect
|
||||
mesh::serviceUsbLoggingPort();
|
||||
assert(attach_calls == race_attach_baseline);
|
||||
};
|
||||
connected = false;
|
||||
meshEsp32TinyUsbCdcLineState(false);
|
||||
during_disconnect = nullptr;
|
||||
during_write = nullptr;
|
||||
assert(!attached && !meshEsp32TinyUsbAcceptRx());
|
||||
mesh::serviceUsbLoggingPort();
|
||||
assert(attach_calls == race_attach_baseline);
|
||||
delay(21);
|
||||
mesh::serviceUsbLoggingPort();
|
||||
assert(attach_calls == race_attach_baseline + 1 && fifo.empty());
|
||||
meshEsp32TinyUsbDeviceSessionBoundary(true);
|
||||
connected = true;
|
||||
meshEsp32TinyUsbCdcLineState(true);
|
||||
mesh::serviceUsbLoggingPort();
|
||||
assert(mesh::takeUsbTerminalSessionReset());
|
||||
assert(mesh::tryCompleteUsbTerminalSessionReset());
|
||||
assert(host.find("racing old") == std::string::npos);
|
||||
fresh_session();
|
||||
}
|
||||
|
||||
static void check_debug_formatter_and_reentrancy() {
|
||||
auto_drain = true;
|
||||
const std::string long_text(1000, 'D');
|
||||
@@ -186,10 +337,11 @@ static void check_protocol_switch_cancels_text_and_overflow_marker() {
|
||||
assert(put(mesh::usbConsolePort(), std::string(5000, 'X')) == 0);
|
||||
assert(put(mesh::usbLoggingPort(), std::string(550, 'L')) == 550);
|
||||
mesh::setUsbLoggingEnabled(false);
|
||||
assert(!mesh::hasPendingUsbTerminalOutput());
|
||||
assert(mesh::hasPendingUsbTerminalOutput()); // no protocol switch yet
|
||||
// A protocol owner may discard application text without resetting USB. The
|
||||
// already accepted FIFO prefix stays ordered before the new binary frame.
|
||||
mesh::discardUsbTerminalOutput();
|
||||
assert(!mesh::hasPendingUsbTerminalOutput());
|
||||
host += fifo;
|
||||
fifo.clear();
|
||||
assert(put(mesh::usbCompanionPort(), "binary") == 6);
|
||||
@@ -210,8 +362,10 @@ int main() {
|
||||
#if MESH_ESP32_TINYUSB_NONBLOCKING
|
||||
mesh::beginUsbLoggingPort();
|
||||
mesh::beginUsbLoggingPort();
|
||||
meshEsp32TinyUsbDeviceSessionBoundary(true);
|
||||
meshEsp32TinyUsbCdcLineState(true);
|
||||
mesh::setUsbLoggingEnabled(true);
|
||||
assert(Serial.registrations == 1);
|
||||
assert(Serial.registrations == 0); // delayed Arduino events cannot purge new RX
|
||||
assert(!Serial.debug_enabled);
|
||||
mesh::serviceUsbLoggingPort();
|
||||
assert(&mesh::usbConsolePort() == &mesh::usbTerminalPort());
|
||||
@@ -219,6 +373,10 @@ int main() {
|
||||
check_ordered_text_and_functional_reserve();
|
||||
check_stalled_host_and_visible_overflow();
|
||||
check_disconnect_cleanup_keeps_new_host_input();
|
||||
check_armed_endpoint_reenumerates_without_leaking();
|
||||
check_unmount_purges_rx_without_rearming_endpoint();
|
||||
check_logging_disable_retains_functional_reply();
|
||||
check_detach_publication_waits_for_physical_disconnect();
|
||||
check_debug_formatter_and_reentrancy();
|
||||
check_cached_logging_gate_and_isr();
|
||||
check_protocol_switch_cancels_text_and_overflow_marker();
|
||||
|
||||
@@ -48,8 +48,10 @@ extern MockFicr mock_ficr;
|
||||
#define WB_IO2 2
|
||||
#define OUTPUT 1
|
||||
#define HIGH 1
|
||||
#define INPUT_PULLUP 2
|
||||
inline void pinMode(int, int) {}
|
||||
inline void digitalWrite(int, int) {}
|
||||
inline int digitalRead(int) { return HIGH; }
|
||||
inline void vTaskDelete(void*) {}
|
||||
inline void vTaskDelay(uint32_t value) { delay(value); }
|
||||
inline uint32_t pdMS_TO_TICKS(uint32_t value) { return value; }
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#define ETH_MISO_PIN 1
|
||||
#define ETH_MOSI_PIN 2
|
||||
#define ETH_SCLK_PIN 3
|
||||
#define ETH_CS_PIN 4
|
||||
#define ETH_INT_PIN 5
|
||||
|
||||
struct ch390_config_t {
|
||||
int spi_miso_gpio = 0;
|
||||
int spi_mosi_gpio = 0;
|
||||
int spi_sck_gpio = 0;
|
||||
int spi_cs_gpio = 0;
|
||||
int int_gpio = 0;
|
||||
};
|
||||
|
||||
#define CH390_DEFAULT_CONFIG() ch390_config_t{}
|
||||
|
||||
class MockCh390 {
|
||||
public:
|
||||
bool valid_hostname = false;
|
||||
unsigned begins = 0;
|
||||
bool setHostname(const char*) const { return valid_hostname; }
|
||||
bool begin(const ch390_config_t&) { ++begins; return true; }
|
||||
};
|
||||
|
||||
extern MockCh390 CH390;
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <array>
|
||||
|
||||
class TwoWire {
|
||||
public:
|
||||
bool present = true;
|
||||
bool all_addresses_present = false;
|
||||
unsigned probes = 0;
|
||||
unsigned fail_probe_count = 0;
|
||||
std::array<uint8_t, 5> frame{{0, 1, 2, 3, 4}};
|
||||
uint8_t reply_length = 5;
|
||||
uint8_t current_address = 0;
|
||||
unsigned cursor = 0;
|
||||
void beginTransmission(uint8_t address) { current_address = address; ++probes; }
|
||||
uint8_t endTransmission() const {
|
||||
if (probes <= fail_probe_count) return 2;
|
||||
return (all_addresses_present || (present && current_address == 0x2E)) ? 0 : 2;
|
||||
}
|
||||
uint8_t requestFrom(uint8_t, uint8_t) { cursor = 0; return reply_length; }
|
||||
int available() const { return static_cast<int>(reply_length) - static_cast<int>(cursor); }
|
||||
int read() { return cursor < reply_length ? frame[cursor++] : -1; }
|
||||
};
|
||||
|
||||
extern TwoWire Wire;
|
||||
+77
@@ -1,6 +1,8 @@
|
||||
#include <helpers/NonBlockingWriteStream.h>
|
||||
#include <helpers/nrf52/EthernetCLI.h>
|
||||
#include "NetworkLoggingMacro.h"
|
||||
#include <helpers/ui/CHSC6XTouch.h>
|
||||
#include <helpers/ethernet/ch390/CH390Config.h>
|
||||
|
||||
#include <iostream>
|
||||
|
||||
@@ -8,6 +10,8 @@ uint32_t mock_millis = 0;
|
||||
RawSerial Serial;
|
||||
MockFicr mock_ficr;
|
||||
MockEthernet Ethernet;
|
||||
TwoWire Wire;
|
||||
MockCh390 CH390;
|
||||
std::string ethernet_output;
|
||||
std::string ethernet_input;
|
||||
bool accept_client = false;
|
||||
@@ -138,5 +142,78 @@ int main() {
|
||||
assert(evaluated == 0 && usb.output.empty() && usb.tries == 0);
|
||||
#endif
|
||||
assert(mock_millis == 0);
|
||||
|
||||
// Touch probe, late-presence and optional raw frames use the same runtime
|
||||
// logging gate as every other diagnostic, including protected boot mode.
|
||||
reset();
|
||||
logging_enabled = false;
|
||||
Wire = TwoWire();
|
||||
CHSC6XTouch found;
|
||||
assert(found.begin());
|
||||
Wire.frame[0] = 0x1F;
|
||||
assert(!found.checkTap(500));
|
||||
assert(found.checkTap(600));
|
||||
assert(usb.output.empty() && usb.tries == 0);
|
||||
|
||||
Wire = TwoWire();
|
||||
Wire.present = false;
|
||||
CHSC6XTouch late;
|
||||
assert(!late.begin());
|
||||
assert(Wire.probes == 1); // No diagnostic I2C scan when logging is off.
|
||||
Wire.present = true;
|
||||
Wire.frame[0] = 0x1F;
|
||||
assert(!late.checkTap(500));
|
||||
assert(late.checkTap(600));
|
||||
Wire.reply_length = 2;
|
||||
late.checkTap(700);
|
||||
assert(usb.output.empty() && usb.tries == 0);
|
||||
|
||||
reset();
|
||||
Wire = TwoWire();
|
||||
CHSC6XTouch visible;
|
||||
assert(visible.begin());
|
||||
assert(usb.output == "Touch: CHSC6X found at 0x2E\n");
|
||||
visible.checkTap(500);
|
||||
#if defined(DISPLAY_TOUCH_DEBUG)
|
||||
assert(usb.output.find("Touch: raw 00 01 02 03 04") != std::string::npos);
|
||||
#endif
|
||||
usb.output.clear();
|
||||
usb.capacity = 0;
|
||||
CHSC6XTouch stalled;
|
||||
assert(stalled.begin());
|
||||
stalled.checkTap(500);
|
||||
assert(usb.output.empty());
|
||||
|
||||
reset();
|
||||
Wire = TwoWire();
|
||||
Wire.present = false;
|
||||
Wire.all_addresses_present = false;
|
||||
CHSC6XTouch absent;
|
||||
assert(!absent.begin());
|
||||
assert(usb.output.find("Touch: idle or absent at 0x2E; I2C bus currently answers:\n") == 0);
|
||||
assert(Wire.probes == 113);
|
||||
|
||||
reset();
|
||||
usb.capacity = 640;
|
||||
Wire = TwoWire();
|
||||
Wire.all_addresses_present = true;
|
||||
Wire.fail_probe_count = 1; // Idle boot probe, then every scan address replies.
|
||||
CHSC6XTouch full_scan;
|
||||
assert(!full_scan.begin());
|
||||
assert(usb.output.size() > 600 && usb.output.size() < 640 && usb.tries == 1);
|
||||
assert(usb.output.substr(usb.output.size() - 6) == " 0x77\n");
|
||||
|
||||
reset();
|
||||
CH390 = MockCh390();
|
||||
logging_enabled = false;
|
||||
assert(beginConfiguredCH390("invalid hostname"));
|
||||
assert(CH390.begins == 1 && usb.output.empty() && usb.tries == 0);
|
||||
logging_enabled = true;
|
||||
assert(beginConfiguredCH390("invalid hostname"));
|
||||
assert(usb.output == "Network: invalid Ethernet hostname invalid hostname\n");
|
||||
usb.output.clear();
|
||||
usb.capacity = 0;
|
||||
assert(beginConfiguredCH390("invalid hostname"));
|
||||
assert(usb.output.empty());
|
||||
std::cout << "PASS: gated USB diagnostics\n";
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Exercise real Companion preferences saves and startup recovery with I/O faults."""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -54,6 +55,7 @@ int main(int argc,char** argv){
|
||||
original.flood_retry_attempts=4;original.flood_retry_max_path=3;
|
||||
original.flood_retry_group_max_path=2;original.flood_retry_advert_enabled=0;
|
||||
original.one_key_dm_enabled=1;
|
||||
original.gps_sync_interval_hours=336;
|
||||
#ifdef TBEAM_1W
|
||||
strcpy(original.fan_mode,"on");original.fan_lo=33;original.fan_hi=42;
|
||||
#endif
|
||||
@@ -90,6 +92,7 @@ int main(int argc,char** argv){
|
||||
assert(loaded.flood_retry_attempts==4&&loaded.flood_retry_max_path==3);
|
||||
assert(loaded.flood_retry_group_max_path==2&&loaded.flood_retry_advert_enabled==0);
|
||||
assert(loaded.one_key_dm_enabled==1);
|
||||
assert(loaded.gps_sync_interval_hours==336);
|
||||
#ifdef TBEAM_1W
|
||||
assert(!strcmp(loaded.fan_mode,"on")&&loaded.fan_lo==33&&loaded.fan_hi==42);
|
||||
#endif
|
||||
@@ -194,11 +197,24 @@ int main(int argc,char** argv){
|
||||
} else if(scenario==7){
|
||||
// Pre-consent image: all earlier settings survive with default-off consent.
|
||||
DataStore legacy;legacy.fs.files["/new_prefs"]=disk;
|
||||
legacy.fs.files["/new_prefs"].resize(disk.size()-2);
|
||||
legacy.fs.files["/new_prefs"].resize(disk.size()-4);
|
||||
CompanionNodePrefs loaded;double lat=0,lon=0;
|
||||
assert(legacy.loadPrefs(loaded,lat,lon));
|
||||
assert(loaded.flood_retry_advert_enabled==0);
|
||||
assert(loaded.one_key_dm_enabled==0);
|
||||
} else if(scenario==9){
|
||||
// The previous complete image loads with the board policy even when the
|
||||
// receiving prefs object previously held an explicit configured interval.
|
||||
DataStore legacy;legacy.fs.files["/new_prefs"]=disk;
|
||||
legacy.fs.files["/new_prefs"].resize(disk.size()-2);
|
||||
CompanionNodePrefs loaded;loaded.gps_sync_interval_hours=24;
|
||||
double lat=0,lon=0;
|
||||
assert(legacy.loadPrefs(loaded,lat,lon));
|
||||
assert(loaded.gps_sync_interval_hours==0 && loaded.one_key_dm_enabled==1);
|
||||
// Half of the two-byte append is never accepted as a complete image.
|
||||
DataStore torn;torn.fs.files["/new_prefs"]=disk;
|
||||
torn.fs.files["/new_prefs"].resize(disk.size()-1);
|
||||
assert(!torn.loadPrefsInt("/new_prefs",loaded,lat,lon));
|
||||
} else if(scenario==8){
|
||||
for(uint8_t enabled : {0,1}){
|
||||
CompanionNodePrefs saved=original;saved.wifi_enabled=enabled;
|
||||
@@ -288,10 +304,12 @@ inline char* utoa(unsigned int value,char* output,int base){
|
||||
'ESP32_PLATFORM,TBEAM_1W', 'RP2040_PLATFORM,ENABLE_WIFI_INTERFACE'):
|
||||
with self.subTest(platform=platform):
|
||||
binary = work / 'test'
|
||||
sanitizer_flags = [] if os.name == 'nt' else [
|
||||
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
|
||||
'-fno-pie', '-no-pie']
|
||||
build = subprocess.run(['g++', '-std=c++17',
|
||||
*['-D'+flag+'=1' for flag in platform.split(',')],
|
||||
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
|
||||
'-fno-pie', '-no-pie', '-include', str(work / 'platform_shim.h'),
|
||||
*sanitizer_flags, '-include', str(work / 'platform_shim.h'),
|
||||
'-I', str(work),
|
||||
'-I', str(ROOT / 'test/fixtures/radio_profiles/mocks'),
|
||||
'-I', str(ROOT / 'test/mocks'), '-I', str(ROOT / 'src'),
|
||||
@@ -303,7 +321,7 @@ inline char* utoa(unsigned int value,char* output,int base){
|
||||
str(ROOT / 'src/helpers/TxtDataHelpers.cpp'),
|
||||
'-o', str(binary)], capture_output=True, text=True)
|
||||
self.assertEqual(build.returncode, 0, build.stdout + build.stderr)
|
||||
for scenario in range(9):
|
||||
for scenario in range(10):
|
||||
with self.subTest(scenario=scenario):
|
||||
run = subprocess.run([str(binary), str(scenario)], capture_output=True, text=True)
|
||||
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
|
||||
|
||||
@@ -107,6 +107,7 @@ struct MyMesh {
|
||||
bool applyAndSaveRxPowerSaving(const char*,char*);
|
||||
bool applyAndSavePowerSaving(const char*,char*);
|
||||
bool applyAndSaveRxBoostedGain(bool);
|
||||
bool setGpsEnabled(bool);
|
||||
bool savePrefs() {
|
||||
++saves;if(!storage_accepts)return false;
|
||||
memcpy(&durable,&_prefs,sizeof(Prefs));durable_lat=sensors.node_lat;durable_lon=sensors.node_lon;return true;
|
||||
@@ -357,6 +358,7 @@ class CompanionPrefsTransactionTests(unittest.TestCase):
|
||||
'bool MyMesh::applyAndSaveRxPowerSaving(',
|
||||
'bool MyMesh::applyAndSaveRxBoostedGain(',
|
||||
'bool MyMesh::applyAndSavePowerSaving(',
|
||||
'bool MyMesh::setGpsEnabled(',
|
||||
)),
|
||||
'@FRAMES@': ' else '.join(extract_braced(text, f'if (cmd_frame[0] == {cmd})') for cmd in frames),
|
||||
'@WEB@': ' else '.join(extract_braced(text,f'if ({expr})') for expr in web),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Exercise logging handoffs with real Companion USB parsing and routing."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
HARNESS = r'''
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include "helpers/ArduinoSerialInterface.h"
|
||||
#include "helpers/MultiSerialInterface.h"
|
||||
#include "helpers/UsbAsciiBinarySwitch.h"
|
||||
#include "helpers/CLICommandUtils.h"
|
||||
#include "helpers/CompanionHardwareCommandCompat.h"
|
||||
#define MESH_USB_LOGGING_AVAILABLE 1
|
||||
#define COMPANION_FEATURE_USB_MOTA_SOURCE 0
|
||||
#define COMPANION_FEATURE_NETWORK_TERMINAL 1
|
||||
static bool connected=true, dedicated=false, logging=false, network=false;
|
||||
static bool usb_logging_network_parked=false, usb_logging_terminal_mode=false;
|
||||
static bool usb_protocol_initialized=true, usb_terminal_discard_line=false;
|
||||
static bool usb_logging_reply_hold=false, usb_logging_reply_staged=false;
|
||||
static bool usb_logging_reply_pending=false, usb_logging_reply_state=false;
|
||||
static mesh::UsbBinaryStartupProbe usb_binary_startup_probe;
|
||||
static mesh::UsbAsciiSessionDefault usb_ascii_session_default;
|
||||
struct UsbStream : Stream {
|
||||
std::vector<uint8_t> rx, tx;
|
||||
size_t cursor=0;
|
||||
int capacity=4096, max_write=4096;
|
||||
int available() override { return static_cast<int>(rx.size()-cursor); }
|
||||
int read() override { return cursor<rx.size()?rx[cursor++]:-1; }
|
||||
int peek() override { return cursor<rx.size()?rx[cursor]:-1; }
|
||||
int availableForWrite() override { return capacity; }
|
||||
size_t write(const uint8_t* p,size_t n) override {
|
||||
if(n>static_cast<size_t>(capacity)) n=capacity;
|
||||
if(n>static_cast<size_t>(max_write)) n=max_write;
|
||||
tx.insert(tx.end(),p,p+n);
|
||||
return n;
|
||||
}
|
||||
void receive(const char* command) {
|
||||
size_t n=strlen(command)+1;
|
||||
rx={'<',static_cast<uint8_t>(n),0,mesh::companion::kRunCliCommand};
|
||||
rx.insert(rx.end(),command,command+n-1);
|
||||
cursor=0;
|
||||
}
|
||||
} stream;
|
||||
struct Remote : BaseSerialInterface {
|
||||
bool enabled=false;
|
||||
std::vector<uint8_t> rx, tx;
|
||||
void enable() override { enabled=true; }
|
||||
void disable() override { enabled=false; }
|
||||
bool isEnabled() const override { return enabled; }
|
||||
bool isConnected() const override { return true; }
|
||||
bool isReadBusy() const override { return false; }
|
||||
bool isWriteBusy() const override { return false; }
|
||||
size_t writeFrame(const uint8_t* p,size_t n) override {
|
||||
tx.insert(tx.end(),p,p+n); return n;
|
||||
}
|
||||
size_t checkRecvFrame(uint8_t* out) override {
|
||||
const size_t n=rx.size();
|
||||
if(n) memcpy(out,rx.data(),n);
|
||||
rx.clear(); return n;
|
||||
}
|
||||
} ble;
|
||||
ArduinoSerialInterface usb_serial_interface;
|
||||
MultiSerialInterface interface_manager;
|
||||
static bool clientConnected() { return connected; }
|
||||
static Stream& usbTerminalOutput() { return stream; }
|
||||
static bool isNetworkTerminalActive() { return network; }
|
||||
static void clearUsbTerminalLine() {}
|
||||
static unsigned banners=0, cancellations=0;
|
||||
namespace mesh {
|
||||
bool hasDedicatedUsbLoggingPort() { return dedicated; }
|
||||
bool isUsbLoggingEnabled() { return logging; }
|
||||
void setUsbLoggingEnabled(bool value) { logging=value; }
|
||||
void discardUsbTerminalOutput() {}
|
||||
Stream& usbCompanionPort() { return stream; }
|
||||
bool saveUsbLoggingBootPreference(bool) { return true; }
|
||||
bool usbLoggingInterfaceRestartRequired() { return false; }
|
||||
}
|
||||
namespace CompanionMqttSetupPortal {
|
||||
static bool saveEnabled(bool) { return true; }
|
||||
}
|
||||
static uint32_t futureMillis(uint32_t value) { return millis()+value; }
|
||||
struct MyMesh {
|
||||
struct { unsigned usb_logging_enabled=0; } _prefs;
|
||||
BaseSerialInterface* _serial=&interface_manager;
|
||||
bool terminal=false, _mqtt_enabled=false;
|
||||
unsigned _scheduled_reboot_at=0;
|
||||
uint8_t cmd_frame[MAX_FRAME_SIZE+1]{}, out_frame[MAX_FRAME_SIZE+4]{};
|
||||
char reply_buf[166]{};
|
||||
bool isTerminalMode() const { return terminal; }
|
||||
void cancelSerialResponseStream() { ++cancellations; }
|
||||
void cancelSerialOperationsForRoute(BaseSerialInterface*) {}
|
||||
void enterTerminalMode(bool show_banner) {
|
||||
terminal=true;
|
||||
if(show_banner) { ++banners; usbTerminalOutput().print("BANNER"); }
|
||||
}
|
||||
bool savePrefs() { return true; }
|
||||
void stopMQTT() {}
|
||||
void applyUsbLoggingState(bool);
|
||||
void beginUsbLoggingReplyBarrier(BaseSerialInterface*);
|
||||
void endUsbLoggingReplyBarrier(bool);
|
||||
void writeErrFrame(uint8_t) { assert(false); }
|
||||
bool handleCommand(const char* command,uint32_t,char* reply) {
|
||||
size_t reply_size=160;
|
||||
@SETTERS@
|
||||
strcpy(reply,"query"); return true;
|
||||
}
|
||||
void handleCmdFrame(size_t len) {
|
||||
static constexpr uint8_t RESP_CODE_CLI_REPLY=29, ERR_CODE_ILLEGAL_ARG=1;
|
||||
@CLI_FRAME@
|
||||
}
|
||||
} the_mesh;
|
||||
static void leaveUsbTerminalMode(bool) { the_mesh.terminal=false; }
|
||||
@FUNCTIONS@
|
||||
static void reset() {
|
||||
connected=true; dedicated=false; logging=false; network=false;
|
||||
usb_logging_network_parked=usb_logging_terminal_mode=false;
|
||||
usb_logging_reply_hold=usb_logging_reply_staged=false;
|
||||
usb_logging_reply_pending=usb_logging_reply_state=false;
|
||||
stream=UsbStream(); ble=Remote(); the_mesh=MyMesh();
|
||||
usb_serial_interface=ArduinoSerialInterface();
|
||||
interface_manager=MultiSerialInterface();
|
||||
usb_serial_interface.begin(stream);
|
||||
usb_serial_interface.setConnectedCheck(clientConnected);
|
||||
usb_serial_interface.enableFlowControl(true);
|
||||
assert(interface_manager.addInterface(InterfaceType::USB,&usb_serial_interface));
|
||||
assert(interface_manager.addInterface(InterfaceType::Bluetooth,&ble));
|
||||
interface_manager.enable(); banners=cancellations=0;
|
||||
}
|
||||
static void command(const char* text) {
|
||||
stream.receive(text);
|
||||
size_t len=interface_manager.checkRecvFrame(the_mesh.cmd_frame);
|
||||
assert(len>0 && interface_manager.captureReplyRoute()==&usb_serial_interface);
|
||||
the_mesh.handleCmdFrame(len);
|
||||
}
|
||||
static void checkReply() {
|
||||
assert(stream.tx.size()>=4 && stream.tx[0]=='>');
|
||||
const size_t size=stream.tx[1]+256*stream.tx[2];
|
||||
assert(stream.tx.size()>=size+3 && stream.tx[3]==29);
|
||||
assert(ble.tx.empty());
|
||||
const std::string reply(stream.tx.begin()+4,stream.tx.begin()+size+3);
|
||||
assert(reply.find("saved")!=std::string::npos);
|
||||
}
|
||||
int main() {
|
||||
// Real CLI setters, not a mock apply call: both variants retain the request.
|
||||
for(const char* setting : {"set usb.logging on","set logging.output both"}) {
|
||||
reset(); command(setting);
|
||||
assert(!logging && !the_mesh.terminal && usb_logging_reply_pending);
|
||||
checkReply();
|
||||
const size_t frame_bytes=stream.tx.size();
|
||||
serviceUsbLoggingReplyBarrier();
|
||||
assert(logging && the_mesh.terminal && !usb_logging_reply_pending);
|
||||
assert(stream.tx.size()==frame_bytes+6);
|
||||
assert(std::string(stream.tx.begin()+frame_bytes,stream.tx.end())=="BANNER");
|
||||
assert(ble.tx.empty());
|
||||
}
|
||||
// Stall and partial writes never block the mesh or allow a banner to splice
|
||||
// into a framed response; the complete reply precedes the logging handoff.
|
||||
reset(); stream.capacity=0; command("set usb.logging on");
|
||||
assert(stream.tx.empty() && usb_logging_reply_pending && !logging);
|
||||
for(unsigned i=0;i<10000;++i) serviceUsbLoggingReplyBarrier();
|
||||
assert(!logging && !the_mesh.terminal && stream.tx.empty());
|
||||
stream.capacity=7; stream.max_write=3;
|
||||
for(unsigned i=0;i<1000 && usb_logging_reply_pending;++i) {
|
||||
serviceUsbLoggingReplyBarrier();
|
||||
if(usb_logging_reply_pending) assert(!logging && !the_mesh.terminal);
|
||||
}
|
||||
assert(logging); checkReply();
|
||||
// An incomplete *next* input does not hold the already admitted reply.
|
||||
reset(); stream.capacity=0; command("set usb.logging on");
|
||||
stream.rx={'<',5,0,1}; stream.cursor=0;
|
||||
uint8_t input[176]; assert(usb_serial_interface.checkRecvFrame(input)==0);
|
||||
assert(usb_serial_interface.isReadBusy());
|
||||
stream.capacity=4096; serviceUsbLoggingReplyBarrier();
|
||||
assert(logging && the_mesh.terminal); checkReply();
|
||||
// A new read-only USB request while stalled must not erase the old intent.
|
||||
reset(); stream.capacity=0; command("set usb.logging on");
|
||||
command("get version"); assert(usb_logging_reply_pending && !logging);
|
||||
stream.capacity=4096; serviceUsbLoggingReplyBarrier(); assert(logging);
|
||||
// Disconnect/reset/ASCII takeover cancels this session's queued intent.
|
||||
reset(); stream.capacity=0; command("set usb.logging on");
|
||||
connected=false; serviceUsbLoggingReplyBarrier();
|
||||
connected=true; stream.capacity=4096; serviceUsbLoggingReplyBarrier();
|
||||
assert(!logging && !usb_logging_reply_pending && ble.tx.empty());
|
||||
reset(); stream.capacity=0; command("set usb.logging on");
|
||||
cancelUsbSerialOperations(); usb_serial_interface.resetSessionState();
|
||||
stream.capacity=4096; serviceUsbLoggingReplyBarrier(); assert(!logging);
|
||||
// A queue unable to accept the acknowledgement cannot start the handoff.
|
||||
reset(); stream.capacity=0;
|
||||
const uint8_t required[]={29,1};
|
||||
for(unsigned i=0;i<4;++i) assert(usb_serial_interface.writeFrame(required,2)==2);
|
||||
command("set usb.logging on");
|
||||
assert(!usb_logging_reply_pending && !logging && ble.tx.empty());
|
||||
// A later explicit setting supersedes an earlier pending on request.
|
||||
reset(); stream.capacity=0; command("set usb.logging on");
|
||||
the_mesh.applyUsbLoggingState(false);
|
||||
stream.capacity=4096; serviceUsbLoggingReplyBarrier(); assert(!logging);
|
||||
// Dedicated CDC and commands from another interface don't defer its reply.
|
||||
reset(); dedicated=true; command("set usb.logging on");
|
||||
assert(logging && !usb_logging_reply_pending && !the_mesh.terminal); checkReply();
|
||||
reset(); the_mesh.beginUsbLoggingReplyBarrier(&ble);
|
||||
the_mesh.applyUsbLoggingState(true); the_mesh.endUsbLoggingReplyBarrier(true);
|
||||
assert(logging && !usb_logging_reply_pending && the_mesh.terminal);
|
||||
// A real BLE framed command retains its route even when it parks USB.
|
||||
// Network terminal ownership likewise survives this logging update.
|
||||
for(bool network_owner : {false,true}) {
|
||||
reset(); network=network_owner;
|
||||
const char* setting="set usb.logging on";
|
||||
ble.rx={mesh::companion::kRunCliCommand};
|
||||
ble.rx.insert(ble.rx.end(),setting,setting+strlen(setting));
|
||||
size_t len=interface_manager.checkRecvFrame(the_mesh.cmd_frame);
|
||||
assert(len>0 && interface_manager.captureReplyRoute()==&ble);
|
||||
the_mesh.handleCmdFrame(len);
|
||||
assert(logging && !usb_logging_reply_pending && ble.tx[0]==29);
|
||||
assert(interface_manager.captureReplyRoute()==&ble && cancellations==0);
|
||||
assert(the_mesh.terminal!=network_owner && network==network_owner);
|
||||
assert(usb_serial_interface.isPassthroughMode());
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class CompanionLoggingReplyTest(unittest.TestCase):
|
||||
def test_logging_acknowledgement_precedes_single_tty_handoff(self):
|
||||
main = (ROOT / 'examples/companion_radio/main.cpp').read_text()
|
||||
mesh = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
|
||||
signatures = (
|
||||
'static void cancelUsbSerialOperations() {',
|
||||
'static void enterUsbTerminalMode(bool show_banner = true) {',
|
||||
'static void enterUsbLoggingTerminalMode() {',
|
||||
'static void serviceUsbLoggingOwnership(bool logging_enabled) {',
|
||||
'void MyMesh::applyUsbLoggingState(bool enabled) {',
|
||||
'void MyMesh::beginUsbLoggingReplyBarrier(BaseSerialInterface* route) {',
|
||||
'void MyMesh::endUsbLoggingReplyBarrier(bool reply_queued) {',
|
||||
'static void serviceUsbLoggingReplyBarrier() {',
|
||||
)
|
||||
source = HARNESS.replace('@FUNCTIONS@', '\n'.join(
|
||||
extract_braced(main, signature) for signature in signatures))
|
||||
setters = '\n'.join(extract_braced(mesh, signature) for signature in (
|
||||
'if (strncmp(command, "set usb.logging", 15) == 0',
|
||||
'if (strncmp(command, "set logging.output ", 19) == 0)',
|
||||
))
|
||||
source = source.replace('@SETTERS@', setters).replace('@CLI_FRAME@',
|
||||
extract_braced(mesh, 'if (mesh::companion::isRunCliFrame(cmd_frame[0], len))'))
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
cpp, exe = Path(directory) / 'reply.cpp', Path(directory) / 'reply.exe'
|
||||
cpp.write_text(source)
|
||||
subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', '-Werror',
|
||||
'-Wno-unused-parameter', '-Wno-sign-compare',
|
||||
'-I' + str(ROOT / 'test/mocks'), '-I' + str(ROOT / 'src'),
|
||||
str(cpp), str(ROOT / 'src/helpers/ArduinoSerialInterface.cpp'),
|
||||
'-o', str(exe)], check=True)
|
||||
subprocess.run([str(exe)], check=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -222,6 +222,27 @@ int main() {
|
||||
drain(radio);
|
||||
require(console.output.empty(), "canceled old-session output leaked");
|
||||
}
|
||||
// The real HWCDC setup falls back to these ring sizes after allocation
|
||||
// failure. A stored row larger than the ring must finish without omission.
|
||||
for (size_t capacity : {256u, 512u}) {
|
||||
const std::string stored = std::string(639, 's') + "\nnext\n";
|
||||
setupFile(stored);
|
||||
MyMesh radio;
|
||||
radio.dumpLogFile();
|
||||
console.capacity = 0;
|
||||
radio.servicePendingSerialOutput();
|
||||
require(console.output.empty(), "fallback dump wrote to a stalled host");
|
||||
drain(radio, capacity);
|
||||
require(console.output == stored + eof, "small TX ring wedged or truncated a stored line");
|
||||
}
|
||||
setupFile("short\n");
|
||||
{
|
||||
MyMesh radio;
|
||||
radio.dumpLogFile();
|
||||
console.short_limit = 3;
|
||||
drain(radio, 7);
|
||||
require(console.output == "short\n" + eof, "short-write EOF was duplicated or truncated");
|
||||
}
|
||||
setupFile("");
|
||||
{
|
||||
MyMesh radio;
|
||||
@@ -305,6 +326,47 @@ int main() {
|
||||
'''
|
||||
|
||||
RECENT_TEST = r'''
|
||||
setupFile("");
|
||||
{
|
||||
MyMesh radio;
|
||||
radio.tables.rows.push_back({{0, 0, 1}, 3, -127});
|
||||
radio.tables.rows.push_back({{0, 0, 2}, 3, -127});
|
||||
console.short_limit = 3;
|
||||
radio.printRecentRepeatersSerial();
|
||||
require(radio.serial_recent_header, "partial header advanced cursor");
|
||||
drain(radio, 7);
|
||||
require(console.output == "Recent repeaters (2):\n000001,-31.75\n000002,-31.75\n",
|
||||
"short-write recent listing duplicated or lost a prefix");
|
||||
}
|
||||
setupFile("");
|
||||
{
|
||||
MyMesh radio;
|
||||
radio.tables.rows.push_back({{0, 0, 1}, 3, -127});
|
||||
radio.printRecentRepeatersSerial();
|
||||
console.short_limit = 3;
|
||||
radio.servicePendingSerialOutput();
|
||||
require(radio.serial_log_pending_size > 0, "partial recent row was not retained");
|
||||
radio.tables.rows[0] = {{0, 0, 9}, 3, 0};
|
||||
console.short_limit = 0;
|
||||
drain(radio, 64);
|
||||
require(console.output == "Recent repeaters (1):\n000001,-31.75\n",
|
||||
"live table update changed a partially emitted recent row");
|
||||
}
|
||||
setupFile("");
|
||||
{
|
||||
MyMesh radio;
|
||||
radio.tables.rows.push_back({{0, 0, 1}, 3, -127});
|
||||
console.short_limit = 3;
|
||||
radio.printRecentRepeatersSerial();
|
||||
radio.cancelPendingSerialOutput();
|
||||
console.output.clear();
|
||||
console.short_limit = 0;
|
||||
radio.tables.rows.clear();
|
||||
radio.printRecentRepeatersSerial();
|
||||
drain(radio, 64);
|
||||
require(console.output == "Recent repeaters (0):\n-none-\r\n",
|
||||
"canceled recent suffix leaked into the next session");
|
||||
}
|
||||
setupFile("");
|
||||
{
|
||||
MyMesh radio;
|
||||
|
||||
@@ -181,7 +181,7 @@ class Esp32TinyUsbRoleHygieneTest(unittest.TestCase):
|
||||
loop.index("servicePendingSerialOutput();"),
|
||||
)
|
||||
|
||||
def test_recent_list_advances_only_after_whole_row_admission(self):
|
||||
def test_recent_list_advances_only_after_retained_row_finishes(self):
|
||||
text = source("examples/simple_repeater/MyMesh.cpp")
|
||||
start = text.index("void MyMesh::servicePendingSerialOutput()")
|
||||
start = text.index("if (serial_recent_next >= 0)", start)
|
||||
@@ -192,10 +192,12 @@ class Esp32TinyUsbRoleHygieneTest(unittest.TestCase):
|
||||
self.assertNotIn("getRecentRepeaterBySortedIdx", service)
|
||||
self.assertNotRegex(service, r"\b(?:while|for)\s*\(")
|
||||
self.assertLess(
|
||||
service.index("console.availableForWrite() < length"),
|
||||
service.index("if (serial_log_pending_size != 0) return;"),
|
||||
service.index("++serial_recent_next"),
|
||||
)
|
||||
self.assertIn("!= static_cast<size_t>(length)) return;", service)
|
||||
self.assertIn("if (serial_log_pending_size == 0)", service)
|
||||
self.assertIn("memcpy(serial_log_pending, record, length);", service)
|
||||
self.assertIn("write_pending();", service)
|
||||
|
||||
def test_functional_reserve_fits_default_full_acl_plus_command_echo(self):
|
||||
logging = source("src/helpers/UsbLogging.cpp")
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Execute GPS sync interval policy and both production provider timers."""
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_gps_upstream_adaptations import ARDUINO, NMEA, RTC
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
MESH = r'''
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
namespace mesh {
|
||||
class RTCClock {
|
||||
uint32_t utc=1790899200U;
|
||||
public:
|
||||
unsigned writes=0;
|
||||
uint32_t getCurrentTime(){return utc;}
|
||||
void setCurrentTime(uint32_t value){utc=value;++writes;}
|
||||
};
|
||||
}
|
||||
'''
|
||||
|
||||
BASE = r'''
|
||||
#include <cassert>
|
||||
#include <helpers/GpsPowerPolicy.h>
|
||||
#include <helpers/sensors/LocationProvider.h>
|
||||
void LocationProvider::sendSentence(const char*){}
|
||||
struct Provider:LocationProvider {
|
||||
using LocationProvider::periodicTimeSyncIntervalMillis;
|
||||
long getLatitude()override{return 0;}
|
||||
long getLongitude()override{return 0;}
|
||||
long getAltitude()override{return 0;}
|
||||
long satellitesCount()override{return 0;}
|
||||
bool isValid()override{return false;}
|
||||
long getTimestamp()override{return 0;}
|
||||
void reset()override{}void begin()override{}void stop()override{}void loop()override{}
|
||||
bool isEnabled()override{return true;}
|
||||
void applied(){markTimeSyncApplied();stopTimeSync();}
|
||||
};
|
||||
'''
|
||||
|
||||
PARSER = BASE + r'''
|
||||
int main(){
|
||||
uint16_t hours=77;
|
||||
for(const char* value:{"1","01","0001"}){
|
||||
assert(mesh::gps::parseSyncIntervalHours(value,hours)&&hours==1);
|
||||
}
|
||||
for(const char* value:{"336","337","999","4294967296",
|
||||
"999999999999999999999999999999999999999999999999"}){
|
||||
assert(mesh::gps::parseSyncIntervalHours(value,hours)&&hours==336);
|
||||
}
|
||||
assert(mesh::gps::parseSyncIntervalHours("168",hours)&&hours==168);
|
||||
for(const char* value:{"","0","000","-1","+1","1.0"," 1","1 ","1h",
|
||||
"999999999999999999999999x","0x10"}){
|
||||
hours=77;assert(!mesh::gps::parseSyncIntervalHours(value,hours));assert(hours==77);
|
||||
}
|
||||
assert(!mesh::gps::parseSyncIntervalHours(nullptr,hours));
|
||||
// Position processing keeps its existing seconds semantics and bounds.
|
||||
uint32_t seconds=77;
|
||||
assert(mesh::gps::parseUpdateInterval("0",seconds)&&seconds==0);
|
||||
assert(mesh::gps::updateIntervalMillis(seconds)==1000);
|
||||
assert(mesh::gps::parseUpdateInterval("86400",seconds)&&seconds==86400);
|
||||
assert(!mesh::gps::parseUpdateInterval("999999",seconds));
|
||||
}
|
||||
'''
|
||||
|
||||
POWER = BASE + r'''
|
||||
int main(){
|
||||
const uint32_t hour=3600000U;
|
||||
Provider gps;now_ms=100;
|
||||
assert(gps.getTimeSyncIntervalHours()==0);
|
||||
assert(gps.periodicTimeSyncIntervalMillis()==1800000UL);
|
||||
gps.setNextWake();assert(gps.getNextWake()==100+24UL*hour);
|
||||
gps.setPowerSavingProfile(600,7UL*24UL*3600UL);
|
||||
gps.setNextWake();assert(gps.getNextWake()==100+7UL*24UL*hour);
|
||||
gps.setNextSleep();assert(gps.getNextSleep()==600100UL);
|
||||
gps.setTimeSyncIntervalHours(1);
|
||||
assert(gps.getTimeSyncIntervalHours()==1&&gps.periodicTimeSyncIntervalMillis()==hour);
|
||||
assert(gps.getNextWake()==100+hour); // Replace an already armed legacy wake.
|
||||
gps.setNextSleep();assert(gps.getNextSleep()==600100UL);
|
||||
gps.stopTimeSync();gps.setNextWake();assert(gps.getNextWake()==100+hour);
|
||||
gps.setTimeSyncIntervalHours(336);
|
||||
assert(gps.periodicTimeSyncIntervalMillis()==1209600000UL);
|
||||
assert(gps.getNextWake()==100+336UL*hour);
|
||||
gps.setNextGPSOn(0);gps.setTimeSyncIntervalHours(1);
|
||||
assert(gps.getNextWake()==0); // Changing policy does not start a disabled GPS.
|
||||
|
||||
// Successful time fixes anchor the next scheduled wake; position work cannot
|
||||
// postpone the long time-sync interval by repeatedly stopping the receiver.
|
||||
now_ms=1000;gps.applied();now_ms=10000;gps.setNextWake();
|
||||
assert(gps.getNextWake()==1000+hour);
|
||||
now_ms=20000;gps.setNextWake();assert(gps.getNextWake()==1000+hour);
|
||||
gps.syncTimeForPowerSavingCycle();assert(!gps.waitingTimeSync());
|
||||
now_ms=1000+hour-1;gps.syncTimeForPowerSavingCycle();assert(!gps.waitingTimeSync());
|
||||
now_ms=1000+hour;gps.syncTimeForPowerSavingCycle();assert(gps.waitingTimeSync());
|
||||
gps.stopTimeSync();
|
||||
// An acquisition request without a fix is still paced, including later
|
||||
// position wake-ups. It cannot cause every telemetry request to force sync.
|
||||
now_ms=1000+hour+5000;gps.syncTimeForPowerSavingCycle();assert(!gps.waitingTimeSync());
|
||||
gps.setNextWake();assert(gps.getNextWake()==1000+2UL*hour);
|
||||
now_ms=1000+2UL*hour;gps.syncTimeForPowerSavingCycle();assert(gps.waitingTimeSync());
|
||||
gps.applied();
|
||||
// Direct manual/startup requests always override the configured pace.
|
||||
++now_ms;gps.syncTime();assert(gps.waitingTimeSync());gps.stopTimeSync();
|
||||
gps.setTimeSyncIntervalHours(0);
|
||||
assert(gps.periodicTimeSyncIntervalMillis()==1800000UL);
|
||||
gps.syncTimeForPowerSavingCycle();assert(gps.waitingTimeSync());
|
||||
gps.setNextWake();assert(gps.getNextWake()==now_ms+7UL*24UL*hour);
|
||||
|
||||
// Deadline/elapsed arithmetic must remain valid through 32-bit millis wrap.
|
||||
Provider wrap;wrap.setTimeSyncIntervalHours(336);
|
||||
now_ms=UINT32_MAX-1000U;wrap.applied();
|
||||
now_ms=1000;wrap.setNextWake();
|
||||
assert(static_cast<uint32_t>(wrap.getNextWake())==
|
||||
static_cast<uint32_t>(UINT32_MAX-1000U+336U*hour));
|
||||
wrap.syncTimeForPowerSavingCycle();assert(!wrap.waitingTimeSync());
|
||||
now_ms=static_cast<uint32_t>(UINT32_MAX-1000U+336U*hour);
|
||||
wrap.syncTimeForPowerSavingCycle();assert(wrap.waitingTimeSync());
|
||||
// Exhausting the acquisition window cannot leave an immediate retry loop.
|
||||
wrap.stopTimeSync();now_ms+=336U*hour;wrap.setNextWake();
|
||||
assert(static_cast<uint32_t>(wrap.getNextWake())==now_ms+336U*hour);
|
||||
// The deadline's zero sentinel must not lose a real wake across rollover.
|
||||
Provider zero;zero.setTimeSyncIntervalHours(1);now_ms=0U-hour;
|
||||
zero.setNextWake();assert(zero.getNextWake()==1);
|
||||
}
|
||||
'''
|
||||
|
||||
PROVIDERS = r'''
|
||||
#include <cassert>
|
||||
#include <helpers/sensors/MicroNMEALocationProvider.h>
|
||||
void LocationProvider::sendSentence(const char*){}
|
||||
struct Uart:Stream {
|
||||
std::deque<char> incoming;
|
||||
int available()override{return static_cast<int>(incoming.size());}
|
||||
int read()override{char c=incoming.front();incoming.pop_front();return c;}
|
||||
void fix(){incoming.push_back('\n');}
|
||||
};
|
||||
struct Gnss {
|
||||
bool getGnssFixOk(int){return true;}
|
||||
long getLatitude(int){return 476100000;}
|
||||
long getLongitude(int){return -1223300000;}
|
||||
long getAltitude(int){return 125000;}
|
||||
int getSIV(int){return 8;}
|
||||
bool getDateValid(int){return true;}
|
||||
bool getTimeValid(int){return true;}
|
||||
long getUnixEpoch(int){return 1790942400;}
|
||||
} ublox_GNSS;
|
||||
@RAK@
|
||||
struct NmeaProvider:MicroNMEALocationProvider {
|
||||
using MicroNMEALocationProvider::MicroNMEALocationProvider;
|
||||
uint32_t lastApplied()const{return _last_time_sync_applied_ms;}
|
||||
};
|
||||
struct RakProvider:RAK12500LocationProvider {
|
||||
uint32_t lastApplied()const{return _last_time_sync_applied_ms;}
|
||||
};
|
||||
struct NmeaFixture {
|
||||
Uart uart;mesh::RTCClock clock;NmeaProvider gps;
|
||||
NmeaFixture():gps(uart,&clock,-1,-1){}
|
||||
void tick(){uart.fix();gps.loop();}
|
||||
};
|
||||
struct RakFixture {
|
||||
mesh::RTCClock clock;RakProvider gps;
|
||||
RakFixture(){gps.setRTCClock(&clock);}
|
||||
void tick(){gps.loop();}
|
||||
};
|
||||
template<class Fixture>void interval(uint16_t hours,uint32_t start){
|
||||
Fixture fixture;
|
||||
fixture.gps.setTimeSyncIntervalHours(hours);
|
||||
// A receiver has already been serviced before uptime reaches rollover.
|
||||
// Advance in gaps below half the target tick range to keep its sample timer
|
||||
// meaningful, then request the test's reference sync at the selected start.
|
||||
if(start>INT32_MAX){
|
||||
for(uint32_t warm:{1U,1073741824U,2147483647U,3221225470U}){
|
||||
now_ms=warm;fixture.tick();
|
||||
}
|
||||
}
|
||||
const unsigned before=fixture.clock.writes;
|
||||
fixture.gps.syncTime();
|
||||
now_ms=start;fixture.tick();
|
||||
if(hours&&fixture.gps.waitingTimeSync()){
|
||||
const unsigned clears=MicroNMEA::clears;
|
||||
fixture.gps.syncTimeForPowerSavingCycle();
|
||||
assert(MicroNMEA::clears==clears&&fixture.gps.waitingTimeSync());
|
||||
}
|
||||
now_ms+=1100;fixture.tick();now_ms+=1100;fixture.tick();
|
||||
assert(fixture.clock.writes==before+1&&!fixture.gps.waitingTimeSync());
|
||||
const uint32_t applied=fixture.gps.lastApplied();
|
||||
const uint32_t interval_ms=hours?static_cast<uint32_t>(hours)*3600000U:1800000U;
|
||||
now_ms=applied+interval_ms-1U;fixture.tick();assert(fixture.clock.writes==before+1);
|
||||
now_ms+=1100;fixture.tick();assert(fixture.clock.writes==before+2);
|
||||
assert(!fixture.gps.waitingTimeSync()&&fixture.gps.consumeTimeSyncApplied());
|
||||
|
||||
// Manual sync remains immediate even shortly after a periodic write.
|
||||
now_ms+=1100;fixture.gps.syncTime();assert(fixture.gps.waitingTimeSync());
|
||||
fixture.tick();now_ms+=1100;fixture.tick();now_ms+=1100;fixture.tick();
|
||||
assert(fixture.clock.writes==before+3&&!fixture.gps.waitingTimeSync());
|
||||
// Unrelated position acquisition reuses the last actual sync rather than
|
||||
// resetting the clock before the selected interval has elapsed.
|
||||
now_ms+=1100;fixture.gps.syncTimeForPowerSavingCycle();
|
||||
if(hours){assert(!fixture.gps.waitingTimeSync());fixture.tick();assert(fixture.clock.writes==before+3);}
|
||||
else{assert(fixture.gps.waitingTimeSync());}
|
||||
}
|
||||
int main(){
|
||||
for(uint16_t hours:{0U,1U,336U}){
|
||||
interval<NmeaFixture>(hours,1);interval<RakFixture>(hours,1);
|
||||
// Host Windows uses the target's 32-bit unsigned long ABI. On a 64-bit
|
||||
// Unix host base policy rollover remains covered by the separate harness.
|
||||
if(sizeof(unsigned long)==sizeof(uint32_t)){
|
||||
interval<NmeaFixture>(hours,UINT32_MAX-3000U);
|
||||
interval<RakFixture>(hours,UINT32_MAX-3000U);
|
||||
}
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
TELEMETRY = r'''
|
||||
#include <cassert>
|
||||
#include <helpers/SensorManager.h>
|
||||
void LocationProvider::sendSentence(const char*){}
|
||||
void SensorManager::setTelemetryLocationAccessAvailable(bool){}
|
||||
struct Provider:LocationProvider {
|
||||
long getLatitude()override{return 0;}long getLongitude()override{return 0;}
|
||||
long getAltitude()override{return 0;}long satellitesCount()override{return 0;}
|
||||
bool isValid()override{return false;}long getTimestamp()override{return 0;}
|
||||
void reset()override{}void begin()override{}void stop()override{}void loop()override{}
|
||||
bool isEnabled()override{return true;}
|
||||
void applied(){markTimeSyncApplied();stopTimeSync();}
|
||||
};
|
||||
struct Sensors:SensorManager {
|
||||
Provider& gps;explicit Sensors(Provider& p):gps(p){}
|
||||
bool telemetryGpsDetected()const override{return true;}
|
||||
LocationProvider* getLocationProvider()override{return &gps;}
|
||||
};
|
||||
int main(){
|
||||
Provider gps;Sensors sensors(gps);gps.setGPSPowerSaving(true);
|
||||
gps.setTimeSyncIntervalHours(336);now_ms=1000;gps.applied();
|
||||
now_ms=1000+60000;assert(!sensors.requestGpsTelemetryTimeSync(60));
|
||||
now_ms=1000+336UL*3600000UL-1;assert(!sensors.requestGpsTelemetryTimeSync(60));
|
||||
++now_ms;assert(sensors.requestGpsTelemetryTimeSync(60));
|
||||
gps.applied();gps.setTimeSyncIntervalHours(0);
|
||||
now_ms+=59999;assert(!sensors.requestGpsTelemetryTimeSync(60));
|
||||
++now_ms;assert(sensors.requestGpsTelemetryTimeSync(60));
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class GpsSyncIntervalTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
if cls.compiler is None:
|
||||
raise unittest.SkipTest("a host C++17 compiler is required")
|
||||
|
||||
def compile_run(self, source):
|
||||
with tempfile.TemporaryDirectory(prefix="meshcore-gps-sync-") as directory:
|
||||
path = Path(directory)
|
||||
for name, content in {
|
||||
"Arduino.h": ARDUINO, "Mesh.h": MESH, "MicroNMEA.h": NMEA,
|
||||
"RTClib.h": RTC, "CayenneLPP.h": "#pragma once\nclass CayenneLPP {};\n",
|
||||
"Wire.h": "#pragma once\nclass TwoWire {};\n",
|
||||
}.items():
|
||||
(path / name).write_text(content, encoding="utf-8")
|
||||
cpp = path / "test.cpp"
|
||||
cpp.write_text(source, encoding="utf-8")
|
||||
binary = path / "test"
|
||||
subprocess.run([
|
||||
self.compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
"-Wno-unused-parameter", "-DENV_INCLUDE_GPS=1",
|
||||
*(["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
|
||||
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []),
|
||||
"-I", str(path), "-I", str(ROOT / "src"), str(cpp), "-o", str(binary),
|
||||
], check=True)
|
||||
subprocess.run([str(binary)], check=True)
|
||||
|
||||
def test_positive_whole_hours_clamp_without_overflow(self):
|
||||
self.compile_run(PARSER)
|
||||
|
||||
def test_power_saving_interval_reconfiguration_and_rollover(self):
|
||||
self.compile_run(POWER)
|
||||
|
||||
def test_both_providers_use_hours_keep_legacy_and_allow_manual_sync(self):
|
||||
source = (ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp").read_text()
|
||||
rak = extract_braced(source, "class RAK12500LocationProvider") + ";"
|
||||
self.compile_run(PROVIDERS.replace("@RAK@", rak))
|
||||
|
||||
def test_position_telemetry_cannot_bypass_selected_time_interval(self):
|
||||
self.compile_run(TELEMETRY)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Run canonical GPS sync CLI branches with durable and live state failures."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
HARNESS = r'''
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
#include <helpers/GpsPowerPolicy.h>
|
||||
struct Prefs { uint16_t gps_sync_interval_hours = 0; };
|
||||
struct Provider {
|
||||
uint16_t hours = 0;
|
||||
uint16_t getTimeSyncIntervalHours() const { return hours; }
|
||||
};
|
||||
struct Sensors {
|
||||
Provider provider;
|
||||
bool present = true;
|
||||
unsigned applications = 0;
|
||||
Provider* getLocationProvider() { return present ? &provider : nullptr; }
|
||||
void applyGpsTimeSyncInterval(uint16_t hours) { provider.hours = hours; ++applications; }
|
||||
};
|
||||
struct CLI {
|
||||
Prefs prefs, persisted;
|
||||
Prefs* _prefs = &prefs;
|
||||
Sensors sensors;
|
||||
Sensors* _sensors = &sensors;
|
||||
bool save_ok = true;
|
||||
unsigned saves = 0;
|
||||
bool trySavePrefs() { ++saves; if (save_ok) persisted = prefs; return save_ok; }
|
||||
void set(const char* config, char* reply) { @COMMON_SET@ }
|
||||
void get(const char* config, char* reply) { @COMMON_GET@ }
|
||||
};
|
||||
struct Companion {
|
||||
Prefs _prefs, persisted;
|
||||
Sensors sensors;
|
||||
bool save_ok = true;
|
||||
unsigned saves = 0;
|
||||
bool savePrefs() { ++saves; if (save_ok) persisted = _prefs; return save_ok; }
|
||||
@SAVE_PREFERENCE@
|
||||
bool command(const char* command, char* reply, size_t reply_size) {
|
||||
@COMPANION_GET@
|
||||
const char* gps_sync_value = nullptr;
|
||||
@COMPANION_PREFIX@
|
||||
@COMPANION_SET@
|
||||
return false;
|
||||
}
|
||||
};
|
||||
int main() {
|
||||
char reply[160];
|
||||
CLI common; Companion companion;
|
||||
common.get("gps.sync.interval", reply);
|
||||
assert(strstr(reply, "default") && !strstr(reply, "0 hours"));
|
||||
assert(companion.command("get gps.sync.interval", reply, sizeof(reply)));
|
||||
assert(strstr(reply, "default") && !strstr(reply, "0 hours"));
|
||||
for (const char* value : {"1", "24", "336", "337", "4294967296", "999999999999999999999999"}) {
|
||||
char command[96];
|
||||
uint16_t expected=0; assert(mesh::gps::parseSyncIntervalHours(value,expected));
|
||||
snprintf(command,sizeof(command),"gps.sync.interval %s",value);
|
||||
common.set(command,reply);
|
||||
assert(common.prefs.gps_sync_interval_hours==expected);
|
||||
assert(common.persisted.gps_sync_interval_hours==expected && common.sensors.provider.hours==expected);
|
||||
char actual[32];snprintf(actual,sizeof(actual),"%u hours",unsigned(expected));
|
||||
assert(strstr(reply,actual) && strstr(reply,"saved"));
|
||||
snprintf(command,sizeof(command),"set gps.sync.interval %s",value);
|
||||
assert(companion.command(command,reply,sizeof(reply)));
|
||||
assert(companion._prefs.gps_sync_interval_hours==expected);
|
||||
assert(companion.persisted.gps_sync_interval_hours==expected && companion.sensors.provider.hours==expected);
|
||||
assert(strstr(reply,actual) && strstr(reply,"saved"));
|
||||
}
|
||||
for(const char* value : {"", "0", "0000", "-1", "1.5", "5h", "1 2", "337junk"}) {
|
||||
char command[64];
|
||||
const unsigned common_saves=common.saves, companion_saves=companion.saves;
|
||||
snprintf(command,sizeof(command),"gps.sync.interval %s",value);
|
||||
common.set(command,reply);assert(strstr(reply,"Error:"));
|
||||
snprintf(command,sizeof(command),"set gps.sync.interval %s",value);
|
||||
assert(companion.command(command,reply,sizeof(reply)) && strstr(reply,"Error:"));
|
||||
assert(common.saves==common_saves && companion.saves==companion_saves);
|
||||
}
|
||||
common.save_ok=companion.save_ok=false;
|
||||
const unsigned common_applied=common.sensors.applications, companion_applied=companion.sensors.applications;
|
||||
common.set("gps.sync.interval 7",reply);assert(strstr(reply,"could not be saved"));
|
||||
assert(companion.command("set gps.sync.interval 7",reply,sizeof(reply)) && strstr(reply,"could not be saved"));
|
||||
assert(common.prefs.gps_sync_interval_hours==336 && common.persisted.gps_sync_interval_hours==336);
|
||||
assert(companion._prefs.gps_sync_interval_hours==336 && companion.persisted.gps_sync_interval_hours==336);
|
||||
assert(common.sensors.provider.hours==336 && companion.sensors.provider.hours==336);
|
||||
assert(common.sensors.applications==common_applied && companion.sensors.applications==companion_applied);
|
||||
common.get("gps.sync.interval",reply);assert(!strcmp(reply,"> 336 hours"));
|
||||
assert(companion.command("get gps.sync.interval",reply,sizeof(reply)) && !strcmp(reply,"> 336 hours"));
|
||||
common.sensors.present=companion.sensors.present=false;
|
||||
const unsigned common_saves=common.saves, companion_saves=companion.saves;
|
||||
common.set("gps.sync.interval 1",reply);assert(strstr(reply,"unavailable"));
|
||||
assert(companion.command("set gps.sync.interval 1",reply,sizeof(reply)) && strstr(reply,"unavailable"));
|
||||
common.get("gps.sync.interval",reply);assert(strstr(reply,"unavailable"));
|
||||
assert(companion.command("get gps.sync.interval",reply,sizeof(reply)) && strstr(reply,"unavailable"));
|
||||
assert(common.saves==common_saves && companion.saves==companion_saves);
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class GpsSyncPreferencesTest(unittest.TestCase):
|
||||
def test_canonical_cli_transactions(self):
|
||||
common = (ROOT / 'src/helpers/CommonCLI.cpp').read_text()
|
||||
companion = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
|
||||
header = (ROOT / 'examples/companion_radio/MyMesh.h').read_text()
|
||||
replacements = {
|
||||
'@COMMON_SET@': extract_braced(common, 'if (strncmp(config, "gps.sync.interval", 17)'),
|
||||
'@COMMON_GET@': extract_braced(common, 'if (strcmp(config, "gps.sync.interval") == 0)'),
|
||||
'@COMPANION_GET@': extract_braced(companion, 'if (strcmp(command, "get gps.sync.interval") == 0)'),
|
||||
'@COMPANION_PREFIX@': extract_braced(companion, 'if (strncmp(command, "set gps.sync.interval", 21)'),
|
||||
'@COMPANION_SET@': extract_braced(companion, 'if (gps_sync_value)'),
|
||||
'@SAVE_PREFERENCE@': extract_braced(header, 'template <typename T> bool savePreference('),
|
||||
}
|
||||
code = HARNESS
|
||||
for token, value in replacements.items():
|
||||
code = code.replace(token, value)
|
||||
with tempfile.TemporaryDirectory(prefix='meshcore-gps-sync-prefs-') as directory:
|
||||
cpp, exe = Path(directory) / 'test.cpp', Path(directory) / 'test.exe'
|
||||
cpp.write_text(code)
|
||||
subprocess.run(['g++', '-std=c++17', '-Wall', '-Wextra', '-Werror',
|
||||
'-I' + str(ROOT / 'src'), str(cpp), '-o', str(exe)], check=True)
|
||||
subprocess.run([str(exe)], check=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -392,9 +392,10 @@ int main(){
|
||||
rak_provider = rak[rak.index("class RAK12500LocationProvider"):rak.index("static RAK12500LocationProvider")]
|
||||
self.assertIn("markTimeSyncApplied();", rak_provider)
|
||||
self.assertIn("resetTimeSyncRequestState();", rak_provider)
|
||||
# Preserve deliberate force-sync callers outside telemetry queries.
|
||||
# Explicit CLI sync remains forced; scheduled power cycles honor the
|
||||
# configured hourly cadence, with force-sync retained for legacy0.
|
||||
self.assertIn("void syncTime() override { nmea.clear(); LocationProvider::syncTime(); }", provider)
|
||||
self.assertIn("_location->syncTime();", extract_braced(rak, "void EnvironmentSensorManager::armGpsPowerSavingCycle()"))
|
||||
self.assertIn("_location->syncTimeForPowerSavingCycle();", extract_braced(rak, "void EnvironmentSensorManager::armGpsPowerSavingCycle()"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -35,6 +35,10 @@ static unsigned rx_flushes = 0, tx_clears = 0, log_edges = 0;
|
||||
// leave an armed packet intact; only a real detach + enumeration retires it.
|
||||
static bool armed_in[2] = {false, false}, attached = true;
|
||||
static unsigned detaches = 0, attaches = 0;
|
||||
struct Registers { unsigned USBPULLUP = 1; } registers;
|
||||
#define NRF_USBD (®isters)
|
||||
static void __ISB() {}
|
||||
static void __DSB() {}
|
||||
static bool close_during_rx_sample = false;
|
||||
static uint32_t millis() { return now_ms; }
|
||||
static bool tud_mounted() { return mounted; }
|
||||
@@ -52,8 +56,8 @@ static void tud_cdc_n_read_flush(uint8_t) { ++rx_flushes; rx_count = 0; }
|
||||
static uint32_t tud_cdc_n_write_clear(uint8_t) { ++tx_clears; tx_count = 0; return 0; }
|
||||
static bool mesh_tud_cdc_n_tx_pending(uint8_t n) { return armed_in[n]; }
|
||||
struct UsbDevice {
|
||||
void detach() { ++detaches; attached = false; }
|
||||
void attach() { ++attaches; attached = true; }
|
||||
void detach() { ++detaches; attached = false; registers.USBPULLUP = 0; }
|
||||
void attach() { ++attaches; attached = true; registers.USBPULLUP = 1; }
|
||||
} TinyUSBDevice;
|
||||
static uint32_t tud_cdc_n_write_available(uint8_t) { return 64 - tx_count; }
|
||||
static uint32_t tud_cdc_n_write(uint8_t, const void*, uint32_t size) {
|
||||
@@ -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);
|
||||
|
||||
@@ -127,6 +127,105 @@ int main() {
|
||||
'''.replace("@BEGIN@", function("void beginUsbLoggingPort()"))
|
||||
self.run_native(source)
|
||||
|
||||
def test_full_owner_event_queue_defers_stack_detach_but_quarantines_packet(self):
|
||||
start = USB.index("static std::atomic<uint32_t> primary_usb_reset_generation")
|
||||
gates = USB[start:USB.index("\n#endif", start)]
|
||||
pinned_driver = (ROOT / "test/fixtures/nrf52_usb_power_original.c").read_text()
|
||||
start = pinned_driver.index("void dcd_disconnect(")
|
||||
disconnect = pinned_driver[start:pinned_driver.index("\n}\n", start) + 3]
|
||||
source = r'''
|
||||
#include <atomic>
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#define ENABLE_USB_INTERFACE 1
|
||||
static bool mounted = true, armed[2] = {false, true};
|
||||
static uint32_t now = 100, fifo[2] = {0, 32};
|
||||
static bool owner_callback = false;
|
||||
static constexpr unsigned DCD_EVENT_UNPLUGGED = 1, queue_capacity = 4;
|
||||
static unsigned events[queue_capacity] = {0, 0, 0, 0};
|
||||
static unsigned queue_head = 0, queue_tail = 0, queue_count = queue_capacity;
|
||||
static unsigned event_sends = 0, detach_count = 0, attach_count = 0;
|
||||
static unsigned owner_resets = 0;
|
||||
struct Registers { unsigned USBPULLUP = 1; } registers;
|
||||
#define NRF_USBD (®isters)
|
||||
static void __ISB() {}
|
||||
static void __DSB() {}
|
||||
static uint32_t millis() { return now; }
|
||||
static bool tud_mounted() { return mounted; }
|
||||
static uint32_t tud_cdc_n_available(uint8_t) { return 0; }
|
||||
static void tud_cdc_n_read_flush(uint8_t) {}
|
||||
static bool tud_cdc_n_write_clear(uint8_t n) { fifo[n] = 0; return true; }
|
||||
static bool mesh_tud_cdc_n_tx_pending(uint8_t n) { return armed[n]; }
|
||||
static void owner_dispatch_one() {
|
||||
assert(!owner_callback && queue_count != 0);
|
||||
owner_callback = true;
|
||||
const unsigned event = events[queue_tail];
|
||||
queue_tail = (queue_tail + 1) % queue_capacity;
|
||||
--queue_count;
|
||||
if (event == DCD_EVENT_UNPLUGGED) {
|
||||
mounted = false;
|
||||
armed[0] = armed[1] = false;
|
||||
++owner_resets;
|
||||
}
|
||||
owner_callback = false;
|
||||
}
|
||||
static void dcd_event_bus_signal(unsigned port, unsigned event, bool isr) {
|
||||
assert(port == 0 && event == DCD_EVENT_UNPLUGGED && !isr);
|
||||
// Real osal_queue_send uses an infinite FreeRTOS timeout here. If called
|
||||
// by its sole consumer while full, it could never return. Fail immediately
|
||||
// rather than hanging the test on precisely that regression.
|
||||
assert(!owner_callback);
|
||||
++event_sends;
|
||||
if (queue_count == queue_capacity) {
|
||||
// A blocked application sender yields to the independent USB owner,
|
||||
// which drains an existing event and releases a slot for UNPLUGGED.
|
||||
owner_dispatch_one();
|
||||
}
|
||||
assert(queue_count < queue_capacity);
|
||||
events[queue_head] = event;
|
||||
queue_head = (queue_head + 1) % queue_capacity;
|
||||
++queue_count;
|
||||
}
|
||||
@DISCONNECT@
|
||||
struct Device {
|
||||
void detach() { ++detach_count; dcd_disconnect(0); }
|
||||
void attach() { ++attach_count; registers.USBPULLUP = 1; }
|
||||
} TinyUSBDevice;
|
||||
namespace mesh {
|
||||
@GATES@
|
||||
}
|
||||
int main() {
|
||||
// The owner is servicing a close/SOF callback while IRQ events fill every
|
||||
// available queue slot. The old data is armed in the controller, not FIFO.
|
||||
owner_callback = true;
|
||||
mesh::clearNrf52UsbTxForSession(1);
|
||||
assert(queue_count == queue_capacity && event_sends == 0);
|
||||
assert(registers.USBPULLUP == 0 && armed[1] && fifo[1] == 0);
|
||||
assert(!mesh::canAccessPrimaryUsbSession(nullptr));
|
||||
mesh::requestNrf52UsbSessionReenumeration(); // repeated callback is harmless
|
||||
assert(detach_count == 0 && event_sends == 0);
|
||||
owner_callback = false;
|
||||
|
||||
// A busy application loop can service much later. Its first pass only
|
||||
// issues the stack detach; it must not reuse an expired callback deadline.
|
||||
now += 400;
|
||||
mesh::serviceNrf52UsbSessionReenumeration();
|
||||
assert(detach_count == 1 && event_sends == 1 && attach_count == 0);
|
||||
mesh::serviceNrf52UsbSessionReenumeration();
|
||||
assert(detach_count == 1 && attach_count == 0);
|
||||
now += 20;
|
||||
mesh::serviceNrf52UsbSessionReenumeration();
|
||||
assert(attach_count == 0); // owner has not consumed UNPLUGGED yet
|
||||
while (queue_count != 0) owner_dispatch_one();
|
||||
assert(owner_resets == 1 && !mounted && !armed[1]);
|
||||
mesh::serviceNrf52UsbSessionReenumeration();
|
||||
assert(attach_count == 1 && registers.USBPULLUP == 1);
|
||||
mesh::serviceNrf52UsbSessionReenumeration();
|
||||
assert(detach_count == 1 && attach_count == 1); // recovery is one-shot
|
||||
}
|
||||
'''
|
||||
self.run_native(source.replace("@DISCONNECT@", disconnect).replace("@GATES@", gates))
|
||||
|
||||
def test_armed_logging_packet_and_racing_primary_packet_force_clean_bus(self):
|
||||
start = USB.index("static std::atomic<uint32_t> primary_usb_reset_generation")
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user