From a2f51253d8c7dc797de08075d36284c515633798 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 13 Oct 2026 09:55:32 -0700 Subject: [PATCH] Fix WisBlock GPS power policy and preserve shared peripherals --- .github/workflows/run-unit-tests.yml | 2 + docs/cli_commands.md | 45 +- docs/gps_tracking.md | 38 +- examples/companion_radio/DataStore.cpp | 24 + examples/companion_radio/MyMesh.cpp | 25 + examples/companion_radio/NodePrefs.h | 20 +- src/helpers/CommonCLI.cpp | 51 +- src/helpers/CommonCLI.h | 4 + src/helpers/PeripheralModePrefs.h | 103 ++++ src/helpers/SensorManager.h | 7 + .../sensors/EnvironmentSensorManager.cpp | 233 ++++++--- .../sensors/EnvironmentSensorManager.h | 28 ++ .../sensors/WisBlockSharedPowerPolicy.h | 108 +++++ test/fixtures/rak_gps_power.cpp | 444 ++++++++++++++++++ test/fixtures/rak_peripheral_power_no_gps.cpp | 77 +++ .../fixtures/wisblock_shared_power_policy.cpp | 134 ++++++ test/test_build_profiles.sh | 8 +- test/test_companion_prefs_values.py | 3 +- ...companion_settings_persistence_contract.py | 11 +- test/test_esp32_gps_discovery.py | 3 +- test/test_gps_status.py | 10 + test/test_nrf52_variant_contracts.py | 47 +- test/test_peripheral_mode_prefs.py | 302 ++++++++++++ test/test_rak_gps_power.py | 204 ++++++++ variants/rak3401/platformio.ini | 3 +- variants/rak4631/platformio.ini | 3 +- 26 files changed, 1830 insertions(+), 107 deletions(-) create mode 100644 src/helpers/PeripheralModePrefs.h create mode 100644 src/helpers/sensors/WisBlockSharedPowerPolicy.h create mode 100644 test/fixtures/rak_gps_power.cpp create mode 100644 test/fixtures/rak_peripheral_power_no_gps.cpp create mode 100644 test/fixtures/wisblock_shared_power_policy.cpp create mode 100644 test/test_peripheral_mode_prefs.py create mode 100644 test/test_rak_gps_power.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 3f47d834..425a3353 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -459,6 +459,8 @@ jobs: python3 -B test/test_esp32_gps_discovery.py -v python3 -B test/test_gps_sync_interval.py -v python3 -B test/test_gps_sync_preferences.py -v + python3 -B test/test_rak_gps_power.py -v + python3 -B test/test_peripheral_mode_prefs.py -v python3 -B test/test_t1000e_sensor_power.py -v python3 -B test/test_sensor_battery_alerts.py -v python3 -B test/test_sensor_subscriptions.py -v diff --git a/docs/cli_commands.md b/docs/cli_commands.md index a46354dd..0eb878b3 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -4715,12 +4715,51 @@ clear recent.repeater **Note:** These command names are shared with GPS-capable Companion builds. Companion reports `on` or `off`; infrastructure includes receiver details: -- `off` when the GPS hardware is disabled -- `on, {active|deactivated}, {fix|no fix}, {sat count} sats` when the GPS hardware is enabled +- `off, {powered|unpowered}` when GPS is manually disabled +- `on, {powered|unpowered}, {fix|no fix}, {sat count} sats` when GPS is enabled + +`powered` describes the supply, not proof that the receiver is tracking or +asleep. Shared-power hardware can remain powered while GPS processing is off. On repeaters, `gps off` cancels any acquisition already in progress. A later authorized location-telemetry request may wake the receiver, but only until its -first valid fix is cached; the receiver then powers down again. +first valid fix is cached; GPS processing then stops again. Whether its supply +can also turn off depends on the board's power policy below. + +--- + +#### RAK4631 shared peripheral power + +**Usage:** + +```text +get peripherals.mode +set peripherals.mode gps-only +set peripherals.mode shared +reboot +``` + +**Default:** `gps-only` on RAK4631. The saved mode takes effect only after +reboot; setting it does not switch power immediately. `get peripherals.mode` +reports the saved choice, which may differ from the current boot's mode. + +- `gps-only`: when no protected power owner exists, GPS-off and GPS sleep can + turn off the shared WisBlock `3V3_S` supply. Select this only when other + attached peripherals do not need that supply. +- `shared`: keeps `3V3_S` powered for other peripherals. Save this mode and + reboot when using other WisBlock modules; firmware does not infer this choice + from an I2C scan. + +Ethernet/PoE builds and detected external NOR storage automatically keep the +rail on, even in `gps-only` mode or when the network service is disabled. +RAK3401 permanently protects the same rail because its radio frontend needs +it; `peripherals.mode` is a RAK4631 setting, not an override of that protection. + +On a powered shared rail, RAK12501/L76K cannot be independently switched off: +GPS-off stops MCU parsing, not physical GNSS operation. Other receivers may use +internal power-saving commands, but `shared` does not promise physical sleep. +UART1 bridge ownership remains protected; changing this mode does not grant +permission to connect two transmitters to the GPS UART. --- diff --git a/docs/gps_tracking.md b/docs/gps_tracking.md index b2e5b981..527098f5 100644 --- a/docs/gps_tracking.md +++ b/docs/gps_tracking.md @@ -50,10 +50,11 @@ GPS on, and manual GPS-on still keeps the cache updated. Repeaters use a shorter on-demand policy when GPS is manually off. An authorized location-telemetry request wakes the receiver only when its cached position is -stale. The first valid fix is cached and the receiver immediately powers down; +stale. The first valid fix is cached and GPS processing immediately stops; the next request can return that fix without another wake. `gps off` also cancels an acquisition already in progress. An acquisition that never obtains a -fix retains the normal 15-minute safety timeout. +fix retains the normal 15-minute safety timeout. Physical supply switching +depends on the board and its shared peripheral policy. When GPS and device power saving are both enabled, a repeater wakes GPS for up to 10 minutes to obtain a valid time and then sleeps it for seven days. A valid @@ -61,6 +62,39 @@ GPS time may correct the RTC either forward or backward. With device power saving off, an enabled GPS remains active and refreshes the RTC every 30 minutes. +## RAK WisBlock Power Supply + +RAK4631 defaults to saved peripheral mode `gps-only`. With no protected power +owner, stopping or sleeping GPS can turn off the shared `3V3_S` rail. If other +attached modules need that rail, explicitly select shared mode: + +```text +get peripherals.mode +set peripherals.mode shared +reboot +``` + +Use `set peripherals.mode gps-only` and reboot to return to the default. +Changes are saved for the next boot, not applied live; the query reports the +saved choice. Other peripheral requirements are not guessed from an I2C scan. +Boot and discovery keep the rail on until the power policy is established. + +This saved setting is available in Companion and Common CLI roles (repeater, +room server, and sensor). Raw KISS and legacy Terminal Chat do not configure +it and retain the powered rail. Shared mode controls the supply; it does not +add Ethernet support to a non-Ethernet firmware target. + +Ethernet/PoE builds and detected external NOR storage automatically retain +power regardless of this setting. RAK3401 always retains the rail for its radio +frontend and does not offer this override. + +In shared mode, GPS-off does not mean the supply is off. In particular, the +[RAK12501/L76K module](https://docs.rakwireless.com/product-categories/wisblock/rak12501/datasheet/) +has no independent power switch: stopping MCU parsing leaves the receiver +powered. Internal sleep support on other GNSS modules is receiver-dependent. +This setting does not remove the UART1 bridge conflict or authorize another +transmitter on the GPS wiring. + ## Companion/Client Nodes Companion/client telemetry uses the existing companion telemetry permission diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 9d2031a2..9e31c73a 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -4,6 +4,9 @@ #include #include "DataStore.h" #include "PrefsStorageLayout.h" +#if defined(RAK_4631) +#include +#endif #include #include #include @@ -1104,6 +1107,9 @@ bool DataStore::loadPrefsInt(const char *filename, loaded_prefs.usb_debug_enabled = 0; // Old images cannot opt in using an unrelated runtime preference value. loaded_prefs.lost_reply = 0; +#if defined(RAK_4631) + loaded_prefs.peripherals_shared = 0; +#endif double loaded_lat = node_lat; double loaded_lon = node_lon; // The original image ended after ble_pin at byte 84. Later releases only @@ -1197,6 +1203,11 @@ bool DataStore::loadPrefsInt(const char *filename, break; } } +#if defined(RAK_4631) + // Only the RAK4631 publishes this footer. Other targets retain their + // exact existing image sizes, including the compact STM32 wire table. + known_size = known_size || prefs_size == 236 + mesh::peripherals::FooterSize; +#endif if (prefs_size < MIN_PREFS_SIZE || !known_size) { file.close(); return false; @@ -1348,6 +1359,14 @@ bool DataStore::loadPrefsInt(const char *filename, readOptionalField(&loaded_prefs.usb_debug_enabled, sizeof(loaded_prefs.usb_debug_enabled)); readOptionalField(&loaded_prefs.lost_reply, sizeof(loaded_prefs.lost_reply)); +#if defined(RAK_4631) + if (file.available() != 0) { + uint8_t peripheral_footer[mesh::peripherals::FooterSize] = {}; + success = success && readField(peripheral_footer, sizeof(peripheral_footer)) + && mesh::peripherals::decodeFooter(peripheral_footer, + sizeof(peripheral_footer), loaded_prefs.peripherals_shared); + } +#endif #endif // Any bytes left over form only part of a historically appended field. @@ -1543,6 +1562,11 @@ bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, dou memcpy(tail + 6, &_prefs.gps_sync_interval_hours, sizeof(_prefs.gps_sync_interval_hours)); success = success && writeField(tail, sizeof(tail)); +#if defined(RAK_4631) + uint8_t peripheral_footer[mesh::peripherals::FooterSize]; + mesh::peripherals::encodeFooter(peripheral_footer, _prefs.peripherals_shared); + success = success && writeField(peripheral_footer, sizeof(peripheral_footer)); +#endif #if defined(STM32_PLATFORM) success = success && image_length == sizeof(image) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index ad3d52c9..c426a78e 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2,6 +2,9 @@ #include #include #include +#if defined(RAK_4631) +#include +#endif #include #include "NotificationSettingsFile.h" #include "MyMesh.h" @@ -2758,6 +2761,9 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _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 +#if defined(RAK_4631) + _prefs.peripherals_shared = 0; // A genuinely fresh missing image is GPS-only. +#endif _prefs.autoadd_config = DEFAULT_AUTOADD_CONFIG; _prefs.path_hash_mode = DEFAULT_PATH_HASH_MODE; #ifdef RADIO_FEM_TXGAIN @@ -2934,6 +2940,12 @@ void MyMesh::begin(bool has_display, bool radio_available, // load persisted prefs const bool prefs_ready = _store->loadPrefs(_prefs, sensors.node_lat, sensors.node_lon); +#if defined(RAK_4631) + // Only a verified image (or a fresh missing file) may permit GPS-only power. + // A failed load never replaces a stored shared policy with an unsafe cut. + if (!prefs_ready) _prefs.peripherals_shared = 1; + sensors.configureSharedPeripheralMode(_prefs.peripherals_shared != 0); +#endif #if defined(NRF52_PLATFORM) // A volatile primary filesystem is the final recovery mode after the @@ -3654,6 +3666,19 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, size_t reply_size) { if (!command || !reply || reply_size == 0) return false; while (*command == ' ') command++; +#if defined(RAK_4631) + // This helper is also reached only after normal remote-admin admission. + if (mesh::peripherals::handleCommand(_prefs.peripherals_shared, command, + reply, reply_size, sensors.supportsSharedPeripheralMode(), + [this]() { return savePrefs(); })) return true; +#else + if (strcmp(command, "get peripherals.mode") == 0 + || (strncmp(command, "set peripherals.mode", 20) == 0 + && (command[20] == 0 || command[20] == ' ' || command[20] == '\t'))) { + snprintf(reply, reply_size, "Error: unsupported"); + return true; + } +#endif if (strcmp(command, "get public.key") == 0) { char public_key_hex[PUB_KEY_SIZE * 2 + 1]; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 35abbae7..65958999 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -117,6 +117,10 @@ public: uint8_t usb_debug_enabled = 0; // Appended opt-in private-message response: 0=off, 1=no, 2=yes. uint8_t lost_reply = 0; +#if defined(RAK_4631) + // A checked footer follows every existing on-flash field, never an insertion. + uint8_t peripherals_shared = 0; // applied at boot, not on a live CLI save +#endif private: class RadioPrefs : public CommonRadioPrefs { @@ -255,10 +259,15 @@ public: // untouched, and leave a normally constructed snapshot's adapters local. bool copyPersistedValuesFrom(const CompanionNodePrefs& source) { if (&source == this) return true; +#if defined(RAK_4631) + const auto& last_value = peripherals_shared; +#else + const auto& last_value = lost_reply; +#endif static_assert(sizeof(lost_reply) == sizeof(uint8_t), "The published lost reply preference is one byte"); const uintptr_t first = reinterpret_cast(&airtime_factor); - const uintptr_t last = reinterpret_cast(&lost_reply); + const uintptr_t last = reinterpret_cast(&last_value); if (last < first || last - first > sizeof(*this) - sizeof(lost_reply)) { return false; @@ -269,8 +278,13 @@ public: const uintptr_t object = reinterpret_cast(&values); const uintptr_t begin = reinterpret_cast(&values.airtime_factor); - const uintptr_t end = - reinterpret_cast(&values.lost_reply); + const uintptr_t end = reinterpret_cast( +#if defined(RAK_4631) + &values.peripherals_shared +#else + &values.lost_reply +#endif + ); const uintptr_t radio_begin = reinterpret_cast(&values.radio); const uintptr_t custom_begin = reinterpret_cast(&values.custom); // Subtractions happen only after the corresponding ordering checks. diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index a607c0fd..3f349b64 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -7,6 +7,9 @@ #include #include #include +#if defined(RAK_4631) +#include +#endif #include "CLICommandUtils.h" #ifdef WITH_RS232_BRIDGE #include "bridges/RS232PowerSettings.h" @@ -811,6 +814,9 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) { #endif _prefs->bridge_format = mesh::bridge::ESPNOW_FORMAT_WRAPPED; _prefs->gps_sync_interval_hours = 0; +#if defined(RAK_4631) + _prefs->peripherals_shared = 0; +#endif #ifdef WITH_MQTT_BRIDGE bool node_prefs_needs_migration = false; @@ -1017,6 +1023,10 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) { if (loaded) syncOtaConfigFromPrefs(); // persisted OTA policy/keys -> OtaContext (else keep safe defaults) #endif mesh::hilStartupTrace("prefs_apply_begin"); +#if defined(RAK_4631) + // Boot-time only. Later CLI saves must not cut a live peripheral/flash rail. + _sensors->configureSharedPeripheralMode(_prefs->peripherals_shared != 0); +#endif #if MESH_USB_LOGGING_AVAILABLE mesh::setUsbDebugEnabled(_prefs->usb_debug_enabled != 0); mesh::setUsbLoggingEnabled(_prefs->usb_logging_enabled != 0); @@ -1057,6 +1067,10 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->gps_sync_interval_hours = 0; _prefs->ota_channel = 0; _prefs->rs232_bridge_enabled = 0; +#if defined(RAK_4631) + // Failure to read an existing policy cannot authorize a shared-rail cut. + _prefs->peripherals_shared = 1; +#endif #ifdef WITH_RS232_BRIDGE _prefs->rs232_heartbeat = 1; _prefs->rs232_autosleep = 1; @@ -1753,6 +1767,10 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { &_prefs->rx_ps_rx_us, &_prefs->rx_ps_sleep_us); // retune level-based timings to the loaded SF/BW + // Read the footer independently of optional legacy UART tails/profile. +#if defined(RAK_4631) + _prefs->peripherals_shared = mesh::peripherals::readCommonMode(file); +#endif file.close(); } } @@ -1918,6 +1936,11 @@ static bool writeCommonPrefsImage(Writer& writer, NodePrefs* prefs) { prefs->rs232_idle_seconds); WRITE_COMMON_PREFS_BYTES(power_tail, sizeof(power_tail)); #endif +#if defined(RAK_4631) + uint8_t peripheral_footer[mesh::peripherals::FooterSize]; + mesh::peripherals::encodeFooter(peripheral_footer, prefs->peripherals_shared); + WRITE_COMMON_PREFS_BYTES(peripheral_footer, sizeof(peripheral_footer)); +#endif #undef WRITE_COMMON_PREFS_BYTES #undef WRITE_COMMON_PREFS @@ -2110,6 +2133,11 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) { _prefs->rs232_idle_seconds); file.write(power_tail, sizeof(power_tail)); #endif +#if defined(RAK_4631) + uint8_t peripheral_footer[mesh::peripherals::FooterSize]; + mesh::peripherals::encodeFooter(peripheral_footer, _prefs->peripherals_shared); + file.write(peripheral_footer, sizeof(peripheral_footer)); +#endif _common_save_succeeded = file.commit(); if (!_common_save_succeeded) { @@ -3451,7 +3479,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } else if (memcmp(command, "gps", 3) == 0) { LocationProvider * l = _sensors->getLocationProvider(); if (l != NULL) { - bool enabled = l->isEnabled(); // is EN pin on ? + bool enabled = _sensors->isGpsPowered(); // physical rail/enable policy bool fix = l->isValid(); // has fix ? int sats = l->satellitesCount(); const char* gps_setting = _sensors->getSettingByKey("gps"); @@ -3737,6 +3765,17 @@ bool CommonCLI::handleSdCardGetCmd(const char* config, char* reply) { void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* reply) { const char* config = &command[4]; +#if defined(RAK_4631) + if (mesh::peripherals::handleCommand(_prefs->peripherals_shared, command, + reply, 160, _sensors->supportsSharedPeripheralMode(), + [this]() { return trySavePrefs(); })) return; +#else + if (strncmp(config, "peripherals.mode", 16) == 0 + && (config[16] == 0 || config[16] == ' ' || config[16] == '\t')) { + strcpy(reply, "Error: unsupported"); + return; + } +#endif if (strncmp(config, "repeat.trace", 12) == 0 && (config[12] == 0 || config[12] == ' ' || config[12] == '\t')) { if (strcmp(_callbacks->getRole(), "repeater") != 0) { @@ -5280,6 +5319,16 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* reply) { const char* config = &command[4]; +#if defined(RAK_4631) + if (mesh::peripherals::handleCommand(_prefs->peripherals_shared, command, + reply, 160, _sensors->supportsSharedPeripheralMode(), + [this]() { return trySavePrefs(); })) return; +#else + if (strcmp(config, "peripherals.mode") == 0) { + strcpy(reply, "Error: unsupported"); + return; + } +#endif if (strncmp(config, "repeat.trace", 12) == 0 && (config[12] == 0 || config[12] == ' ' || config[12] == '\t')) { if (strcmp(_callbacks->getRole(), "repeater") != 0) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 72fcf1c2..8025285d 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -278,6 +278,10 @@ public: uint8_t flood_retry_group_max_path = 0; // PAYLOAD_TYPE_GRP_DATA retry path gate; 0xFF = use only the general gate uint8_t rx_watchdog_enabled = mesh::RepeaterRadioTiming::DEFAULT_RX_WATCHDOG_ENABLED; uint8_t system_watchdog_enabled = 0; // nRF52 main-loop hardware watchdog (boolean; default on) +#if defined(RAK_4631) + // Checked append-only file footer; never shifts existing preference offsets. + uint8_t peripherals_shared = 0; // 0=GPS-only, 1=shared; applied only at boot +#endif private: class RadioPrefs : public CommonRadioPrefs { diff --git a/src/helpers/PeripheralModePrefs.h b/src/helpers/PeripheralModePrefs.h new file mode 100644 index 00000000..07ee5fff --- /dev/null +++ b/src/helpers/PeripheralModePrefs.h @@ -0,0 +1,103 @@ +#pragma once + +#include +#include +#include +#include + +namespace mesh { +namespace peripherals { + +// RAK4631 only: append this checked policy after every previously published +// preference byte. Missing historical policy means GPS-only; an unreadable or +// damaged new policy must retain the shared supply instead of removing power. +static constexpr size_t FooterSize = 8; +static constexpr size_t CommonLegacySize = 877; +static constexpr size_t LegacyRs232TailSize = 10; + +inline uint16_t checksum(const uint8_t* bytes, size_t length) { + uint16_t crc = 0xFFFF; + for (size_t i = 0; i < length; ++i) { + crc ^= static_cast(bytes[i]) << 8; + for (unsigned bit = 0; bit < 8; ++bit) + crc = (crc & 0x8000) ? static_cast((crc << 1) ^ 0x1021) + : static_cast(crc << 1); + } + return crc; +} + +inline void encodeFooter(uint8_t out[FooterSize], uint8_t shared) { + out[0] = 'P'; out[1] = 'M'; out[2] = 'O'; out[3] = 1; + out[4] = shared == 0 ? 0 : 1; + out[5] = 0; + const uint16_t crc = checksum(out, 6); + out[6] = static_cast(crc >> 8); + out[7] = static_cast(crc); +} + +inline bool decodeFooter(const uint8_t* in, size_t size, uint8_t& shared) { + if (!in || size != FooterSize || in[0] != 'P' || in[1] != 'M' + || in[2] != 'O' || in[3] != 1 || in[4] > 1 || in[5] != 0 + || checksum(in, 6) != static_cast((in[6] << 8) | in[7])) + return false; + shared = in[4]; + return true; +} + +template uint8_t readCommonMode(File& file) { + const size_t size = file.size(); + if (size <= CommonLegacySize) return 0; + // A historical RS232-only image has no peripheral policy. This check is + // independent of the current profile: UART -> no-UART upgrades must work. + if (size == CommonLegacySize + LegacyRs232TailSize) { + uint8_t header[4] = {}; + if (!file.seek(CommonLegacySize) || file.read(header, sizeof(header)) != sizeof(header)) + return 1; + if (header[0] == 'R' && header[1] == 'S' && header[2] == 'P' && header[3] == 1) + return 0; + } + uint8_t footer[FooterSize] = {}; + uint8_t shared = 1; + if (size < FooterSize || !file.seek(size - FooterSize) + || file.read(footer, sizeof(footer)) != sizeof(footer) + || !decodeFooter(footer, sizeof(footer), shared)) return 1; + return shared; +} + +template +bool handleCommand(uint8_t& shared, const char* command, char* reply, + size_t capacity, bool supported, Save save) { + if (!command || !reply || capacity == 0) return false; + const bool query = strcmp(command, "get peripherals.mode") == 0; + static const char prefix[] = "set peripherals.mode"; + const bool setter = strncmp(command, prefix, sizeof(prefix) - 1) == 0 + && (command[sizeof(prefix) - 1] == 0 || command[sizeof(prefix) - 1] == ' ' + || command[sizeof(prefix) - 1] == '\t'); + if (!query && !setter) return false; + if (!supported) { + snprintf(reply, capacity, "Error: peripheral mode unsupported"); + return true; + } + if (query) { + snprintf(reply, capacity, "> %s (saved; applied at boot)", shared ? "shared" : "gps-only"); + return true; + } + const char* value = command + sizeof(prefix) - 1; + while (*value == ' ' || *value == '\t') ++value; + if (strcmp(value, "gps-only") != 0 && strcmp(value, "shared") != 0) { + snprintf(reply, capacity, "Error: use set peripherals.mode gps-only|shared"); + return true; + } + const uint8_t previous = shared; + shared = strcmp(value, "shared") == 0 ? 1 : 0; + if (!save()) { + shared = previous; + snprintf(reply, capacity, "Error: peripheral mode could not be saved"); + } else { + snprintf(reply, capacity, "OK - %s saved; reboot to apply", value); + } + return true; +} + +} // namespace peripherals +} // namespace mesh diff --git a/src/helpers/SensorManager.h b/src/helpers/SensorManager.h index a6ba2893..75ce527d 100644 --- a/src/helpers/SensorManager.h +++ b/src/helpers/SensorManager.h @@ -162,6 +162,13 @@ public: return false; } virtual LocationProvider* getLocationProvider() { return NULL; } + // WisBlock slot power is a boot-time choice, separate from logical GPS on/off. + virtual bool supportsSharedPeripheralMode() const { return false; } + virtual void configureSharedPeripheralMode(bool shared) { (void)shared; } + virtual bool isGpsPowered() const { + LocationProvider* location = const_cast(this)->getLocationProvider(); + return location != NULL && location->isEnabled(); + } virtual void setPowerSavingEnabled(bool enabled) { powersaving_enabled = enabled; } // Runtime bridges may temporarily own the UART used by a GPS receiver. A // sensor manager that actually detected a GPS on that UART overrides these diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index a1397ea0..5791d675 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -318,6 +318,16 @@ static bool serialGPSFlag = false; #include static SFE_UBLOX_GNSS ublox_GNSS; +static bool configureRakUbloxReceiver() { + // Retain the receiver's supported constellation defaults/BBR selection. + // ZOE-M8Q cannot run GPS/Galileo/GLONASS/BeiDou all at once, and blindly + // enabling every constellation ignores CFG-GNSS constraints/errata. Only + // require the transport and cadence we actually consume after a cold wake. + // Do not repeatedly write receiver nonvolatile storage on wake. + return ublox_GNSS.setI2COutput(COM_TYPE_UBX) + && ublox_GNSS.setMeasurementRate(1000); +} + #ifndef RAK_UART_GPS_PROBE_TIMEOUT_MS #define RAK_UART_GPS_PROBE_TIMEOUT_MS 1200UL #endif @@ -334,8 +344,8 @@ static void setRakGpsControl(uint8_t pin, bool shared_power_rail, bool enabled) { pinMode(pin, OUTPUT); if (shared_power_rail) { - // WB_IO2 controls every 3V3_S peripheral, not just the GPS. It must never - // be dropped as a GPS power-saving operation. + // Discovery cannot drop this shared rail. The manager's frozen ownership + // policy, not a provider's enable pin, handles later GPS-only shutdown. digitalWrite(pin, HIGH); return; } @@ -343,6 +353,14 @@ static void setRakGpsControl(uint8_t pin, bool shared_power_rail, digitalWrite(pin, enabled ? active : static_cast(!active)); } +static void stopRakGpsUart() { + Serial1.end(); + // UARTE teardown disconnects PSEL but does not restore GPIO configuration. + // Remove output drive and pulls so serial pads cannot back-power an off GPS. + pinMode(PIN_GPS_TX, INPUT); + pinMode(PIN_GPS_RX, INPUT); +} + static bool serialHasValidGpsSentence(Stream& serial, uint32_t timeout_ms) { mesh::NmeaSentenceProbe probe; const uint32_t started = millis(); @@ -956,6 +974,10 @@ static const size_t SENSOR_TABLE_SIZE = (sizeof(SENSOR_TABLE) / sizeof(SENSOR_TA bool EnvironmentSensorManager::begin() { _active_sensor_count = 0; +#if defined(RAK_BOARD) && !defined(RAK_WISMESH_TAG) + _wisblock_power.setDiscoveryActive(true); + applyWisBlockRailPower(); +#endif #if ENV_INCLUDE_GPS #ifdef RAK_WISBLOCK_GPS @@ -973,7 +995,7 @@ bool EnvironmentSensorManager::begin() { #ifdef NRF52_PLATFORM if (!isValidNrfI2cPinPair(ENV_PIN_SDA, ENV_PIN_SCL)) { MESH_DEBUG_PRINTLN("Second I2C skipped: invalid SDA/SCL pin pair"); - return true; + return finishSensorDiscovery(); } Wire1.setPins(static_cast(ENV_PIN_SDA), static_cast(ENV_PIN_SCL)); @@ -987,13 +1009,13 @@ bool EnvironmentSensorManager::begin() { // Avoid touching a shared I2C bus when no environmental drivers were built. if (SENSOR_TABLE_SIZE == 0) { - return true; + return finishSensorDiscovery(); } if (!ensureI2cBusReleased(TELEM_WIRE)) { MESH_DEBUG_PRINTLN( "I2C discovery skipped: bus pins unavailable or SDA/SCL remained low"); - return true; + return finishSensorDiscovery(); } // Probe only unique addresses compiled into this image before invoking any @@ -1054,7 +1076,7 @@ bool EnvironmentSensorManager::begin() { } } - return true; + return finishSensorDiscovery(); } // ============================================================ @@ -1070,6 +1092,10 @@ bool EnvironmentSensorManager::querySensors(uint8_t requester_permissions, Cayen queryGpsTelemetry(requester_permissions, telemetry); #endif +#if defined(RAK_BOARD) && !defined(RAK_WISMESH_TAG) + if (!_wisblock_power.railEnabled()) return true; +#endif + if (requester_permissions & TELEM_PERM_ENVIRONMENT) { for (int i = 0; i < _active_sensor_count; i++) { _active_sensors[i].query(next_available_channel, _active_sensors[i].sub_channel, telemetry); @@ -1095,6 +1121,9 @@ uint8_t EnvironmentSensorManager::getVoltageSensorChannels( uint8_t EnvironmentSensorManager::queryVoltageSensors( VoltageSensorReading readings[], uint8_t capacity) { +#if defined(RAK_BOARD) && !defined(RAK_WISMESH_TAG) + if (!_wisblock_power.railEnabled()) return 0; +#endif uint8_t count = 0; for (int i = 0; i < _active_sensor_count && count < capacity; i++) { const ActiveSensor& sensor = _active_sensors[i]; @@ -1149,17 +1178,6 @@ bool EnvironmentSensorManager::setSettingValue(const char* name, const char* val gps_discovery_preference_known = true; #endif bool enabled = strcmp(value, "0") != 0; -#if defined(RAK_WISBLOCK_GPS) && defined(FORCE_GPS_ALIVE) - // A UART L76K cannot remove power from the shared 3V3_S rail. "Off" stops - // parsing and releases the MCU UART, but the still-powered receiver keeps - // driving its TX pin. It therefore does not make that pin safe for an - // external UART bridge. - if (serialGPSFlag) { - _location->setGPSPowerSaving(false); - setGpsTelemetryUserEnabled(enabled); - return true; - } -#endif bool was_active = gps_active; _location->setGPSPowerSaving(enabled && powersaving_enabled); setGpsTelemetryUserEnabled(enabled); @@ -1183,17 +1201,6 @@ void EnvironmentSensorManager::setPowerSavingEnabled(bool enabled) { return; } bool gps_user_enabled = isGpsTelemetryUserEnabled(); -#if defined(RAK_WISBLOCK_GPS) && defined(FORCE_GPS_ALIVE) - if (serialGPSFlag) { - _location->setGPSPowerSaving(false); - if (gps_user_enabled) { - if (!gps_active) start_gps(); - } else if (gps_active) { - stop_gps(); - } - return; - } -#endif _location->setGPSPowerSaving(enabled && gps_user_enabled); if (!gps_user_enabled) return; @@ -1327,6 +1334,67 @@ void EnvironmentSensorManager::initBasicGPS() { #endif } +bool EnvironmentSensorManager::supportsSharedPeripheralMode() const { +#if defined(RAK_4631) && !defined(RAK_WISMESH_TAG) + return true; +#else + return false; +#endif +} + +#if defined(RAK_BOARD) && !defined(RAK_WISMESH_TAG) +void EnvironmentSensorManager::applyWisBlockRailPower() { + pinMode(WB_IO2, OUTPUT); + digitalWrite(WB_IO2, _wisblock_power.railEnabled() ? HIGH : LOW); +} + +uint8_t EnvironmentSensorManager::detectWisBlockStorageHolds() { + uint8_t holds = 0; +#if defined(OTA_RAK_AUTO_STORE) + uint32_t rak_id = 0, w25_id = 0; + const uint8_t selected = mesh::ota::OtaStoreQspiNrf52::autoDetect(); + mesh::ota::OtaStoreQspiNrf52::autoProbeIds(rak_id, w25_id); + // Unsupported but responding NOR parts must not lose their supply either. + // All-zero/all-one reads are the probe's absent-chip values. + if (selected != 0 || (rak_id != 0 && rak_id != 0xFFFFFFUL) + || (w25_id != 0 && w25_id != 0xFFFFFFUL)) { + holds |= mesh::gps::ExternalStorage; + } +#elif defined(OTA_QSPI_SHARED_WISBLOCK_SPI) || defined(OTA_SD_STORE) + holds |= mesh::gps::ExternalStorage; +#endif + return holds; +} +#endif + +void EnvironmentSensorManager::configureSharedPeripheralMode(bool shared) { +#if defined(RAK_BOARD) && !defined(RAK_WISMESH_TAG) + // Called once after boot discovery and validated preference loading. Live + // setters only save the next-boot choice; they never revoke active owners. + if (!_wisblock_power.configure(shared ? mesh::gps::WisBlockPeripheralMode::Shared + : mesh::gps::WisBlockPeripheralMode::GpsOnly, + detectWisBlockStorageHolds())) return; + _wisblock_power.setGpsActive(gps_active); + // Companion loads preferences before sensor discovery, whereas repeaters + // load them afterwards. Only finishSensorDiscovery releases that interlock. + applyWisBlockRailPower(); +#else + (void)shared; +#endif +} + +bool EnvironmentSensorManager::isGpsPowered() const { +#ifdef RAK_WISBLOCK_GPS +#if defined(PIN_GPS_EN) && PIN_GPS_EN >= 0 + return digitalRead(PIN_GPS_EN) == rakGpsControlActiveLevel(); +#else + return digitalRead(WB_IO2) == HIGH; +#endif +#else + return SensorManager::isGpsPowered(); +#endif +} + // gps code for rak might be moved to MicroNMEALoactionProvider // or make a new location provider ... #ifdef RAK_WISBLOCK_GPS @@ -1382,27 +1450,21 @@ void EnvironmentSensorManager::rakGPSInit() { #endif if (gpsIsAwake(gps_control_pin, shared_power_rail)) { - _location->setPinEn(gps_control_pin); + // WB_IO2 is not an independently owned GPS pin. A provider must never + // lower it during reset/begin/stop behind the shared-power policy's back. + _location->setPinEn(shared_power_rail ? -1 : gps_control_pin); + if (i2cGPSFlag) stopRakGpsUart(); } else { MESH_DEBUG_PRINTLN("No GPS found"); gps_active = false; gps_detected = false; - Serial1.end(); + stopRakGpsUart(); return; } -#ifdef FORCE_GPS_ALIVE - if (i2cGPSFlag) { - // The u-blox receiver can sleep while the shared rail remains available. - stop_gps(); - } else if (serialGPSFlag) { - // The UART L76K cannot be isolated from the RAK3401 radio power rail. - setGpsTelemetryUserEnabled(true); - } -#else - // Now that GPS is found and set up, set it to sleep for the initial state. - stop_gps(); -#endif + // Keep discovery power available for the remaining slot probes. Loading the + // saved mode and GPS preference later performs the initial stop, without + // ever manufacturing a GPS-on preference from a successful UART probe. } bool EnvironmentSensorManager::gpsIsAwake(uint8_t ioPin, @@ -1467,17 +1529,9 @@ bool EnvironmentSensorManager::gpsIsAwake(uint8_t ioPin, // After two successful non-INA manufacturer reads, use the u-blox-specific // probe. No software can make two physical devices at one address safe. if (probe_i2c_gps - && ublox_GNSS.begin(Wire, gps_i2c_address) == true) { + && ublox_GNSS.begin(Wire, gps_i2c_address) == true + && configureRakUbloxReceiver()) { MESH_DEBUG_PRINTLN("RAK12500 GPS init correctly with pin %i", ioPin); - ublox_GNSS.setI2COutput(COM_TYPE_UBX); - ublox_GNSS.enableGNSS(true, SFE_UBLOX_GNSS_ID_GPS); - ublox_GNSS.enableGNSS(true, SFE_UBLOX_GNSS_ID_GALILEO); - ublox_GNSS.enableGNSS(true, SFE_UBLOX_GNSS_ID_GLONASS); - ublox_GNSS.enableGNSS(true, SFE_UBLOX_GNSS_ID_SBAS); - ublox_GNSS.enableGNSS(true, SFE_UBLOX_GNSS_ID_BEIDOU); - ublox_GNSS.enableGNSS(true, SFE_UBLOX_GNSS_ID_IMES); - ublox_GNSS.enableGNSS(true, SFE_UBLOX_GNSS_ID_QZSS); - ublox_GNSS.setMeasurementRate(1000); ublox_GNSS.saveConfigSelective(VAL_CFG_SUBSEC_IOPORT); gpsResetPin = ioPin; i2cGPSFlag = true; @@ -1506,6 +1560,40 @@ void EnvironmentSensorManager::armGpsPowerSavingCycle() { void EnvironmentSensorManager::start_gps() { if (gps_active || gps_serial_transport_blocked) return; +#ifdef RAK_WISBLOCK_GPS + const bool was_powered = isGpsPowered(); + _wisblock_power.setGpsActive(true); + applyWisBlockRailPower(); +#if defined(PIN_GPS_EN) && PIN_GPS_EN >= 0 + setRakGpsControl(PIN_GPS_EN, false, true); +#endif + // Supply first, UART second. A cold receiver must settle before its bus or + // serial transport is used, including a wake after full GPS-only shutdown. + if (!was_powered) delay(500); + if (i2cGPSFlag) { + if (!was_powered) { + if (!ensureI2cBusReleased(&Wire) + || !ublox_GNSS.begin(Wire, TELEM_RAK12500_ADDRESS) + || !configureRakUbloxReceiver()) { + // Do not spin on a failed cold wake on every mesh loop. Keep saved + // intent, fail this receiver closed, and allow rediscovery on reboot. + gps_detected = false; + _wisblock_power.setGpsActive(false); + applyWisBlockRailPower(); +#if defined(PIN_GPS_EN) && PIN_GPS_EN >= 0 + setRakGpsControl(PIN_GPS_EN, false, false); +#endif + _location->stopTimeSync(); + MESH_DEBUG_PRINTLN("RAK12500 cold wake failed; GPS rediscovery needs reboot"); + return; + } + } else { + // Shared mode retains the historical u-blox internal PSM. This is not + // a promise of full rail shutdown, nor does it silence a UART receiver. + ublox_GNSS.powerSaveMode(false); + } + } +#endif if (gps_serial_transport) { Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX); #ifdef GPS_BAUD_RATE @@ -1516,13 +1604,7 @@ void EnvironmentSensorManager::start_gps() { } gps_active = true; #ifdef RAK_WISBLOCK_GPS -#ifdef FORCE_GPS_ALIVE - // The UART L76K has no power-save command. Keep the shared rail enabled; - // an I2C u-blox receiver can also be explicitly returned to full power. - if (i2cGPSFlag) ublox_GNSS.powerSaveMode(false); -#else - setRakGpsControl(gpsResetPin, false, true); -#endif + _location->clearPositionFix(); #else _location->begin(); _location->reset(); @@ -1546,18 +1628,17 @@ void EnvironmentSensorManager::stop_gps() { } #ifdef RAK_WISBLOCK_GPS -#ifdef FORCE_GPS_ALIVE - // Keep the shared rail alive for the RAK3401 radio FEM. The I2C u-blox can - // still enter its internal power-save mode. Ending Serial1 stops the MCU - // parser, but a UART L76K remains powered and continues driving its TX pin. - if (i2cGPSFlag) { - ublox_GNSS.powerSaveMode(true); - } else if (serialGPSFlag) { - Serial1.end(); - } + // End the MCU UART before removing receiver power, never the reverse. + if (serialGPSFlag) stopRakGpsUart(); + _wisblock_power.setGpsActive(false); +#if defined(PIN_GPS_EN) && PIN_GPS_EN >= 0 + setRakGpsControl(PIN_GPS_EN, false, false); #else - setRakGpsControl(gpsResetPin, false, false); + if (i2cGPSFlag && _wisblock_power.railEnabled()) { + ublox_GNSS.powerSaveMode(true); + } #endif + applyWisBlockRailPower(); #else _location->stop(); #endif @@ -1603,11 +1684,10 @@ bool EnvironmentSensorManager::gpsSerialTransportMayConflict( bool EnvironmentSensorManager::gpsSerialTransportCanYield(uint8_t uart) const { #if ENV_INCLUDE_GPS if (!gpsUsesSerialUart(uart)) return false; -#if defined(RAK_WISBLOCK_GPS) && defined(FORCE_GPS_ALIVE) - // RAK12501 exposes no independently controlled standby/power pin. WB_IO2 is - // the shared 3V3_S rail and must remain high, so its L76K keeps transmitting - // NMEA after Serial1.end(). Two TX drivers on UART1 would electrically - // contend; require UART2 or physical removal/isolation of this GPS instead. +#if defined(RAK_WISBLOCK_GPS) && !(defined(PIN_GPS_EN) && PIN_GPS_EN >= 0) + // WB_IO2 is not independent isolation even in GPS-only mode: a bridge or + // future GPS request can repower the same rail. Reserve UART1 to avoid two + // TX drivers; use UART2 or a physically independent GPS enable instead. return false; #elif defined(RAK_WISBLOCK_GPS) // This profile is allowed to remove power with its validated GPS control. @@ -1637,7 +1717,9 @@ bool EnvironmentSensorManager::setGpsSerialTransportBlocked(uint8_t uart, if (gps_discovery_pending) _location->stop(); #endif if (gps_active) stop_gps(); +#ifndef RAK_WISBLOCK_GPS Serial1.end(); +#endif } else { gps_serial_transport_blocked = false; #if (defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM)) && !defined(RAK_WISBLOCK_GPS) @@ -1722,6 +1804,9 @@ void EnvironmentSensorManager::loop() { } #endif #if ENV_INCLUDE_BME680_BSEC +#if defined(RAK_BOARD) && !defined(RAK_WISMESH_TAG) + if (!_wisblock_power.railEnabled()) return; +#endif if (bsec_active && bsec_iaq.run()) { uint8_t prev_accuracy = bsec_accuracy; bsec_temperature = bsec_iaq.temperature; diff --git a/src/helpers/sensors/EnvironmentSensorManager.h b/src/helpers/sensors/EnvironmentSensorManager.h index 9f3fccc4..bfb94aa5 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.h +++ b/src/helpers/sensors/EnvironmentSensorManager.h @@ -3,6 +3,9 @@ #include #include #include +#if defined(RAK_BOARD) && !defined(RAK_WISMESH_TAG) +#include "WisBlockSharedPowerPolicy.h" +#endif class EnvironmentSensorManager : public SensorManager { protected: @@ -24,6 +27,28 @@ protected: bool gps_active = false; bool gps_serial_transport = false; bool gps_serial_transport_blocked = false; +#if defined(RAK_BOARD) && !defined(RAK_WISMESH_TAG) + mesh::gps::WisBlockSharedPowerPolicy _wisblock_power; + bool _wisblock_discovery_completed = false; + void applyWisBlockRailPower(); + uint8_t detectWisBlockStorageHolds(); +#endif + bool finishSensorDiscovery() { +#if defined(RAK_BOARD) && !defined(RAK_WISMESH_TAG) +#if ENV_INCLUDE_GPS + // A repeated probe must not override the loaded manual preference or an + // established telemetry demand. Initial boot waits for preference load. + if (_wisblock_discovery_completed && _wisblock_power.isConfigured() && gps_active + && !isGpsTelemetryUserEnabled() + && !gpsTelemetryReceiverRequired(millis())) stop_gps(); +#endif + _wisblock_power.setGpsActive(gps_active); + _wisblock_discovery_completed = true; + _wisblock_power.setDiscoveryActive(false); + applyWisBlockRailPower(); +#endif + return true; + } #if ENV_INCLUDE_GPS #if (defined(ESP32_PLATFORM) || defined(NRF52_PLATFORM)) \ @@ -75,6 +100,9 @@ public: const char* getSettingValue(int i) const override; bool setSettingValue(const char* name, const char* value) override; void setPowerSavingEnabled(bool enabled) override; + bool supportsSharedPeripheralMode() const override; + void configureSharedPeripheralMode(bool shared) override; + bool isGpsPowered() const override; bool gpsUsesSerialUart(uint8_t uart) const override; bool gpsSerialTransportMayConflict(uint8_t uart) const override; bool gpsSerialTransportCanYield(uint8_t uart) const override; diff --git a/src/helpers/sensors/WisBlockSharedPowerPolicy.h b/src/helpers/sensors/WisBlockSharedPowerPolicy.h new file mode 100644 index 00000000..b89b60f7 --- /dev/null +++ b/src/helpers/sensors/WisBlockSharedPowerPolicy.h @@ -0,0 +1,108 @@ +#pragma once + +#include + +namespace mesh { +namespace gps { + +enum class WisBlockPeripheralMode : uint8_t { + GpsOnly = 0, + Shared = 1, + SharedPeripherals = Shared, +}; + +enum WisBlockRailHold : uint8_t { + RadioPower = 1U << 0, + EthernetPower = 1U << 1, + ExternalStorage = 1U << 2, +}; + +// These are hardware dependencies, not whether the corresponding service is +// enabled at runtime. In particular, removing Ethernet power can brown out a +// PoE-powered core even when its network service is disabled. +constexpr uint8_t wisBlockCompileRailHolds() { + return 0 +#if defined(RAK_3401) + | RadioPower +#endif +#if defined(ETHERNET_ENABLED) || defined(ETHERNET_USE_RAK13800) \ + || defined(RAK4631_COMBINED_ETHERNET) + | EthernetPower +#endif + ; +} + +// No GPIO, bus probes, allocation, or persistence belongs in this policy. +// Integrators must finish their read-only external-storage probe while the +// discovery hold is set, then freeze its result together with the saved mode. +// A powered/shared GNSS UART still cannot be safely handed to another driver. +class WisBlockSharedPowerPolicy { + public: + explicit WisBlockSharedPowerPolicy(uint8_t initial_holds = 0) + : _holds(static_cast(initial_holds | wisBlockCompileRailHolds())), + _state(DiscoveryActive) {} + + // Configuration is boot-only. Reject repeated or invalid configuration + // without changing the established owners; changing mode requires reboot. + bool configure(WisBlockPeripheralMode mode, uint8_t detected_holds = 0) { + if (isConfigured() + || (mode != WisBlockPeripheralMode::GpsOnly + && mode != WisBlockPeripheralMode::Shared) + || (detected_holds & static_cast(~KnownHolds)) != 0) { + return false; + } + _holds = static_cast(_holds | detected_holds); + _state = static_cast(_state | Configured); + if (mode == WisBlockPeripheralMode::Shared) _state |= SharedMode; + return true; + } + + void setDiscoveryActive(bool active) { setFlag(DiscoveryActive, active); } + void setGpsActive(bool active) { setFlag(GpsActive, active); } + + bool isConfigured() const { return (_state & Configured) != 0; } + bool discoveryActive() const { return (_state & DiscoveryActive) != 0; } + bool gpsActive() const { return (_state & GpsActive) != 0; } + WisBlockPeripheralMode mode() const { + return (_state & SharedMode) != 0 ? WisBlockPeripheralMode::Shared + : WisBlockPeripheralMode::GpsOnly; + } + uint8_t holds() const { return _holds; } + + // Permission to remove GPS power, independent of its current requested + // state. This is not proof that a GNSS TX pin has already become isolated. + bool gpsPowerIndependentlyControllable() const { + return isConfigured() && !discoveryActive() + && mode() == WisBlockPeripheralMode::GpsOnly && _holds == 0; + } + bool canPowerDown() const { + return gpsPowerIndependentlyControllable() && !gpsActive(); + } + bool railEnabled() const { return !canPowerDown(); } + + private: + enum State : uint8_t { + Configured = 1U << 0, + SharedMode = 1U << 1, + GpsActive = 1U << 2, + DiscoveryActive = 1U << 3, + }; + static constexpr uint8_t KnownHolds = + RadioPower | EthernetPower | ExternalStorage; + + // Retain unknown constructor hold bits fail-closed. They must never become + // permission to cut power merely because a future owner is not recognized. + uint8_t _holds; + uint8_t _state; + + void setFlag(uint8_t flag, bool enabled) { + if (enabled) _state |= flag; + else _state &= static_cast(~flag); + } +}; + +static_assert(sizeof(WisBlockSharedPowerPolicy) == 2, + "WisBlock rail ownership must stay allocation-free and compact"); + +} // namespace gps +} // namespace mesh diff --git a/test/fixtures/rak_gps_power.cpp b/test/fixtures/rak_gps_power.cpp new file mode 100644 index 00000000..c6e13084 --- /dev/null +++ b/test/fixtures/rak_gps_power.cpp @@ -0,0 +1,444 @@ +#include +#include +#include +#include +#include +#include +#include + +#define WB_IO2 21 +#define PIN_GPS_TX 15 +#define PIN_GPS_RX 16 +#define TELEM_RAK12500_ADDRESS 0x42 +#define RAK_UART_GPS_PROBE_TIMEOUT_MS 1200UL +#define TELEM_WIRE (&Wire) +#define MESH_DEBUG_PRINTLN(...) ((void)0) +#define POWERSAVING_DEBUG_PRINTLN(...) ((void)0) +#define COM_TYPE_UBX 1 +#define SFE_UBLOX_GNSS_ID_GPS 0 +#define SFE_UBLOX_GNSS_ID_GALILEO 1 +#define SFE_UBLOX_GNSS_ID_GLONASS 2 +#define SFE_UBLOX_GNSS_ID_SBAS 3 +#define SFE_UBLOX_GNSS_ID_BEIDOU 4 +#define SFE_UBLOX_GNSS_ID_IMES 5 +#define SFE_UBLOX_GNSS_ID_QZSS 6 +#define VAL_CFG_SUBSEC_IOPORT 1 + +void LocationProvider::sendSentence(const char*) {} +static uint32_t gpsResetPin = static_cast(-1); +static bool i2cGPSFlag = false, serialGPSFlag = false; +static const char meshcore_capability_rak_wisblock_gps[] = "linked-provider"; + +struct SerialMock : Stream { + unsigned begins=0, ends=0, polls=0; + bool installed=false; + std::deque incoming; + void setPins(int tx,int rx) { event("uartPins",tx,rx); } + void begin(unsigned baud) { installed=true; ++begins; event("uartBegin",baud); } + void end() { assert(installed&&"pinned UARTE end must not be called twice"); installed=false; ++ends; event("uartEnd"); } + int available() override { ++polls; return installed ? int(incoming.size()) : 0; } + int read() override { if(incoming.empty()) return -1; int c=incoming.front(); incoming.pop_front(); return c; } + void nmea() { + const char* body="GPRMC,valid"; + uint8_t checksum=0; + for(const char* c=body;*c;++c) checksum^=uint8_t(*c); + char sentence[64]; std::snprintf(sentence,sizeof(sentence),"$%s*%02X\r\n",body,checksum); + for(const char* c=sentence;*c;++c) incoming.push_back(uint8_t(*c)); + } +} Serial1; + +namespace mesh { namespace ota { +struct OtaStoreQspiNrf52 { + inline static uint8_t selected=0; + inline static uint32_t rak_id=0,w25_id=0; + inline static bool header_present=false; + inline static bool cached=false; + inline static unsigned probes=0; + static bool headerW25Detected() { autoDetect(); event("headerProbe"); return header_present; } + static uint8_t autoDetect() { if(!cached) { cached=true; ++probes; event("norProbe"); assert(digitalRead(WB_IO2)==HIGH); } return selected; } + static void autoProbeIds(uint32_t& rak,uint32_t& w25) { autoDetect(); rak=rak_id; w25=w25_id; } +}; +}} + +static bool bus_available=true; +static bool ensureI2cBusReleased(TwoWire*) { event("i2cBus"); return bus_available; } +struct GnssMock { + bool present=false, begin_ok=true, output_ok=true, rate_ok=true, psm_ok=true, valid=false; + unsigned begins=0, polls=0, flushes=0, output_calls=0, rate_calls=0, nv_writes=0; + std::vector psm; + bool begin(TwoWire&,uint8_t address) { ++begins; event("gnssBegin",address); return present&&begin_ok; } + bool powerSaveMode(bool enabled) { psm.push_back(enabled); event("gnssPsm",enabled); return psm_ok; } + bool setI2COutput(unsigned output) { ++output_calls; event("gnssOutput",output); return output_ok; } + void enableGNSS(bool,unsigned) {} + bool setMeasurementRate(unsigned rate) { ++rate_calls; event("gnssRate",rate); return rate_ok; } + void saveConfigSelective(unsigned) { ++nv_writes; } + void flushPVT() { ++flushes; } + bool getPVT(unsigned) { ++polls; return valid; } + bool getGnssFixOk(unsigned) { ++polls; return valid; } + long getLatitude(unsigned) { return 476100000; } + long getLongitude(unsigned) { return -1223300000; } + long getAltitude(unsigned) { return 125000; } + int getSIV(unsigned) { return valid?8:0; } + bool getDateValid(unsigned) { return valid; } + bool getTimeValid(unsigned) { return valid; } + long getUnixEpoch(unsigned) { return 1790942400; } +} ublox_GNSS; + +@HELPERS@ +@PROVIDER@ +static RAK12500LocationProvider RAK12500_provider; + +struct SensorDef { + uint32_t address; + const char* name; + uint8_t (*init)(TwoWire*,uint8_t); + void (*query)(uint8_t,uint8_t,CayenneLPP&); + bool (*query_voltage)(uint8_t,float&); +}; +static const SensorDef SENSOR_TABLE[1] = {}; +static const size_t SENSOR_TABLE_SIZE=0; +bool EnvironmentSensorManager::i2c_probe(TwoWire&,uint8_t) { return false; } +@METHODS@ + +struct TestLocation : LocationProvider { + unsigned loops=0,resets=0,clears=0; + int pin=-1; + mesh::RTCClock clock; + bool valid=false; + void reset() override { ++resets; valid=false; } + void begin() override {} + void stop() override {} + void loop() override { ++loops; } + bool isEnabled() override { return pin<0 || digitalRead(pin)==HIGH; } + bool clearPositionFix() override { valid=false; ++clears; return true; } + void setPinEn(int value) override { pin=value; } + int getPinEn() override { return pin; } + mesh::RTCClock* getRTCClock() override { return &clock; } + long getLatitude() override { return 47610000; } + long getLongitude() override { return -122330000; } + long getAltitude() override { return 125000; } + long satellitesCount() override { return valid?8:0; } + bool isValid() override { return valid; } + long getTimestamp() override { return 1790942400; } + void acceptedClock() { markTimeSyncApplied(); stopTimeSync(); } +}; + +struct TestEnvironment : EnvironmentSensorManager { + explicit TestEnvironment(TestLocation& location):EnvironmentSensorManager(location) {} + bool active() const { return gps_active; } + bool userEnabled() const { return isGpsTelemetryUserEnabled(); } + bool transportAvailable() const { return isGpsTelemetryTransportAvailable(); } + using EnvironmentSensorManager::start_gps; + using EnvironmentSensorManager::stop_gps; + using EnvironmentSensorManager::rakGPSInit; + using EnvironmentSensorManager::_wisblock_power; +}; + +static constexpr bool compile_held = mesh::gps::wisBlockCompileRailHolds()!=0; +static void fresh() { + now_ms=100; delay_count=0; hardware_events.clear(); + std::memset(pin_values,0,sizeof(pin_values)); pin_values[WB_IO2]=HIGH; + Serial1=SerialMock{}; ublox_GNSS=GnssMock{}; RAK12500_provider=RAK12500LocationProvider{}; + mesh::ota::OtaStoreQspiNrf52::selected=0; + mesh::ota::OtaStoreQspiNrf52::rak_id=mesh::ota::OtaStoreQspiNrf52::w25_id=0; + mesh::ota::OtaStoreQspiNrf52::header_present=false; + mesh::ota::OtaStoreQspiNrf52::cached=false; + mesh::ota::OtaStoreQspiNrf52::probes=0; + i2cGPSFlag=serialGPSFlag=false; gpsResetPin=uint32_t(-1); bus_available=true; +} +static size_t firstEvent(const char* name,int a=-999,int b=-999,size_t after=0) { + for(size_t i=after;i=started+(held?0:500)); + assert(location.clears==1&&location.pin<0); + sensors.setPowerSavingEnabled(false); now_ms+=600001; sensors.loop(); + assert(sensors.active()); // Manual on plus power-saving off is continuous. + hardware_events.clear(); assert(sensors.setSettingValue("gps","0")); + assert(!sensors.active()&&!Serial1.installed&&sensors.isGpsPowered()==held); + if(!held) requireUartReleasedBeforePowerOff(); + else requireNoLow(); + sensors.setPowerSavingEnabled(true); now_ms+=1000; sensors.loop(); + sensors.setPowerSavingEnabled(false); assert(!sensors.active()); + const unsigned saved_begins=Serial1.begins; + sensors.configureSharedPeripheralMode(!shared); // Boot-only mode cannot revoke/reconfigure owners. + assert(sensors.isGpsPowered()==held&&Serial1.begins==saved_begins); +} +static void hourly() { + fresh(); TestLocation location; TestEnvironment sensors(location); bootUart(sensors,location); + sensors.applyGpsTimeSyncInterval(1); sensors.setPowerSavingEnabled(true); + assert(sensors.setSettingValue("gps","1")); + assert(sensors.active()&&location.getGPSPowerSaving()); + assert(location.getNextGPSOff()!=0); + now_ms+=100; location.acceptedClock(); sensors.loop(); + assert(!sensors.active()&&!sensors.isGpsPowered()); + const uint32_t deadline=uint32_t(location.getNextGPSOn()); + assert(deadline==millis()+3600000U); + now_ms=deadline-1; sensors.loop(); assert(!sensors.active()); + now_ms=deadline; hardware_events.clear(); sensors.loop(); + assert(sensors.active()&&Serial1.installed); + assert(firstEvent("write",WB_IO2,HIGH)0); + } + } + } +} +static void i2c(bool shared,unsigned wake_failure=0) { + fresh(); TestLocation location; TestEnvironment sensors(location); ublox_GNSS.present=true; + assert(sensors.begin()); assert(sensors.isGPSDetected()&&sensors.active()&&!sensors.userEnabled()); + assert(i2cGPSFlag&&!serialGPSFlag&&sensors.getLocationProvider()==&RAK12500_provider); + assert(RAK12500_provider.getPinEn()<0); + sensors.configureSharedPeripheralMode(shared); + assert(sensors.setSettingValue("gps","0")); + const bool held=shared||compile_held; + assert(!sensors.active()&&sensors.isGpsPowered()==held); + if(held) { requireNoLow(); assert(ublox_GNSS.psm.back()); } + else assert(ublox_GNSS.psm.empty()); + const unsigned begins=ublox_GNSS.begins,polls=ublox_GNSS.polls; + now_ms+=1000; sensors.loop(); assert(ublox_GNSS.polls==polls); + hardware_events.clear(); const uint32_t started=millis(); + const unsigned writes=ublox_GNSS.nv_writes; + if(wake_failure==1) ublox_GNSS.begin_ok=false; + if(wake_failure==2) ublox_GNSS.output_ok=false; + if(wake_failure==3) ublox_GNSS.rate_ok=false; + if(wake_failure==4) bus_available=false; + assert(sensors.setSettingValue("gps","1")); + if(wake_failure) { + assert(!held&&!sensors.active()&&!sensors.isGPSDetected()&&!sensors.isGpsPowered()); + assert(sensors.userEnabled()); // Persisted intent is not silently rewritten after failure. + const unsigned failed_begins=ublox_GNSS.begins; + for(unsigned i=0;i<10;++i) { now_ms+=1000; sensors.loop(); } + assert(ublox_GNSS.begins==failed_begins&&ublox_GNSS.polls==polls); + assert(!sensors.setSettingValue("gps","1")); + return; + } + assert(ublox_GNSS.nv_writes==writes); // Cold duty wake must not write receiver flash/BBR again. + assert(sensors.active()&&sensors.isGpsPowered()); + if(held) { + assert(ublox_GNSS.begins==begins&&!ublox_GNSS.psm.back()); requireNoLow(); + } else { + assert(ublox_GNSS.begins==begins+1); + assert(firstEvent("write",WB_IO2,HIGH)=started+500); + } + assert(ublox_GNSS.flushes>0); // A cold wake never reuses a previous position as fresh. + assert(sensors.setSettingValue("gps","0")); + assert(!sensors.active()&&sensors.isGpsPowered()==held); +} +static void initialConfigFailure() { + for(unsigned failed_step:{1U,2U}) { + fresh(); TestLocation location; TestEnvironment sensors(location); ublox_GNSS.present=true; + if(failed_step==1) ublox_GNSS.output_ok=false; + else ublox_GNSS.rate_ok=false; + assert(sensors.begin()); + assert(!sensors.isGPSDetected()&&!sensors.active()&&!i2cGPSFlag&&!serialGPSFlag); + assert(sensors.getLocationProvider()==&location&&!Serial1.installed); + assert(ublox_GNSS.output_calls==1&&ublox_GNSS.rate_calls==(failed_step==1?0U:1U)); + assert(ublox_GNSS.nv_writes==0&&!sensors.userEnabled()); + requireNoLow(); sensors.configureSharedPeripheralMode(false); + assert(!sensors.isGpsPowered()&&digitalRead(WB_IO2)==LOW); + const unsigned begins=ublox_GNSS.begins,polls=ublox_GNSS.polls; + for(unsigned i=0;i<10;++i) { now_ms+=1000; sensors.loop(); } + assert(ublox_GNSS.begins==begins&&ublox_GNSS.polls==polls); + assert(!sensors.setSettingValue("gps","1")); + } +} +static void dedicated() { +#ifdef PIN_GPS_EN + fresh(); TestLocation location; TestEnvironment sensors(location); Serial1.nmea(); + assert(sensors.begin()&&sensors.isGPSDetected()&&location.pin==PIN_GPS_EN); + sensors.configureSharedPeripheralMode(true); + assert(sensors.setSettingValue("gps","0")); + requireUartReleasedBeforePowerOff(PIN_GPS_EN,!PIN_GPS_EN_ACTIVE); + assert(!sensors.active()&&!sensors.isGpsPowered()); + assert(digitalRead(PIN_GPS_EN)==!PIN_GPS_EN_ACTIVE); + assert(digitalRead(WB_IO2)==HIGH); requireNoLow(); + assert(sensors.gpsSerialTransportCanYield(1)); + const unsigned off_ends=Serial1.ends; + assert(sensors.setGpsSerialTransportBlocked(1,true)); + assert(Serial1.ends==off_ends); // Already-off GPS must not close UARTE twice. + assert(!sensors.transportAvailable()&&!sensors.isGpsPowered()); + assert(sensors.setSettingValue("gps","1")); + assert(sensors.userEnabled()&&!sensors.active()&&!sensors.isGpsPowered()); + hardware_events.clear(); assert(sensors.setGpsSerialTransportBlocked(1,false)); + assert(sensors.active()&&Serial1.installed&&sensors.isGpsPowered()); + assert(firstEvent("write",PIN_GPS_EN,PIN_GPS_EN_ACTIVE) +#include +#include +#include +#include + +#define WB_IO2 21 +#define TELEM_WIRE (&Wire) +#define MESH_DEBUG_PRINTLN(...) ((void)0) + +void LocationProvider::sendSentence(const char*) {} + +namespace mesh { namespace ota { +struct OtaStoreQspiNrf52 { + inline static unsigned probes=0; + inline static uint32_t raw_id=0; + static uint8_t autoDetect() { ++probes; assert(digitalRead(WB_IO2)==HIGH); return 0; } + static void autoProbeIds(uint32_t& rak,uint32_t& w25) { rak=raw_id; w25=0; } +}; +}} + +static bool ensureI2cBusReleased(TwoWire*) { return true; } +struct SensorDef { + uint32_t address; + const char* name; + uint8_t (*init)(TwoWire*,uint8_t); + void (*query)(uint8_t,uint8_t,CayenneLPP&); + bool (*query_voltage)(uint8_t,float&); +}; +static const SensorDef SENSOR_TABLE[1] = {}; +static const size_t SENSOR_TABLE_SIZE=0; +bool EnvironmentSensorManager::i2c_probe(TwoWire&,uint8_t) { return false; } +@METHODS@ + +struct TestEnvironment : EnvironmentSensorManager { + using EnvironmentSensorManager::_wisblock_power; +}; + +int main(int argc,char** argv) { + assert(argc==2&&!std::strcmp(argv[1],"no_gps")); + for(bool prefs_first:{false,true}) { + for(bool shared:{false,true}) { + for(uint32_t id:{0U,0xffffffU,0x123456U}) { + now_ms=100; hardware_events.clear(); + std::memset(pin_values,0,sizeof(pin_values)); pin_values[WB_IO2]=HIGH; + mesh::ota::OtaStoreQspiNrf52::raw_id=id; + mesh::ota::OtaStoreQspiNrf52::probes=0; + TestEnvironment sensors; + if(prefs_first) { + sensors.configureSharedPeripheralMode(shared); + assert(sensors._wisblock_power.discoveryActive()); + assert(digitalRead(WB_IO2)==HIGH); + } + assert(sensors.begin()); + if(!prefs_first) { + // Even a no-GPS image must wait for validated mode after discovery. + assert(digitalRead(WB_IO2)==HIGH); + sensors.configureSharedPeripheralMode(shared); + } + assert(sensors._wisblock_power.isConfigured()&&!sensors._wisblock_power.discoveryActive()); + const bool storage=id!=0&&id!=0xffffffU; + const bool held=shared||storage||mesh::gps::wisBlockCompileRailHolds()!=0; + assert(digitalRead(WB_IO2)==int(held)); + assert(bool(sensors._wisblock_power.holds()&mesh::gps::ExternalStorage)==storage); + assert(mesh::ota::OtaStoreQspiNrf52::probes>0); + assert(!sensors.isGPSDetected()&&!sensors.isGpsPowered()); + assert(!sensors.setSettingValue("gps","1")); + assert(!sensors.gpsUsesSerialUart(1)&&!sensors.gpsSerialTransportCanYield(1)); + assert(!sensors.setGpsSerialTransportBlocked(1,true)); + if(held) { + for(const auto& event:hardware_events) + assert(!(event.name=="write"&&event.a==WB_IO2&&event.b==LOW)); + } + } + } + } +} diff --git a/test/fixtures/wisblock_shared_power_policy.cpp b/test/fixtures/wisblock_shared_power_policy.cpp new file mode 100644 index 00000000..11d69a45 --- /dev/null +++ b/test/fixtures/wisblock_shared_power_policy.cpp @@ -0,0 +1,134 @@ +#include +#include +#include +#include +#include + +using mesh::gps::WisBlockPeripheralMode; +using mesh::gps::WisBlockSharedPowerPolicy; +using mesh::gps::RadioPower; +using mesh::gps::EthernetPower; +using mesh::gps::ExternalStorage; + +static_assert(sizeof(WisBlockSharedPowerPolicy) == 2, "No allocation or added per-board RAM"); +static_assert(std::is_trivially_copyable::value, + "The rail policy must not acquire an owning allocation"); +static_assert(mesh::gps::wisBlockCompileRailHolds() == EXPECTED_COMPILE_HOLDS, + "Compile-time radio/PoE dependencies are unconditional holds"); + +static void expectState(const WisBlockSharedPowerPolicy& policy, bool configured, + bool discovery, bool gps, WisBlockPeripheralMode mode, + uint8_t holds) { + assert(policy.isConfigured() == configured); + assert(policy.discoveryActive() == discovery); + assert(policy.gpsActive() == gps); + assert(policy.mode() == mode); + assert(policy.holds() == holds); + const bool independently_controllable = configured && !discovery + && mode == WisBlockPeripheralMode::GpsOnly && holds == 0; + assert(policy.gpsPowerIndependentlyControllable() == independently_controllable); + assert(policy.canPowerDown() == (independently_controllable && !gps)); + assert(policy.railEnabled() == !(independently_controllable && !gps)); +} + +static void bootAndDiscovery() { + WisBlockSharedPowerPolicy policy; + expectState(policy, false, true, false, WisBlockPeripheralMode::GpsOnly, + EXPECTED_COMPILE_HOLDS); + // Even an early discovery completion cannot cut power before saved mode is known. + policy.setDiscoveryActive(false); + expectState(policy, false, false, false, WisBlockPeripheralMode::GpsOnly, + EXPECTED_COMPILE_HOLDS); + policy.setDiscoveryActive(true); + policy.setGpsActive(true); + assert(policy.configure(WisBlockPeripheralMode::GpsOnly)); + expectState(policy, true, true, true, WisBlockPeripheralMode::GpsOnly, + EXPECTED_COMPILE_HOLDS); + policy.setGpsActive(false); + assert(policy.railEnabled()); // Saved disabled GPS cannot defeat discovery protection. + policy.setDiscoveryActive(false); + expectState(policy, true, false, false, WisBlockPeripheralMode::GpsOnly, + EXPECTED_COMPILE_HOLDS); + // Configuration is boot-only: changing a saved mode must not cause a live rail transition. + assert(!policy.configure(WisBlockPeripheralMode::Shared, ExternalStorage)); + expectState(policy, true, false, false, WisBlockPeripheralMode::GpsOnly, + EXPECTED_COMPILE_HOLDS); + policy.setGpsActive(true); + expectState(policy, true, false, true, WisBlockPeripheralMode::GpsOnly, + EXPECTED_COMPILE_HOLDS); + policy.setGpsActive(false); + policy.setDiscoveryActive(true); // A subsequent safe probe reacquires its hold. + assert(policy.railEnabled() && !policy.gpsPowerIndependentlyControllable()); + policy.setDiscoveryActive(false); + expectState(policy, true, false, false, WisBlockPeripheralMode::GpsOnly, + EXPECTED_COMPILE_HOLDS); +} + +static void everyOwnershipCombination() { + // External NOR is a physical dependency, independent of user/service enablement. + // All valid owner unions must survive GPS settings and repeated probes. + for (uint8_t initial = 0; initial < 8; ++initial) { + for (uint8_t detected = 0; detected < 8; ++detected) { + for (uint8_t mode_value = 0; mode_value < 2; ++mode_value) { + const auto mode = static_cast(mode_value); + WisBlockSharedPowerPolicy policy(initial); + assert(policy.configure(mode, detected)); + const uint8_t holds = static_cast(initial | detected | EXPECTED_COMPILE_HOLDS); + for (unsigned repeat = 0; repeat < 2; ++repeat) { + for (bool discovery : {false, true}) { + for (bool gps : {false, true}) { + policy.setDiscoveryActive(discovery); + policy.setGpsActive(gps); + expectState(policy, true, discovery, gps, mode, holds); + assert(!policy.configure(WisBlockPeripheralMode::GpsOnly, 0)); + expectState(policy, true, discovery, gps, mode, holds); + } + } + } + } + } + } + WisBlockSharedPowerPolicy storage; + assert(storage.configure(WisBlockPeripheralMode::GpsOnly, ExternalStorage)); + storage.setDiscoveryActive(false); + assert(storage.railEnabled() && !storage.canPowerDown()); + assert(!storage.gpsPowerIndependentlyControllable()); + WisBlockSharedPowerPolicy shared; + assert(shared.configure(WisBlockPeripheralMode::SharedPeripherals)); + shared.setDiscoveryActive(false); + assert(shared.railEnabled() && !shared.gpsPowerIndependentlyControllable()); +} + +static void rejectedConfigurationRetainsOwners() { + WisBlockSharedPowerPolicy policy(RadioPower); + policy.setGpsActive(true); + assert(!policy.configure(static_cast(2), ExternalStorage)); + expectState(policy, false, true, true, WisBlockPeripheralMode::GpsOnly, + RadioPower | EXPECTED_COMPILE_HOLDS); + assert(!policy.configure(static_cast(255), 0)); + assert(!policy.configure(WisBlockPeripheralMode::GpsOnly, 0x80)); + expectState(policy, false, true, true, WisBlockPeripheralMode::GpsOnly, + RadioPower | EXPECTED_COMPILE_HOLDS); + assert(policy.configure(WisBlockPeripheralMode::GpsOnly, ExternalStorage)); + policy.setGpsActive(false); + policy.setDiscoveryActive(false); + expectState(policy, true, false, false, WisBlockPeripheralMode::GpsOnly, + RadioPower | ExternalStorage | EXPECTED_COMPILE_HOLDS); + assert(!policy.configure(WisBlockPeripheralMode::Shared, EthernetPower)); + expectState(policy, true, false, false, WisBlockPeripheralMode::GpsOnly, + RadioPower | ExternalStorage | EXPECTED_COMPILE_HOLDS); + // A future/unknown constructor owner stays fail-closed rather than being masked out. + for (uint8_t unknown : {uint8_t(0x08), uint8_t(0x80), uint8_t(0xff)}) { + WisBlockSharedPowerPolicy held(unknown); + assert(held.configure(WisBlockPeripheralMode::GpsOnly)); + held.setDiscoveryActive(false); + expectState(held, true, false, false, WisBlockPeripheralMode::GpsOnly, + static_cast(unknown | EXPECTED_COMPILE_HOLDS)); + } +} + +int main() { + bootAndDiscovery(); + everyOwnershipCombination(); + rejectedConfigurationRetainsOwners(); +} diff --git a/test/test_build_profiles.sh b/test/test_build_profiles.sh index 02cda4f2..d4ab771b 100644 --- a/test/test_build_profiles.sh +++ b/test/test_build_profiles.sh @@ -429,7 +429,13 @@ for env_name in ( rak_usb = "RAK_3401_companion_radio_usb" require(rak_usb, "build_flags", "RAK_BOARD") -require(rak_usb, "build_flags", "FORCE_GPS_ALIVE") +require(rak_usb, "build_flags", "RAK_3401") +require(rak_usb, "build_flags", "PIN_GPS_EN=-1") +reject(rak_usb, "build_flags", "FORCE_GPS_ALIVE") +for env_name in ("RAK_4631_companion_radio_usb", "RAK_4631_repeater"): + require(env_name, "build_flags", "RAK_4631") + require(env_name, "build_flags", "PIN_GPS_EN=-1") + reject(env_name, "build_flags", "FORCE_GPS_ALIVE") ' # Every qualified ESP32 Companion layout publishes one Full image even when diff --git a/test/test_companion_prefs_values.py b/test/test_companion_prefs_values.py index 6df510dd..e875fff8 100644 --- a/test/test_companion_prefs_values.py +++ b/test/test_companion_prefs_values.py @@ -185,11 +185,12 @@ class CompanionPrefsValuesTests(unittest.TestCase): r'^\s*(?:float|char|u?int(?:8|16|32)_t)\s+([A-Za-z_][A-Za-z_0-9]*)', prefix, re.M) self.assertEqual(declarations[0], 'airtime_factor') - self.assertEqual(declarations[-1], 'lost_reply') + self.assertEqual(declarations[-2:], ['lost_reply', 'peripherals_shared']) optional = { 'fan_mode': 'TBEAM_1W', 'fan_lo': 'TBEAM_1W', 'fan_hi': 'TBEAM_1W', 'wifi_ssid': 'defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)', 'wifi_pwd': 'defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)', + 'peripherals_shared': 'RAK_4631', } def fields(template): diff --git a/test/test_companion_settings_persistence_contract.py b/test/test_companion_settings_persistence_contract.py index b9f7bb92..b142938b 100644 --- a/test/test_companion_settings_persistence_contract.py +++ b/test/test_companion_settings_persistence_contract.py @@ -122,10 +122,13 @@ class CompanionSettingsPersistenceContractTests(unittest.TestCase): "bool copyPersistedValuesFrom(const CompanionNodePrefs& source)", ) self.assertIn("reinterpret_cast(&airtime_factor)", copy) - # The appended lost-reply byte is the last persisted value. Both - # snapshots must stop there before either runtime adapter begins. - self.assertIn("reinterpret_cast(&lost_reply)", copy) - self.assertIn("reinterpret_cast(&values.lost_reply)", copy) + # RAK4631 appends its peripheral-mode byte; every other target stops + # at lost-reply. Neither endpoint may include a runtime adapter. + self.assertIn("const auto& last_value = peripherals_shared;", copy) + self.assertIn("const auto& last_value = lost_reply;", copy) + self.assertIn("reinterpret_cast(&last_value)", copy) + self.assertIn("&values.peripherals_shared", copy) + self.assertIn("&values.lost_reply", copy) self.assertIn("+ sizeof(lost_reply)", copy) self.assertIn("end - begin == size - sizeof(values.lost_reply)", copy) self.assertIn("reinterpret_cast(&values.radio)", copy) diff --git a/test/test_esp32_gps_discovery.py b/test/test_esp32_gps_discovery.py index c605a691..58d33243 100644 --- a/test/test_esp32_gps_discovery.py +++ b/test/test_esp32_gps_discovery.py @@ -399,7 +399,8 @@ class CooperativeGpsDiscoveryTests(unittest.TestCase): with self.subTest(defines=defines): result = subprocess.run([self.compiler, "-E", "-P", "-x", "c++", "-DENV_INCLUDE_GPS=1", *["-D" + value for value in defines], - "-I" + str(work), str(cpp)], text=True, capture_output=True, timeout=10) + "-I" + str(work), "-I" + str(ROOT / "src/helpers/sensors"), + str(cpp)], text=True, capture_output=True, timeout=10) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual("gps_discovery_pending" in result.stdout, expected) diff --git a/test/test_gps_status.py b/test/test_gps_status.py index 0bd73374..fcf3b3f7 100644 --- a/test/test_gps_status.py +++ b/test/test_gps_status.py @@ -41,7 +41,11 @@ struct LocationProvider { struct Sensors { LocationProvider* provider; const char* user = "0"; + bool physical_override = false, physical_power = false; LocationProvider* getLocationProvider() { return provider; } + bool isGpsPowered() const { + return physical_override ? physical_power : (provider && provider->powered); + } const char* getSettingByKey(const char*) { return user; } }; struct Prefs { bool powersaving_enabled = true; }; @@ -58,6 +62,12 @@ int main() { gps.powered = true; status(&sensors, &prefs, reply); assert(!strcmp(reply, "off, powered")); + // A provider without its own enable pin can report enabled despite a cut + // shared rail. The manager's physical policy must win over that stale view. + sensors.physical_override = true; sensors.physical_power = false; + status(&sensors, &prefs, reply); + assert(!strcmp(reply, "off, unpowered")); + sensors.physical_override = false; sensors.user = "1"; gps.powered = false; status(&sensors, &prefs, reply); assert(!strcmp(reply, "on, unpowered (powersaving, wake in 0h 30m), last sync: none")); diff --git a/test/test_nrf52_variant_contracts.py b/test/test_nrf52_variant_contracts.py index 95e76b0c..0bc417e8 100644 --- a/test/test_nrf52_variant_contracts.py +++ b/test/test_nrf52_variant_contracts.py @@ -259,18 +259,35 @@ class Nrf52VariantContractsTest(unittest.TestCase): self.assertIn("strap INA3221 A0 to SCL for 0x43", recipe) self.assertIn("-DTELEM_INA3221_ADDRESS=0x43", recipe) - def test_rak4631_shared_sensor_rail_stays_enabled(self) -> None: + def test_rak4631_shared_sensor_rail_is_owned_by_boot_policy(self) -> None: recipe = (ROOT / "variants/rak4631/platformio.ini").read_text() + rak3401_recipe = (ROOT / "variants/rak3401/platformio.ini").read_text() board = (ROOT / "variants/rak4631/RAK4631Board.cpp").read_text() - sensors = ( - ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp" + sensor_header = ( + ROOT / "src/helpers/sensors/EnvironmentSensorManager.h" ).read_text() + policy = ( + ROOT / "src/helpers/sensors/WisBlockSharedPowerPolicy.h" + ).read_text() + prefs = (ROOT / "src/helpers/PeripheralModePrefs.h").read_text() - self.assertRegex(recipe, r"-D\s+FORCE_GPS_ALIVE\b") + for current_recipe in (recipe, rak3401_recipe): + self.assertNotRegex(current_recipe, r"-D\s+FORCE_GPS_ALIVE\b") + self.assertIn("peripherals.mode defaults to gps-only", recipe) self.assertIn("pinMode(WB_IO2, OUTPUT)", board) self.assertIn("digitalWrite(WB_IO2, HIGH)", board) - self.assertIn("setRakGpsControl(ioPin, shared_power_rail, true)", sensors) - self.assertIn("setRakGpsControl(ioPin, shared_power_rail, false)", sensors) + self.assertIn("WisBlockSharedPowerPolicy _wisblock_power", sensor_header) + self.assertIn("_state(DiscoveryActive)", policy) + self.assertIn("GpsOnly = 0", policy) + self.assertIn("Shared = 1", policy) + self.assertIn("#if defined(RAK_3401)", policy) + self.assertIn("| RadioPower", policy) + self.assertIn("defined(ETHERNET_ENABLED)", policy) + self.assertIn("| EthernetPower", policy) + self.assertIn("ExternalStorage =", policy) + self.assertIn("get peripherals.mode", prefs) + self.assertIn("set peripherals.mode", prefs) + self.assertIn("saved; reboot to apply", prefs) def test_rak4631_ethernet_does_not_suppress_shared_rail_gps(self) -> None: recipe = (ROOT / "variants/rak4631/platformio.ini").read_text() @@ -362,17 +379,17 @@ class Nrf52VariantContractsTest(unittest.TestCase): self.assertIn( "return uart == 1 && gps_detected && gps_serial_transport", sensors ) - self.assertIn( - "#if defined(RAK_WISBLOCK_GPS) && defined(FORCE_GPS_ALIVE)", - can_yield, + shared_rail_guard = ( + "#if defined(RAK_WISBLOCK_GPS) " + "&& !(defined(PIN_GPS_EN) && PIN_GPS_EN >= 0)" ) - forced_rak = can_yield[ - can_yield.index( - "#if defined(RAK_WISBLOCK_GPS) && defined(FORCE_GPS_ALIVE)" - ): - can_yield.index("#elif defined(RAK_WISBLOCK_GPS)") + self.assertIn(shared_rail_guard, can_yield) + shared_rak = can_yield[ + can_yield.index(shared_rail_guard): + can_yield.index("#endif", can_yield.index(shared_rail_guard)) ] - self.assertIn("return false;", forced_rak) + self.assertIn("return false;", shared_rak) + self.assertNotIn("FORCE_GPS_ALIVE", can_yield) refusal = block.index( "if (blocked && !gpsSerialTransportCanYield(uart)) return false;" ) diff --git a/test/test_peripheral_mode_prefs.py b/test/test_peripheral_mode_prefs.py new file mode 100644 index 00000000..e238732c --- /dev/null +++ b/test/test_peripheral_mode_prefs.py @@ -0,0 +1,302 @@ +"""Execute RAK4631 append-only policy images, CLI rollback and boot ordering.""" +from pathlib import Path +import os +import re +import subprocess +import tempfile +import unittest + +from test_common_usb_debug import HARNESS as COMMON_HARNESS +from test_companion_preferences_transaction import HARNESS as COMPANION_HARNESS +from test_radio_receive_contract import method +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] + + +def compile_run(code, macros=(), *, serializers=False): + with tempfile.TemporaryDirectory(prefix="meshcore-peripheral-mode-") as directory: + work = Path(directory) + (work / "helpers").mkdir() + fs = (ROOT / "test/fixtures/radio_profiles/mocks/helpers/IdentityStore.h").read_text() + fs = fs.replace(" size_t available() const", " bool seek(size_t p) { if (p > size()) return false; cursor_ = p; return true; }\n size_t available() const") + (work / "helpers/IdentityStore.h").write_text(fs) + (work / "Utils.h").write_text("""#pragma once +#include +#include +namespace mesh { struct Utils { + static void printHex(Stream&,const uint8_t*,size_t){assert(false);} + static void fromHex(uint8_t*,size_t,const char*){assert(false);} +}; } +""") + (work / "platform_shim.h").write_text("""#include +#include +inline char* utoa(unsigned int v,char* p,int b){if(b!=10)abort();sprintf(p,"%u",v);return p;} +""") + transaction = (ROOT / "src/helpers/ContactFileTransaction.h").read_text() + (work / "ContactFileTransaction.h").write_text(transaction.replace( + '#include "IdentityStore.h"', '#include ')) + cpp, binary = work / "test.cpp", work / "test" + cpp.write_text(code) + flags = [] if os.name == "nt" else ["-fsanitize=address,undefined", + "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + command = ["g++", "-std=c++17", *flags, *["-D" + x + "=1" for x in macros], + "-include", str(work / "platform_shim.h"), "-I" + str(work), + "-I" + str(ROOT / "test/fixtures/radio_profiles/mocks"), + "-I" + str(ROOT / "test/mocks"), "-I" + str(ROOT / "src"), + "-I" + str(ROOT / "src/helpers"), "-I" + str(ROOT), str(cpp)] + if serializers: + command += [str(ROOT / "src/helpers/" / name) for name in ( + "ConfigSerializer.cpp", "DynamicConfigSerializer.cpp", "CommonRadioPrefs.cpp", + "TxtDataHelpers.cpp")] + build = subprocess.run(command + ["-o", str(binary)], capture_output=True, text=True, timeout=90) + if build.returncode: + raise AssertionError(build.stdout + build.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30) + if result.returncode: + raise AssertionError(result.stdout + result.stderr) + + +def common_code(): + source = (ROOT / "src/helpers/CommonCLI.cpp").read_text() + header = (ROOT / "src/helpers/CommonCLI.h").read_text() + fields = header.split("class NodePrefs :", 1)[1].split("private:", 1)[0] + fields = "\n".join(re.findall(r"^\s*(?:float|double|char|u?int(?:8|16|32)_t)\s+[^;]+;", fields, re.M)) + constants = "\n".join(re.findall(r"^\s*#define\s+\w+\s+(?:0x[\dA-Fa-f]+|\d+)\s*$", header, re.M)) + constants += "\n" + "\n".join(re.findall(r"^static const size_t LEGACY_\w+\s*=[^;]+;", source, re.M)) + constants += "\n" + re.search(r"^static constexpr uint8_t DEFAULT_ADVERT_LOC_POLICY\s*=[^;]+;", header, re.M).group() + helpers = "\n".join(extract_braced(source, signature) for signature in ( + "static bool bwMatches(", "static bool isValidLoRaBandwidth(", + "static float defaultLoRaBandwidth(", "static void markDirectRetryPrefsValid(", + "static void applyFloodRetryPreset(", "static void applyDirectRetryPreset(", + "static void setDefaultDirectRetryPrefs(", "static bool directRetryPrefsValid(")) + methods = "\n".join(extract_braced(source, signature) for signature in ( + "static const char* commonPrefsSaveResultName(", "bool CommonCLI::recoverCommonPrefsFiles(", + "bool CommonCLI::saveCommonPrefsImageAtomically(", "void CommonCLI::savePrefs(FILESYSTEM*", + "void CommonCLI::savePrefs(PrefsSaveRouting::Scope scope)", "bool CommonCLI::trySavePrefs(", + "void CommonCLI::loadPrefsInt(")) + code = COMMON_HARNESS.split("int main(")[0] + for name, value in { + "FIELDS": fields, "CONSTANTS": constants, "HELPERS": helpers, + "LEGACY": extract_braced(header, "struct LegacyObserverTail") + ";", + "SERIALIZER": "template\n" + extract_braced(source, "static bool writeCommonPrefsImage("), + "STORE": extract_braced(source, "class CommonPrefsFileStore"), "METHODS": methods, + "DYNAMIC": extract_braced(header, "class BridgePrefs :"), + "SET_DEBUG": "", "SET_MASTER": "", "GET_DEBUG": "", "GET_MASTER": "", + }.items(): + code = code.replace("@" + name + "@", value) + return "#include \n#include \n" + code + + +class PeripheralModePrefsTests(unittest.TestCase): + def test_actual_cli_wrappers_use_saved_intent_without_hot_gpio(self): + common = (ROOT / "src/helpers/CommonCLI.cpp").read_text() + companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + snippets = [] + for source, signature in ( + (common, "void CommonCLI::handleSetCmd("), + (common, "void CommonCLI::handleGetCmd("), + (companion, "bool MyMesh::handleLocalControlCommand(")): + snippet = method(source, signature).split("#if defined(RAK_4631)", 1)[1].split("#else", 1)[0] + self.assertIn("mesh::peripherals::handleCommand", snippet) + snippets.append(snippet) + code = r''' +#include +#include +struct Prefs{uint8_t peripherals_shared=0;}; +struct Sensors{bool supported=true;unsigned applications=0; + bool supportsSharedPeripheralMode()const{return supported;} + void configureSharedPeripheralMode(bool){++applications;} +}; +struct Common{ + Prefs prefs,durable;Prefs* _prefs=&prefs;Sensors sensor;Sensors* _sensors=&sensor; + bool ok=true;unsigned saves=0; + bool trySavePrefs(){++saves;if(ok)durable=prefs;return ok;} + void set(const char* command,char* reply){@SET@} + void get(const char* command,char* reply){@GET@} +}; +struct Companion{ + Prefs _prefs,durable;Sensors sensors;bool ok=true;unsigned saves=0; + bool savePrefs(){++saves;if(ok)durable=_prefs;return ok;} + bool command(const char* command,char* reply,size_t reply_size){@COMPANION@ return false;} +}; +int main(){Common common;Companion companion;char reply[160]; + common.set("set peripherals.mode shared",reply); + assert(common.prefs.peripherals_shared==1&&common.durable.peripherals_shared==1&&strstr(reply,"reboot")); + assert(companion.command("set peripherals.mode shared",reply,sizeof(reply))); + assert(companion._prefs.peripherals_shared==1&&companion.durable.peripherals_shared==1&&strstr(reply,"reboot")); + assert(common.sensor.applications==0&&companion.sensors.applications==0); + common.ok=companion.ok=false; + common.set("set peripherals.mode gps-only",reply);assert(strstr(reply,"could not be saved")); + assert(companion.command("set peripherals.mode gps-only",reply,sizeof(reply))&&strstr(reply,"could not be saved")); + assert(common.prefs.peripherals_shared==1&&companion._prefs.peripherals_shared==1); + common.get("get peripherals.mode",reply);assert(strstr(reply,"shared")); + assert(companion.command("get peripherals.mode",reply,sizeof(reply))&&strstr(reply,"shared")); + unsigned cs=common.saves,ps=companion.saves; + common.sensor.supported=companion.sensors.supported=false; + common.set("set peripherals.mode gps-only",reply);assert(strstr(reply,"unsupported")); + assert(companion.command("set peripherals.mode gps-only",reply,sizeof(reply))&&strstr(reply,"unsupported")); + assert(common.saves==cs&&companion.saves==ps&&common.sensor.applications==0&&companion.sensors.applications==0); +} +''' + for key, snippet in zip(("SET", "GET", "COMPANION"), snippets): + code = code.replace("@" + key + "@", snippet) + compile_run(code) + + def test_codec_and_strict_cli_transactions(self): + compile_run(r''' +#include +#include +#include +int main(){ + uint8_t mode=0, durable=0;unsigned saves=0;bool ok=true;char reply[160]; + auto save=[&](){++saves;if(ok)durable=mode;return ok;}; + assert(mesh::peripherals::handleCommand(mode,"get peripherals.mode",reply,sizeof(reply),true,save)); + assert(strstr(reply,"gps-only")&&saves==0); + for(const char* text:{"shared","gps-only"}){ + char cmd[80];snprintf(cmd,sizeof(cmd),"set peripherals.mode %s",text); + assert(mesh::peripherals::handleCommand(mode,cmd,reply,sizeof(reply),true,save)); + assert(mode==(strcmp(text,"shared")==0)&&durable==mode&&strstr(reply,"reboot")); + } + for(const char* text:{"", "on", "1", "SHARED", "shared junk", "shared ", "gps-only\n"}){ + unsigned before=saves;char cmd[80];snprintf(cmd,sizeof(cmd),"set peripherals.mode %s",text); + assert(mesh::peripherals::handleCommand(mode,cmd,reply,sizeof(reply),true,save)); + assert(strstr(reply,"Error")&&saves==before&&mode==durable); + } + ok=false;assert(mesh::peripherals::handleCommand(mode,"set peripherals.mode shared",reply,sizeof(reply),true,save)); + assert(mode==0&&durable==0&&strstr(reply,"could not be saved")); + unsigned before=saves; + assert(mesh::peripherals::handleCommand(mode,"set peripherals.mode shared",reply,sizeof(reply),false,save)); + assert(strstr(reply,"unsupported")&&saves==before&&mode==0); + assert(!mesh::peripherals::handleCommand(mode,"set peripherals.modeX shared",reply,sizeof(reply),true,save)); + for(uint8_t value:{0,1}){ + uint8_t footer[8];mesh::peripherals::encodeFooter(footer,value);uint8_t decoded=9; + assert(mesh::peripherals::decodeFooter(footer,sizeof(footer),decoded)&&decoded==value); + for(unsigned i=0;i<8;++i){footer[i]^=1;decoded=9; + assert(!mesh::peripherals::decodeFooter(footer,sizeof(footer),decoded)&&decoded==9);footer[i]^=1;} + for(unsigned n=0;n<8;++n)assert(!mesh::peripherals::decodeFooter(footer,n,decoded)); + } +} +''') + + def test_actual_common_images_and_footer_fail_closed(self): + code = common_code() + r''' +int main(){ + CommonCLI writer;writer.prefs.peripherals_shared=1;strcpy(writer.prefs.password,"keep secret"); + strcpy(writer.prefs.node_name,"keep node");writer.savePrefs(&writer.fs,PrefsSaveRouting::Scope::Common); + assert(writer._common_save_succeeded);const auto disk=writer.fs.files["/com_prefs"]; + const size_t legacy=mesh::peripherals::CommonLegacySize+ +#ifdef WITH_RS232_BRIDGE + mesh::peripherals::LegacyRs232TailSize; +#else + 0; +#endif + assert(disk.size()==legacy+8);Capture capture; + assert(writeCommonPrefsImage(capture,&writer.prefs)&&capture.bytes==disk); + for(uint8_t mode:{0,1}){ + writer.prefs.peripherals_shared=mode;writer.savePrefs(&writer.fs,PrefsSaveRouting::Scope::Common); + CommonCLI reader;reader.fs.files=writer.fs.files;reader.prefs.peripherals_shared=9; + reader.loadPrefsInt(&reader.fs,"/com_prefs");assert(reader.prefs.peripherals_shared==mode); + assert(!strcmp(reader.prefs.password,"keep secret")&&!strcmp(reader.prefs.node_name,"keep node")); + } + for(size_t keep=legacy;keep UART and UART -> no-UART upgrades retain the checked mode. + for(bool uart:{false,true}){ + std::vector foreign(disk.begin(),disk.begin()+mesh::peripherals::CommonLegacySize); + if(uart){uint8_t tail[mesh::bridge::rs232power::PREFS_SIZE]; + mesh::bridge::rs232power::encodePrefs(tail,1,1,300);foreign.insert(foreign.end(),tail,tail+sizeof(tail));} + CommonCLI old;old.fs.files["/com_prefs"]=foreign;old.loadPrefsInt(&old.fs,"/com_prefs"); + assert(old.prefs.peripherals_shared==0); + uint8_t footer[8];mesh::peripherals::encodeFooter(footer,1);foreign.insert(foreign.end(),footer,footer+8); + CommonCLI migrated;migrated.fs.files["/com_prefs"]=foreign;migrated.loadPrefsInt(&migrated.fs,"/com_prefs"); + assert(migrated.prefs.peripherals_shared==1); + } + CommonCLI unreadable;unreadable.fs.files["/com_prefs"]=disk;unreadable.fs.fail_read_open=true; + unreadable.loadPrefsInt(&unreadable.fs,"/com_prefs");assert(unreadable.prefs.peripherals_shared==1); + CommonCLI failed;failed.fs.files["/com_prefs"]=disk;failed.fs.fail_write_after=int(legacy+4); + failed.prefs.peripherals_shared=0;failed.savePrefs(&failed.fs,PrefsSaveRouting::Scope::Common); + assert(!failed._common_save_succeeded&&failed.fs.files["/com_prefs"]==disk); +} +''' + for flags in (("NRF52_PLATFORM", "RAK_4631", "ENABLE_OTA"), + ("NRF52_PLATFORM", "RAK_4631", "ENABLE_OTA", "WITH_MQTT_BRIDGE"), + ("NRF52_PLATFORM", "RAK_4631", "ENABLE_OTA", "WITH_RS232_BRIDGE")): + with self.subTest(flags=flags): + # Only the profile's compile-time UART constant matters here. + compile_run("#define WITH_RS232_BRIDGE_UART 2\n" + code, flags) + + def test_actual_companion_images_and_adapter_snapshot(self): + source = (ROOT / "examples/companion_radio/DataStore.cpp").read_text() + methods = "\n".join(method(source, signature) for signature in ( + "bool DataStore::loadPrefs(", "bool DataStore::loadPrefsInt(", "bool DataStore::savePrefs(")) + code = "#include \n" + COMPANION_HARNESS.split("int main(")[0].replace("@METHODS@", methods) + code += r''' +int main(){ + DataStore store;CompanionNodePrefs prefs;prefs.peripherals_shared=1; + strcpy(prefs.node_name,"retained name");prefs.ble_pin=654321;prefs.gps_interval=42; + assert(prefs.getCustom()->setByKey("snapshot_tag","original")); + assert(store.savePrefs(prefs,47.2,-122.1));const auto disk=store.fs.files["/new_prefs"]; + assert(disk.size()==244);double lat=0,lon=0; + CompanionNodePrefs loaded;assert(loaded.getCustom()->setByKey("snapshot_tag","loaded")); + assert(store.loadPrefsInt("/new_prefs",loaded,lat,lon)); + assert(loaded.peripherals_shared==1&&loaded.ble_pin==654321&&loaded.gps_interval==42); + assert(!strcmp(loaded.node_name,"retained name")&&lat==47.2&&lon==-122.1); + char tag[32];assert(loaded.getCustom()->getByKey("snapshot_tag",tag,sizeof(tag))&&!strcmp(tag,"loaded")); + loaded.getRadioPrefs()->setFreq(919.5f);assert(loaded.freq==919.5f&&prefs.freq!=919.5f); + for(size_t keep=236;keep<244;++keep){ + DataStore damaged;damaged.fs.files["/new_prefs"]={disk.begin(),disk.begin()+keep}; + CompanionNodePrefs candidate;candidate.peripherals_shared=1;candidate.ble_pin=123456; + const bool ok=damaged.loadPrefsInt("/new_prefs",candidate,lat,lon); + assert(ok==(keep==236));if(ok)assert(candidate.peripherals_shared==0); + else assert(candidate.peripherals_shared==1&&candidate.ble_pin==123456); + } + for(size_t byte=236;byte<244;++byte){ + DataStore damaged;damaged.fs.files["/new_prefs"]=disk;damaged.fs.files["/new_prefs"][byte]^=1; + CompanionNodePrefs candidate;candidate.peripherals_shared=1;candidate.ble_pin=123456; + assert(!damaged.loadPrefsInt("/new_prefs",candidate,lat,lon)); + assert(candidate.peripherals_shared==1&&candidate.ble_pin==123456); + // A separately chosen, fully validated image remains usable. This does + // not change nRF52's existing primary-file authority/fallback policy. + damaged.fs.files["/known_good"]=disk; + assert(damaged.loadPrefsInt("/known_good",candidate,lat,lon)&&candidate.peripherals_shared==1); + } + DataStore interrupted;interrupted.fs.files["/new_prefs"]=disk; + interrupted.fs.files["/new_prefs.tmp"]={disk.begin(),disk.begin()+240}; + CompanionNodePrefs rebooted;assert(interrupted.loadPrefs(rebooted,lat,lon)&&rebooted.peripherals_shared==1); + DataStore fail;fail.fs.files["/new_prefs"]=disk;fail.fs.fail_write_after=240; + prefs.peripherals_shared=0;assert(!fail.savePrefs(prefs,1,2)&&fail.fs.files["/new_prefs"]==disk); + DataStore fresh;CompanionNodePrefs defaults; + assert(fresh.loadPrefs(defaults,lat,lon)&&defaults.peripherals_shared==0); +} +''' + compile_run(code, ("NRF52_PLATFORM", "RAK_4631"), serializers=True) + + def test_production_wiring_is_boot_only_and_before_gps_intent(self): + common = (ROOT / "src/helpers/CommonCLI.cpp").read_text() + companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + load = method(common, "void CommonCLI::loadPrefs(FILESYSTEM*") + begin = method(companion, "void MyMesh::begin(") + self.assertLess(load.index("loadPrefsInt("), load.index("configureSharedPeripheralMode(")) + self.assertLess(begin.index("_store->loadPrefs("), begin.index("configureSharedPeripheralMode(")) + self.assertIn("if (!prefs_ready) _prefs.peripherals_shared = 1;", begin) + self.assertNotIn("configureSharedPeripheralMode", method(companion, "bool MyMesh::handleLocalControlCommand(")) + self.assertNotIn("configureSharedPeripheralMode", method(common, "void CommonCLI::handleSetCmd(")) + for path in ("examples/simple_repeater/MyMesh.cpp", "examples/simple_room_server/MyMesh.cpp", + "examples/simple_sensor/SensorMesh.cpp"): + source = (ROOT / path).read_text() + self.assertLess(source.index("_cli.loadPrefs(_fs)"), source.index("applyGpsPrefs()")) + main = (ROOT / "examples/companion_radio/main.cpp").read_text() + self.assertLess(main.index("the_mesh.begin("), main.index("the_mesh.applyGpsPrefs()")) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_rak_gps_power.py b/test/test_rak_gps_power.py new file mode 100644 index 00000000..ba210097 --- /dev/null +++ b/test/test_rak_gps_power.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Execute WisBlock GPS/shared-rail ownership without touching hardware. + +The production allocation-free policy is compiled unchanged. GPIO/UART/GNSS +integration regressions live alongside these portable boot/hold cases. +""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest +from test_gps_upstream_adaptations import ARDUINO, CAYENNE, MESH +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "test/fixtures/wisblock_shared_power_policy.cpp" +ENVIRONMENT = ROOT / "src/helpers/sensors/EnvironmentSensorManager.cpp" + + +def lifecycle_harness(gps=True, negative=None): + source = ENVIRONMENT.read_text(encoding="utf-8") + mutations = { + "shared_provider_pin": ( + "_location->setPinEn(shared_power_rail ? -1 : gps_control_pin);", + "_location->setPinEn(gps_control_pin);"), + "forced_user_on": ( + "_location->setPinEn(shared_power_rail ? -1 : gps_control_pin);", + "_location->setPinEn(shared_power_rail ? -1 : gps_control_pin);\n" + " setGpsTelemetryUserEnabled(true);"), + "no_wake_settle": ("if (!was_powered) delay(500);", "// Negative control: use UART before cold receiver settles."), + "no_pad_release": ("pinMode(PIN_GPS_TX, INPUT);\n pinMode(PIN_GPS_RX, INPUT);", + "// Negative control: retain driven serial pads after receiver power-off."), + } + if negative in mutations: + before, after = mutations[negative] + assert source.count(before) == 1, "negative-control source anchor changed" + source = source.replace(before, after, 1) + elif negative == "power_before_uart_end": + before = extract_braced(source, "void EnvironmentSensorManager::stop_gps()") + after = before.replace("if (serialGPSFlag) stopRakGpsUart();", "// Negative control: release UART too late.") + assert after != before, "negative-control UART stop anchor changed" + after = after.replace("applyWisBlockRailPower();", "applyWisBlockRailPower();\n if (serialGPSFlag) stopRakGpsUart();", 1) + source = source.replace(before, after, 1) + elif negative is not None: + raise ValueError("unknown lifecycle negative control") + fixture_name = "rak_gps_power.cpp" if gps else "rak_peripheral_power_no_gps.cpp" + fixture = (ROOT / "test/fixtures" / fixture_name).read_text(encoding="utf-8") + helpers = "\n".join(extract_braced(source, signature) for signature in ( + "static uint8_t rakGpsControlActiveLevel(", + "static void setRakGpsControl(", + "static void stopRakGpsUart(", + "static bool serialHasValidGpsSentence(", + "static bool configureRakUbloxReceiver(", + )) if gps else "" + provider = extract_braced(source, "class RAK12500LocationProvider") + ";\n" if gps else "" + signatures = ( + "bool EnvironmentSensorManager::begin()", + "bool EnvironmentSensorManager::querySensors(", + "uint8_t EnvironmentSensorManager::getVoltageSensorChannels(", + "uint8_t EnvironmentSensorManager::queryVoltageSensors(", + "int EnvironmentSensorManager::getNumSettings()", + "const char* EnvironmentSensorManager::getSettingName(", + "const char* EnvironmentSensorManager::getSettingValue(", + "bool EnvironmentSensorManager::setSettingValue(", + "void EnvironmentSensorManager::setPowerSavingEnabled(", + "bool EnvironmentSensorManager::supportsSharedPeripheralMode()", + "void EnvironmentSensorManager::applyWisBlockRailPower()", + "uint8_t EnvironmentSensorManager::detectWisBlockStorageHolds()", + "void EnvironmentSensorManager::configureSharedPeripheralMode(", + "bool EnvironmentSensorManager::isGpsPowered()", + "void EnvironmentSensorManager::rakGPSInit()", + "bool EnvironmentSensorManager::gpsIsAwake(", + "void EnvironmentSensorManager::armGpsPowerSavingCycle()", + "void EnvironmentSensorManager::start_gps()", + "void EnvironmentSensorManager::stop_gps()", + "bool EnvironmentSensorManager::gpsUsesSerialUart(", + "bool EnvironmentSensorManager::gpsSerialTransportMayConflict(", + "bool EnvironmentSensorManager::gpsSerialTransportCanYield(", + "bool EnvironmentSensorManager::setGpsSerialTransportBlocked(", + "void EnvironmentSensorManager::loop()", + ) + if not gps: + signatures = tuple(signature for signature in signatures if signature not in ( + "void EnvironmentSensorManager::rakGPSInit()", + "bool EnvironmentSensorManager::gpsIsAwake(", + "void EnvironmentSensorManager::armGpsPowerSavingCycle()", + "void EnvironmentSensorManager::start_gps()", + "void EnvironmentSensorManager::stop_gps()", + "void EnvironmentSensorManager::loop()", + )) + methods = "\n".join(extract_braced(source, signature) for signature in signatures) + return fixture.replace("@HELPERS@", helpers).replace("@PROVIDER@", provider).replace("@METHODS@", methods) + + +def compile_lifecycle(testcase, cases, defines=(), gps=True, negative=None, auto_store=True): + with tempfile.TemporaryDirectory(prefix="rak-gps-lifecycle-") as folder: + work = Path(folder) + arduino = ARDUINO.replace("#include ", "#include \n#include \n#include \n#include \n#define INPUT 0") + arduino = arduino.replace("inline void pinMode(int,int){}\ninline void digitalWrite(int,int){}\ninline int digitalRead(int){return HIGH;}", r''' +struct HardwareEvent { std::string name; int a; int b; uint32_t time; }; +inline std::vector hardware_events; +inline int pin_values[64]={}; +inline void event(const char* name,int a=0,int b=0){hardware_events.push_back({name,a,b,now_ms});} +inline void pinMode(int pin,int mode){event("pinMode",pin,mode);} +inline void digitalWrite(int pin,int value){assert(pin>=0&&pin<64);pin_values[pin]=value;event("write",pin,value);} +inline int digitalRead(int pin){assert(pin>=0&&pin<64);return pin_values[pin];} +''') + for name, content in {"Arduino.h": arduino, "Mesh.h": MESH, "CayenneLPP.h": CAYENNE, + "Wire.h": "#pragma once\nclass TwoWire {};\ninline TwoWire Wire;\n"}.items(): + (work / name).write_text(content, encoding="utf-8") + cpp, binary = work / "lifecycle.cpp", work / "lifecycle" + cpp.write_text(lifecycle_harness(gps=gps, negative=negative), encoding="utf-8") + compiled = subprocess.run([ + os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", "-fsanitize=address,undefined", "-fno-sanitize-recover=all", + *(["-Wno-unused-variable"] if not gps else []), + "-fno-pie", "-no-pie", f"-DENV_INCLUDE_GPS={int(gps)}", "-DNRF52_PLATFORM", + "-DRAK_BOARD", *(["-DRAK_WISBLOCK_GPS"] if gps else []), + *(["-DOTA_RAK_AUTO_STORE"] if auto_store else []), + "-DWITH_RS232_BRIDGE_GPS_CONFLICT_UART=1", *defines, + "-I" + str(work), "-I" + str(ROOT / "src"), str(cpp), + str(ROOT / "src/helpers/SensorManager.cpp"), "-o", str(binary), + ], text=True, capture_output=True, timeout=60) + testcase.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) + for case in cases: + with testcase.subTest(scenario=case, defines=defines, negative=negative): + result = subprocess.run([str(binary), case], text=True, capture_output=True, timeout=20) + if negative: + testcase.assertNotEqual(result.returncode, 0, "unsafe production variant escaped the regression") + testcase.assertIn("Assertion", result.stderr) + else: + testcase.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +def compile_policy(testcase, flags=()): + with tempfile.TemporaryDirectory(prefix="wisblock-gps-power-") as folder: + binary = Path(folder) / "policy" + compiled = subprocess.run([ + os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", + *flags, "-I" + str(ROOT / "src"), str(FIXTURE), "-o", str(binary), + ], text=True, capture_output=True, timeout=60) + testcase.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) + executed = subprocess.run([str(binary)], text=True, capture_output=True, timeout=20) + testcase.assertEqual(executed.returncode, 0, executed.stdout + executed.stderr) + + +class RakGpsPowerTests(unittest.TestCase): + def test_actual_policy_boot_configuration_activity_and_fail_closed_holds(self): + compile_policy(self, ("-DEXPECTED_COMPILE_HOLDS=0",)) + + def test_actual_compile_time_radio_and_poe_dependencies_are_not_service_toggles(self): + variants = [ + (("-DRAK_4631",), 0), + (("-DRAK_3401",), 1), + (("-DETHERNET_ENABLED=1",), 2), + (("-DETHERNET_ENABLED=0",), 2), + (("-DETHERNET_USE_RAK13800=1",), 2), + (("-DETHERNET_USE_RAK13800=0",), 2), + (("-DRAK4631_COMBINED_ETHERNET=1",), 2), + (("-DRAK4631_COMBINED_ETHERNET=0",), 2), + (("-DRAK_3401", "-DETHERNET_ENABLED=0"), 3), + (("-DETHERNET_ENABLED=0", "-DETHERNET_USE_RAK13800=0", + "-DRAK4631_COMBINED_ETHERNET=0"), 2), + ] + for flags, expected in variants: + with self.subTest(flags=flags): + compile_policy(self, (*flags, f"-DEXPECTED_COMPILE_HOLDS={expected}")) + + def test_actual_rak4631_uart_boot_off_wake_schedule_and_bridge_ownership(self): + compile_lifecycle(self, ("uart", "shared_uart", "repeat", "prefs_before", "powered_status", "hourly", "absent", "storage", + "storage_ids", "i2c", "shared_i2c", "wake_failure", "initial_config_failure", + "wake_output_failure", "wake_rate_failure", "wake_bus_failure"), + ("-DRAK_4631",)) + + def test_actual_radio_and_poe_holds_preserve_the_supply_when_gps_is_off(self): + for defines in (("-DRAK_3401",), ("-DRAK_4631", "-DETHERNET_ENABLED=0")): + with self.subTest(defines=defines): + compile_lifecycle(self, ("held_uart", "held_i2c", "absent"), defines) + + def test_actual_dedicated_gps_control_isolated_from_shared_slot_power(self): + for active in (0, 1): + with self.subTest(active_level=active): + compile_lifecycle(self, ("dedicated",), + ("-DRAK_4631", "-DPIN_GPS_EN=14", f"-DPIN_GPS_EN_ACTIVE={active}")) + + def test_actual_boot_policy_protects_storage_and_poe_even_when_gps_is_not_compiled(self): + for defines in (("-DRAK_4631",), ("-DRAK_3401",), ("-DRAK_4631", "-DETHERNET_ENABLED=0")): + with self.subTest(defines=defines): + compile_lifecycle(self, ("no_gps",), defines, gps=False) + + def test_independent_negative_controls_reject_forced_gps_and_unsafe_rail_ownership(self): + for negative in ("shared_provider_pin", "forced_user_on", "no_wake_settle", "power_before_uart_end", "no_pad_release"): + with self.subTest(negative=negative): + compile_lifecycle(self, ("uart",), ("-DRAK_4631",), negative=negative) + + def test_fixed_shared_spi_or_sd_storage_is_a_rail_owner_without_auto_probe(self): + for store in ("OTA_QSPI_SHARED_WISBLOCK_SPI", "OTA_SD_STORE"): + with self.subTest(store=store): + compile_lifecycle(self, ("fixed_storage",), ("-DRAK_4631", "-D" + store), auto_store=False) + + +if __name__ == "__main__": + unittest.main() diff --git a/variants/rak3401/platformio.ini b/variants/rak3401/platformio.ini index 826e0944..80a1c01a 100644 --- a/variants/rak3401/platformio.ini +++ b/variants/rak3401/platformio.ini @@ -22,7 +22,8 @@ build_flags = ${nrf52_base.build_flags} -D SX126X_CURRENT_LIMIT=140 -D SX126X_RX_BOOSTED_GAIN=1 -D SX126X_REGISTER_PATCH=1 ; Patch register 0x8B5 for improved RX with SKY66122 FEM - -D FORCE_GPS_ALIVE ; WB_IO2 controls the shared 3V3_S rail; stop_gps() must not drop it. + ; WB_IO2 also powers the radio frontend: its immutable rail hold stays HIGH. + ; GPS may stop parsing, but it must never remove this shared radio supply. -D PIN_GPS_EN=-1 ; -1 = no dedicated GPS enable pin (power is the shared 3V3_S rail), same as rak4631 -UENV_INCLUDE_BME680 -D ENV_INCLUDE_BME680_BSEC=1 diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index 24d7bd8c..18d5c290 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -15,7 +15,8 @@ build_flags = ${nrf52_base.build_flags} -D MOTA_HW_ID='"RAK4631"' ; OTA hardware tag (apply refuses a .mota for different hw) -D PIN_BOARD_SCL=14 -D PIN_BOARD_SDA=13 - -D FORCE_GPS_ALIVE ; WB_IO2 is shared 3V3_S; GPS sleep must not power off other slots + ; Saved peripherals.mode defaults to gps-only; shared keeps WB_IO2 alive. + ; The boot power policy automatically protects Ethernet/PoE and external NOR. -D PIN_GPS_EN=-1 -D PIN_OLED_RESET=-1 -D USE_SX1262