diff --git a/boards/nrf52840_s140_v6.ld b/boards/nrf52840_s140_v6.ld index 6dad975b0d8..43e182571b9 100644 --- a/boards/nrf52840_s140_v6.ld +++ b/boards/nrf52840_s140_v6.ld @@ -1,38 +1,49 @@ -/* Linker script to configure memory regions. */ - -SEARCH_DIR(.) -GROUP(-lgcc -lc -lnosys) - -MEMORY -{ - FLASH (rx) : ORIGIN = 0x26000, LENGTH = 0xED000 - 0x26000 - - /* SRAM required by Softdevice depend on - * - Attribute Table Size (Number of Services and Characteristics) - * - Vendor UUID count - * - Max ATT MTU - * - Concurrent connection peripheral + central + secure links - * - Event Len, HVN queue, Write CMD queue - */ - RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 0x20040000 - 0x20006000 -} - -SECTIONS -{ - . = ALIGN(4); - .svc_data : - { - PROVIDE(__start_svc_data = .); - KEEP(*(.svc_data)) - PROVIDE(__stop_svc_data = .); - } > RAM - - .fs_data : - { - PROVIDE(__start_fs_data = .); - KEEP(*(.fs_data)) - PROVIDE(__stop_fs_data = .); - } > RAM -} INSERT AFTER .data; - -INCLUDE "nrf52_common.ld" +/* Linker script to configure memory regions. */ + +SEARCH_DIR(.) +GROUP(-lgcc -lc -lnosys) + +MEMORY +{ + FLASH (rx) : ORIGIN = 0x26000, LENGTH = 0xED000 - 0x26000 + + /* To keep data in RAM across resets */ + PERSISTENT_RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 8 + + /* SRAM required by Softdevice depend on + * - Attribute Table Size (Number of Services and Characteristics) + * - Vendor UUID count + * - Max ATT MTU + * - Concurrent connection peripheral + central + secure links + * - Event Len, HVN queue, Write CMD queue + */ + RAM (rwx) : ORIGIN = 0x20006000 + 8, LENGTH = 0x20040000 - 0x20006000 - 8 +} + +SECTIONS +{ + . = ALIGN(4); + .persistent (NOLOAD) : + { + KEEP(*(.persistent_magic)) + KEEP(*(.persistent_data)) + . = ALIGN(4); + } > PERSISTENT_RAM + + . = ALIGN(4); + .svc_data : + { + PROVIDE(__start_svc_data = .); + KEEP(*(.svc_data)) + PROVIDE(__stop_svc_data = .); + } > RAM + + .fs_data : + { + PROVIDE(__start_fs_data = .); + KEEP(*(.fs_data)) + PROVIDE(__stop_fs_data = .); + } > RAM +} + +INCLUDE "nrf52_common.ld" diff --git a/boards/nrf52840_s140_v6_extrafs.ld b/boards/nrf52840_s140_v6_extrafs.ld index 352610679e1..14eef6114a1 100644 --- a/boards/nrf52840_s140_v6_extrafs.ld +++ b/boards/nrf52840_s140_v6_extrafs.ld @@ -7,18 +7,29 @@ MEMORY { FLASH (rx) : ORIGIN = 0x26000, LENGTH = 0xD4000 - 0x26000 + /* To keep data in RAM across resets */ + PERSISTENT_RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 8 + /* SRAM required by Softdevice depend on * - Attribute Table Size (Number of Services and Characteristics) * - Vendor UUID count * - Max ATT MTU * - Concurrent connection peripheral + central + secure links * - Event Len, HVN queue, Write CMD queue - */ - RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 0x20040000 - 0x20006000 + */ + RAM (rwx) : ORIGIN = 0x20006000 + 8, LENGTH = 0x20040000 - 0x20006000 - 8 } SECTIONS { + . = ALIGN(4); + .persistent (NOLOAD) : + { + KEEP(*(.persistent_magic)) + KEEP(*(.persistent_data)) + . = ALIGN(4); + } > PERSISTENT_RAM + . = ALIGN(4); .svc_data : { @@ -26,13 +37,13 @@ SECTIONS KEEP(*(.svc_data)) PROVIDE(__stop_svc_data = .); } > RAM - + .fs_data : { PROVIDE(__start_fs_data = .); KEEP(*(.fs_data)) PROVIDE(__stop_fs_data = .); } > RAM -} INSERT AFTER .data; +} INCLUDE "nrf52_common.ld" diff --git a/boards/nrf52840_s140_v7.ld b/boards/nrf52840_s140_v7.ld index 6aaeb4034fe..d7626251882 100644 --- a/boards/nrf52840_s140_v7.ld +++ b/boards/nrf52840_s140_v7.ld @@ -7,18 +7,29 @@ MEMORY { FLASH (rx) : ORIGIN = 0x27000, LENGTH = 0xED000 - 0x27000 + /* To keep data in RAM across resets */ + PERSISTENT_RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 8 + /* SRAM required by Softdevice depend on * - Attribute Table Size (Number of Services and Characteristics) * - Vendor UUID count * - Max ATT MTU * - Concurrent connection peripheral + central + secure links * - Event Len, HVN queue, Write CMD queue - */ - RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 0x20040000 - 0x20006000 + */ + RAM (rwx) : ORIGIN = 0x20006000 + 8, LENGTH = 0x20040000 - 0x20006000 - 8 } SECTIONS { + . = ALIGN(4); + .persistent (NOLOAD) : + { + KEEP(*(.persistent_magic)) + KEEP(*(.persistent_data)) + . = ALIGN(4); + } > PERSISTENT_RAM + . = ALIGN(4); .svc_data : { @@ -26,13 +37,13 @@ SECTIONS KEEP(*(.svc_data)) PROVIDE(__stop_svc_data = .); } > RAM - + .fs_data : { PROVIDE(__start_fs_data = .); KEEP(*(.fs_data)) PROVIDE(__stop_fs_data = .); } > RAM -} INSERT AFTER .data; +} INCLUDE "nrf52_common.ld" diff --git a/boards/nrf52840_s140_v7_extrafs.ld b/boards/nrf52840_s140_v7_extrafs.ld index 5956183aa39..3fa3baf87da 100644 --- a/boards/nrf52840_s140_v7_extrafs.ld +++ b/boards/nrf52840_s140_v7_extrafs.ld @@ -7,18 +7,29 @@ MEMORY { FLASH (rx) : ORIGIN = 0x27000, LENGTH = 0xD4000 - 0x27000 + /* To keep data in RAM across resets */ + PERSISTENT_RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 8 + /* SRAM required by Softdevice depend on * - Attribute Table Size (Number of Services and Characteristics) * - Vendor UUID count * - Max ATT MTU * - Concurrent connection peripheral + central + secure links * - Event Len, HVN queue, Write CMD queue - */ - RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 0x20040000 - 0x20006000 + */ + RAM (rwx) : ORIGIN = 0x20006000 + 8, LENGTH = 0x20040000 - 0x20006000 - 8 } SECTIONS { + . = ALIGN(4); + .persistent (NOLOAD) : + { + KEEP(*(.persistent_magic)) + KEEP(*(.persistent_data)) + . = ALIGN(4); + } > PERSISTENT_RAM + . = ALIGN(4); .svc_data : { @@ -26,13 +37,13 @@ SECTIONS KEEP(*(.svc_data)) PROVIDE(__stop_svc_data = .); } > RAM - + .fs_data : { PROVIDE(__start_fs_data = .); KEEP(*(.fs_data)) PROVIDE(__stop_fs_data = .); } > RAM -} INSERT AFTER .data; +} INCLUDE "nrf52_common.ld" diff --git a/boards/seeed_wio_tracker_l2.json b/boards/seeed_wio_tracker_l2.json new file mode 100644 index 00000000000..706ed4c1701 --- /dev/null +++ b/boards/seeed_wio_tracker_l2.json @@ -0,0 +1,43 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "memory_type": "qio_opi", + "partitions": "default_16MB.csv" + }, + "core": "esp32", + "extra_flags": [ + "-DBOARD_HAS_PSRAM", + "-DARDUINO_USB_CDC_ON_BOOT=1", + "-DARDUINO_USB_MODE=1", + "-DARDUINO_RUNNING_CORE=1", + "-DARDUINO_EVENT_RUNNING_CORE=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "psram_type": "opi", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "esp32s3" + }, + "connectivity": ["wifi", "bluetooth", "lora"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino"], + "name": "Seeed Wio Tracker L2 (16 MB flash, 8 MB OPI PSRAM)", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://www.seeedstudio.com/", + "vendor": "Seeed Studio" +} diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 9fde4d436e5..94cbf290bb2 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -285,9 +285,12 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Parameters:** - `state`: `on`|`off` +**Default:** `off` for fresh installs; a build with `-D RADIO_FEM_RXGAIN=1` defaults to `on`. A saved setting wins. + **Notes:** - This controls the external LoRa FEM receive-path LNA where the board supports it. - This is separate from `radio.rxgain`, which controls the radio chip receive gain mode. +- Boards without a controllable LNA answer `Error: unsupported`. --- @@ -445,7 +448,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore --- -#### View or change this node's power saving flag (Repeater Only) +#### View or change this node's power saving flag (Repeater, room server, sensor and companion) **Usage:** - `powersaving` - `powersaving on` @@ -455,10 +458,12 @@ This document provides an overview of CLI commands that can be sent to MeshCore - `on`: enable power saving - `off`: disable power saving -**Default:** `off` +**Default:** `on` for fresh installs **Note:** When enabled, device enters sleep mode between radio transmissions +**Companion:** on ESP32 the flag switches automatic light sleep at once (no reboot). Light sleep stays off on USB-serial, hardware-serial, Ethernet and ESP-NOW builds, and while a WiFi network is configured; those builds still scale the CPU clock (80/40 MHz). ESP32-C6 uses the upstream Arduino 3.x core without power management, so it gets neither. nRF52 companions sleep between events regardless of the flag. + --- ### Routing @@ -1196,3 +1201,35 @@ Ethernet support is available on RAK4631 boards with a RAK13800 (W5100S) Etherne - Connect with any TCP client (e.g. `nc`, PuTTY) to access the same CLI available over serial. --- + +### Periodic reboot + +`get reboot.interval` / `set reboot.interval <0-255>`: hours since boot (0 disables). Repeater, Room Server and Sensor persist this setting. A due reboot waits for current TX and an active OTA session to finish. + +--- + +### RX power saving (RXPS) + +Repeater, Room Server, Sensor and Companion. SX1262 and LR1110 receive in a duty cycle; LR1121, LR2021 and SX127x stay in continuous RX (`supported=no`). + +- `get radio.rxps`: `desired` (saved on/off), `effective` (`armed` or `continuous`), `supported`, `err` (last error), `fail` (arm failures), `level`, `catch` (preamble symbols still caught), `preamble`, `rx`/`sleep` (µs). `erx`/`eslp` appear when the driver had to clamp the periods. +- `set radio.rxps off|on|conservative|balanced|max|overdrive|riskyWorkingMax`: `on` = `conservative` = level 3; `balanced` = level 6; `max` = level 8; `overdrive` (9) and `riskyWorkingMax` (10) run outside the datasheet timing rule. +- `set radio.rxps level <1-10> [preamble <16|32>]`, `set radio.rxps max|overdrive|riskyWorkingMax preamble <16|32>` +- `set radio.rxps `: manual periods, 1000–30000000 µs, kept across SF/BW changes. + +**Default:** Balanced (level 6, preamble 16) for fresh installs. A saved setting, including `off`, wins. + +**Errors:** a profile that does not fit the active SF/BW is rejected, or, after a radio change, leaves the node in continuous RX with `err=-10001` while the saved choice is kept. Other nonzero `err` values come from the radio driver; after three failed arm attempts the node stays in continuous RX until the setting changes. `set radio` takes effect at reboot; until then the windows of the running modulation stay in use. + +--- + +### Repeater return path (outpath) + +Remote admin console of a Repeater only; the command needs the requesting client, so it fails on the local serial console. + +- `get outpath` +- `set outpath flood|direct|clear` +- `set outpath ,,...`: 1-, 2- or 3-byte hop hashes, e.g. `set outpath 12,34` or `set outpath aabbcc,ddeeff`. + +The path applies to replies to this client. `flood` survives reboot, login and learned PATH messages; `direct` and a hop list are replaced when the client logs in again by flood or a new PATH arrives; `clear` returns to automatic path learning. Saved with the ACL about 5 s later. + diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f32ea67aecf..c640cd0cc51 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -925,7 +925,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.tx_power_dbm = LORA_TX_POWER; _prefs.gps_enabled = 0; // GPS disabled by default _prefs.gps_interval = 0; // No automatic GPS updates by default - _prefs.radio_fem_rxgain = 1; + _prefs.radio_fem_rxgain = RADIO_FEM_RXGAIN; // external RX LNA off unless the build or a saved value says otherwise #ifdef RADIO_FEM_TXGAIN _prefs.radio_fem_txgain = RADIO_FEM_TXGAIN; // board-specific default for PA #else @@ -940,6 +940,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.rx_boosted_gain = 1; // enabled by default #endif #endif + + _prefs.powersaving_enabled = 1; // fresh installs; a saved power.pwr_sav_en overrides it } void MyMesh::begin() { @@ -1003,6 +1005,7 @@ void MyMesh::begin() { _store->loadChannels(this); radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + _prefs.getRadioPrefs()->applyRxPowerSaving(_prefs.sf, _prefs.bw); radio_driver.setTxPower(_prefs.tx_power_dbm); radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); @@ -1448,6 +1451,7 @@ void MyMesh::handleCmdFrame(size_t len) { savePrefs(); radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + _prefs.getRadioPrefs()->applyRxPowerSaving(_prefs.sf, _prefs.bw); MESH_DEBUG_PRINTLN("OK: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf, (uint32_t)cr); @@ -2181,6 +2185,17 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* } #endif + if (strcmp(command, "powersaving on") == 0 || strcmp(command, "powersaving off") == 0) { + _prefs.powersaving_enabled = strcmp(command, "powersaving on") == 0 ? 1 : 0; + savePrefs(); + strcpy(reply, _prefs.powersaving_enabled ? "on" : "off"); + return true; + } + if (strcmp(command, "powersaving") == 0) { + strcpy(reply, _prefs.powersaving_enabled ? "on" : "off"); + return true; + } + if (strcmp(command, "board") == 0) { strcpy(reply, board.getManufacturerName()); return true; @@ -2450,5 +2465,5 @@ bool MyMesh::advert() { // To check if there is pending work bool MyMesh::hasPendingWork() const { - return _mgr->getOutboundTotal() > 0 || dirty_contacts_expiry != 0; + return _mgr->getOutboundTotal() > 0 || dirty_contacts_expiry != 0 || isTransmitting() || _prefs.getRadioPrefs()->isRxPowerSavingCalibrationActive(); } diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 46a3dbb7c98..dfe5c60e46f 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -37,6 +37,7 @@ class NodePrefs : public ConfigSerializer { // persisted to file uint32_t gps_interval = 0; // GPS read interval in seconds uint8_t autoadd_config = 0; // bitmask for auto-add contacts config uint8_t rx_boosted_gain = 0; // SX126x RX boosted gain mode (0=power saving, 1=boosted) + RxPowerSavingConfig rxps; uint8_t radio_fem_rxgain = 0; // external LoRa FEM RX gain (LNA) uint8_t radio_fem_txgain = 0; // external LoRa FEM TX gain (low by default) uint8_t _client_repeat = 0; // DEPRECATED -> use repeat.disable_fwd @@ -48,6 +49,7 @@ class NodePrefs : public ConfigSerializer { // persisted to file char default_scope_name[31]; uint8_t default_scope_key[16]; int8_t tz_offset = 0; + uint8_t powersaving_enabled = 1; // boolean #ifdef ENABLE_WIFI_INTERFACE #ifndef WIFI_SSID #define WIFI_SSID "" @@ -64,6 +66,11 @@ class NodePrefs : public ConfigSerializer { // persisted to file NodePrefs* _parent; protected: void structure() override { + def("rxps_en", _parent->rxps.enabled); + def("rxps_rx_us", _parent->rxps.rx_us); + def("rxps_sleep_us", _parent->rxps.sleep_us); + def("rxps_level", _parent->rxps.level); + def("rxps_preamble", _parent->rxps.preamble); def("freq", _parent->freq); def("bw", _parent->bw); def("sf", _parent->sf); @@ -84,6 +91,7 @@ class NodePrefs : public ConfigSerializer { // persisted to file } public: RadioPrefs(NodePrefs* parent) : _parent(parent) { } + RxPowerSavingConfig* getRxPowerSavingConfig() override { return &_parent->rxps; } // CommonRadioPrefs interface float getFreq() const override { return _parent->freq; } @@ -172,6 +180,17 @@ class NodePrefs : public ConfigSerializer { // persisted to file }; CompanionPrefs companion; + class PowerPrefs : public ConfigSerializer { // same keys as CommonCLI + NodePrefs* _parent; + protected: + void structure() override { + def("pwr_sav_en", _parent->powersaving_enabled); + } + public: + PowerPrefs(NodePrefs* parent) : _parent(parent) { } + }; + PowerPrefs power; + DynamicConfigSerializer custom; #ifdef ENABLE_WIFI_INTERFACE @@ -200,17 +219,19 @@ class NodePrefs : public ConfigSerializer { // persisted to file def("gps", gps); def("repeat", repeat); def("comp", companion); + def("power", power); def("custom", custom); #ifdef ENABLE_WIFI_INTERFACE def("wifi", wifi); #endif } public: - NodePrefs() : radio(this), gps(this), companion(this), custom(&radio) + NodePrefs() : radio(this), gps(this), companion(this), power(this), custom(&radio) #ifdef ENABLE_WIFI_INTERFACE , wifi(this) #endif { + rxps.enabled = 1; // fresh installs: Balanced L6/p16; loaded fields override this node_name[0] = 0; default_scope_name[0] = 0; memset(default_scope_key, 0, sizeof(default_scope_key)); @@ -220,6 +241,7 @@ class NodePrefs : public ConfigSerializer { // persisted to file void setRepeatEn(bool en) { repeat.disable_fwd = en ? 0 : 1; } CommonRadioPrefs* getRadioPrefs() { return &radio; } + const CommonRadioPrefs* getRadioPrefs() const { return &radio; } KeyValueStore* getCustom() { return &custom; } bool isDirty() const override { return ConfigSerializer::isDirty() || radio.isDirty() || custom.isDirty(); } diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 839d4958336..1af3b281d88 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -2,6 +2,11 @@ #include #include "MyMesh.h" +#ifdef ESP32_PLATFORM +#include "esp_pm.h" +#include "esp_bt.h" +#endif + // Believe it or not, this std C function is busted on some platforms! static uint32_t _atoi(const char* sp) { uint32_t n = 0; @@ -132,6 +137,44 @@ void halt() { bool wifi_enabled = false; // set at boot from prefs; false also when the effective SSID is blank #endif +#if defined(ESP32_PLATFORM) +// ESP32 power management: frequency scaling, plus automatic light sleep while +// powersaving is on and nothing needs the CPU clock running. +static void applyCompanionPowerSaving() { + bool light_sleep = the_mesh.getNodePrefs()->powersaving_enabled != 0; +#ifndef WRAPPER_CLASS + light_sleep = false; // ESP-NOW: the Wi-Fi receiver is the mesh radio +#endif +#if (defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT) || defined(ENABLE_USB_INTERFACE) + light_sleep = false; // USB CDC serial does not survive automatic light sleep +#endif +#if defined(SERIAL_RX) || defined(ETHERNET_ENABLED) + light_sleep = false; // UART RX and the Ethernet INT pin cannot wake the CPU +#endif +#ifdef ENABLE_WIFI_INTERFACE + if (wifi_enabled && wifi_ssid[0]) light_sleep = false; // WiFi is up: keep the modem awake +#endif + +#if CONFIG_IDF_TARGET_ESP32C3 + esp_pm_config_esp32c3_t pm_config; +#elif CONFIG_IDF_TARGET_ESP32S3 + esp_pm_config_esp32s3_t pm_config; +#elif CONFIG_IDF_TARGET_ESP32 + esp_pm_config_esp32_t pm_config; +#else + esp_pm_config_t pm_config; +#endif + pm_config = { .max_freq_mhz = 80, .min_freq_mhz = 40, .light_sleep_enable = light_sleep }; + + esp_err_t err = esp_pm_configure(&pm_config); + if (err == ESP_OK) { + POWERSAVING_DEBUG_PRINTLN("Power management configured, light sleep %s", light_sleep ? "on" : "off"); + } else { + Serial.printf("Power Management failed to configure: %d\r\n", err); + } +} +#endif + void setup() { Serial.begin(115200); board.begin(); @@ -287,6 +330,13 @@ void setup() { #endif board.onBootComplete(); + +#if defined(ESP32_PLATFORM) + #if defined(BLE_PIN_CODE) && !CONFIG_IDF_TARGET_ESP32C6 + esp_bt_sleep_enable(); // let the BLE controller sleep between connection events + #endif + applyCompanionPowerSaving(); +#endif } void loop() { @@ -305,9 +355,22 @@ void loop() { if (!the_mesh.hasPendingWork()) { #if defined(NRF52_PLATFORM) board.sleep(0); // nrf ignores seconds param, sleeps whenever possible +#elif defined(ESP32_PLATFORM) + if (the_mesh.getNodePrefs()->powersaving_enabled && + !interface_manager.isReadBusy() && !interface_manager.isWriteBusy()) { + vTaskDelay(pdMS_TO_TICKS(10)); // yield to the idle task: lower clock, or light sleep when enabled + } #endif } +#if defined(ESP32_PLATFORM) + static uint8_t applied_powersaving = the_mesh.getNodePrefs()->powersaving_enabled; + if (the_mesh.getNodePrefs()->powersaving_enabled != applied_powersaving) { // changed via CLI + applied_powersaving = the_mesh.getNodePrefs()->powersaving_enabled; + applyCompanionPowerSaving(); + } +#endif + #ifdef ENABLE_WIFI_INTERFACE if (wifi_enabled) { // RP2040 has no WiFi event callbacks, so poll the link state instead diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index bc25de79c17..7d134005cda 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1,3 +1,5 @@ +#include +static Uptime ui_uptime; #include "UITask.h" #include #include "../MyMesh.h" @@ -123,7 +125,8 @@ class HomeScreen : public UIScreen { bool discovery_disp_names = true; // by default desplay names if available (removes SNR_O) #endif - void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) { + // draws the battery and the uptime below it; returns the uptime's left x + int renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) { // Convert millivolts to percentage #ifndef BATT_MIN_MILLIVOLTS #define BATT_MIN_MILLIVOLTS 3000 @@ -174,6 +177,28 @@ class HomeScreen : public UIScreen { display.drawXbm(iconX - (charging ? 18 : 9), iconY + 1, muted_icon, 8, 8); } #endif + uint64_t uptimeMinutes = ui_uptime.milliseconds() / 60000ULL; + + unsigned long days = uptimeMinutes / 1440; + unsigned long hours = (uptimeMinutes % 1440) / 60; + unsigned long minutes = uptimeMinutes % 60; + char uptime[16]; + if (days > 0) { + snprintf(uptime, sizeof(uptime), "%lud %luh", days, hours); + } else if (hours > 0) { + snprintf(uptime, sizeof(uptime), "%luh %lum", hours, minutes); + } else { + snprintf(uptime, sizeof(uptime), "%lum", minutes); + } + + display.setColor(UIColor::title_txt); + display.setTextSize(1); + int uptimeWidth = display.getTextWidth(uptime); + int uptimeX = display.width() - uptimeWidth - 2; + int uptimeY = iconY + iconHeight + 1; + display.setCursor(uptimeX, uptimeY); + display.print(uptime); + return uptimeX; } CayenneLPP sensors_lpp; @@ -261,7 +286,7 @@ class HomeScreen : public UIScreen { display.print(filtered_name); // battery voltage - renderBatteryIndicator(display, _task->getBattMilliVolts()); + int uptimeX = renderBatteryIndicator(display, _task->getBattMilliVolts()); // curr page indicator if (UIColor::title_bkg == UIColor::window_bkg) { @@ -270,8 +295,12 @@ class HomeScreen : public UIScreen { display.setColor(UIColor::title_bkg); } int y = 14; - int x = display.width() / 2 - 5 * (HomePage::Count-1); - for (uint8_t i = 0; i < HomePage::Count; i++, x += 10) { + int x = 5; // left-aligned, leaving the right side for the uptime + int step = 10; + if (HomePage::Count > 1 && x + step * (HomePage::Count - 1) + 4 > uptimeX - 2) { + step = (uptimeX - 2 - 4 - x) / (HomePage::Count - 1); // squeeze the dots rather than overlap the uptime + } + for (uint8_t i = 0; i < HomePage::Count; i++, x += step) { if (i == _page) { display.fillRect(x-1, y-1, 4, 4); } else { @@ -921,6 +950,7 @@ bool UITask::isButtonPressed() const { } void UITask::loop() { + ui_uptime.tick(millis()); char c = 0; #if UI_HAS_JOYSTICK int ev = user_btn.check(); diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 61b6c149e98..60bba452acf 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -128,7 +128,7 @@ uint8_t MyMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t* secr } } - if (is_flood) { + if (is_flood && client->out_path_len != OUT_PATH_FORCE_FLOOD) { client->out_path_len = OUT_PATH_UNKNOWN; // need to rediscover out_path } @@ -595,9 +595,10 @@ void MyMesh::onAnonDataRecv(mesh::Packet *packet, const uint8_t *secret, const m // a DIRECT login can reply via the stored out_path, as onPeerDataRecv() does for REQ ClientInfo* client = acl.getClient(sender.pub_key, PUB_KEY_SIZE); - bool have_out_path = client != NULL && client->out_path_len != OUT_PATH_UNKNOWN; + bool have_out_path = client != NULL && mesh::Packet::isValidPathLen(client->out_path_len); - auto route = mesh::chooseReplyRoute(packet->isRouteFlood(), reply_path_len != 0xFF, have_out_path); + const bool force_flood = client && client->out_path_len == OUT_PATH_FORCE_FLOOD; + auto route = mesh::chooseReplyRoute(packet->isRouteFlood(), !force_flood && reply_path_len != 0xFF, have_out_path); if (route == mesh::REPLY_ROUTE_PATH_RETURN) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response @@ -689,7 +690,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len); if (reply) { - if (client->out_path_len != OUT_PATH_UNKNOWN) { // we have an out_path, so send DIRECT + if (mesh::Packet::isValidPathLen(client->out_path_len)) { // we have an out_path, so send DIRECT sendDirect(reply, client->out_path, client->out_path_len, SERVER_RESPONSE_DELAY); } else { sendFloodReply(reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); @@ -722,7 +723,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, mesh::Packet *ack = createAck(ack_hash); if (ack) { - if (client->out_path_len == OUT_PATH_UNKNOWN) { + if (!mesh::Packet::isValidPathLen(client->out_path_len)) { sendFloodReply(ack, TXT_ACK_DELAY, packet->getPathHashSize()); } else { sendDirect(ack, client->out_path, client->out_path_len, TXT_ACK_DELAY); @@ -736,7 +737,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, if (is_retry) { *reply = 0; } else { - handleCommand(sender_timestamp, command, reply); + handleCommand(sender_timestamp, command, reply, client); } int text_len = strlen(reply); if (text_len > 0) { @@ -750,7 +751,7 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, auto reply = createDatagram(PAYLOAD_TYPE_TXT_MSG, client->id, secret, temp, 5 + text_len); if (reply) { - if (client->out_path_len == OUT_PATH_UNKNOWN) { + if (!mesh::Packet::isValidPathLen(client->out_path_len)) { sendFloodReply(reply, CLI_REPLY_DELAY_MILLIS, packet->getPathHashSize()); } else { sendDirect(reply, client->out_path, client->out_path_len, CLI_REPLY_DELAY_MILLIS); @@ -773,7 +774,7 @@ bool MyMesh::onPeerPathRecv(mesh::Packet *packet, int sender_idx, const uint8_t auto client = acl.getClientByIdx(i); // store a copy of path, for sendDirect() - client->out_path_len = mesh::Packet::copyPath(client->out_path, path, path_len); + learnOutPath(client->out_path, client->out_path_len, path, path_len); client->last_activity = getRTCClock()->getCurrentTime(); } else { MESH_DEBUG_PRINTLN("onPeerPathRecv: invalid peer idx: %d", i); @@ -933,7 +934,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.rx_boosted_gain = 1; // enabled by default; #endif #endif - _prefs.radio_fem_rxgain = 1; + _prefs.radio_fem_rxgain = RADIO_FEM_RXGAIN; // external RX LNA off unless the build or a saved value says otherwise _prefs.radio_fem_txgain = 0; pending_discover_tag = 0; @@ -978,6 +979,7 @@ void MyMesh::begin(FILESYSTEM *fs) { #endif radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + _prefs.getRadioPrefs()->applyRxPowerSaving(_prefs.sf, _prefs.bw); radio_driver.setTxPower(_prefs.tx_power_dbm); radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); @@ -1195,7 +1197,7 @@ void MyMesh::clearStats() { ((SimpleMeshTables *)getTables())->resetStats(); } -void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply) { +void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply, ClientInfo* sender) { if (region_load_active) { if (StrHelper::isBlank(command)) { // empty/blank line, signal to terminate 'load' operation region_map = temp_map; // copy over the temp instance as new current map @@ -1272,6 +1274,34 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply Serial.printf("\n"); } reply[0] = 0; + } else if (strcmp(command, "get outpath") == 0 + || strcmp(command, "set outpath") == 0 + || strncmp(command, "set outpath ", 12) == 0) { + bool is_get = strncmp(command, "get ", 4) == 0; + if (sender == NULL) { + strcpy(reply, "Err - command needs remote client context"); + } else if (is_get) { + formatPathReply(sender->out_path, sender->out_path_len, reply, 160); + } else { + char* spec = command + 11; // length of "set outpath" + if (*spec == ' ') spec++; + + uint8_t path[MAX_PATH_SIZE]; + uint8_t path_len = OUT_PATH_UNKNOWN; + const char* err = NULL; + if (!parsePathCommand(spec, path, path_len, err)) { + strcpy(reply, err ? err : "Err - invalid path"); + } else { + if (path_len == OUT_PATH_UNKNOWN || path_len == OUT_PATH_FORCE_FLOOD) { + memset(sender->out_path, 0, sizeof(sender->out_path)); + sender->out_path_len = path_len; + } else { + sender->out_path_len = mesh::Packet::copyPath(sender->out_path, path, path_len); + } + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + formatPathReply(sender->out_path, sender->out_path_len, reply, 160); + } + } } else if (memcmp(command, "discover.neighbors", 18) == 0) { const char* sub = command + 18; while (*sub == ' ') sub++; @@ -1310,12 +1340,14 @@ void MyMesh::loop() { if (set_radio_at && millisHasNowPassed(set_radio_at)) { // apply pending (temporary) radio params set_radio_at = 0; // clear timer radio_driver.setParams(pending_freq, pending_bw, pending_sf, pending_cr); + _prefs.getRadioPrefs()->applyRxPowerSaving(pending_sf, pending_bw); MESH_DEBUG_PRINTLN("Temp radio params"); } if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { // revert radio params to orig revert_radio_at = 0; // clear timer radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + _prefs.getRadioPrefs()->applyRxPowerSaving(_prefs.sf, _prefs.bw); MESH_DEBUG_PRINTLN("Radio params restored"); } @@ -1336,5 +1368,6 @@ bool MyMesh::hasPendingWork() const { #if defined(WITH_BRIDGE) if (bridge.isRunning()) return true; // bridge needs WiFi radio, can't sleep #endif - return _mgr->getOutboundTotal() > 0; + return _mgr->getOutboundTotal() > 0 || _mgr->getInboundTotal() > 0 || // rxdelay packets wait in the inbound queue + isTransmitting() || _prefs.getRadioPrefs()->isRxPowerSavingCalibrationActive(); } diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index dc89325861c..260a4154909 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -226,7 +226,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void saveIdentity(const mesh::LocalIdentity& new_id) override; void clearStats() override; - void handleCommand(uint32_t sender_timestamp, char* command, char* reply); + void handleCommand(uint32_t sender_timestamp, char* command, char* reply, ClientInfo* sender = nullptr); void loop(); #if defined(WITH_BRIDGE) diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index e0b6a41df5f..f87c1a3c6ef 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -106,6 +106,13 @@ void UITask::renderCurrScreen() { _display->setCursor(0, 30); sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); _display->print(tmp); + + _display->setCursor(0, 40); + const unsigned mv = _board->getBattMilliVolts(); + snprintf(tmp, sizeof(tmp), "BAT: %u.%03u V", mv / 1000, mv % 1000); + _display->print(tmp); + _display->setCursor(0, 50); + _display->print(_node_prefs->powersaving_enabled ? "PowerSaving: ON" : "PowerSaving: off"); } } diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index d475b13d842..d8950dcd069 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -1,3 +1,5 @@ +#include +static PeriodicReboot periodic_reboot; #include // needed for PlatformIO #include @@ -214,4 +216,9 @@ void loop() { } #endif } + if (periodic_reboot.due(the_mesh.getNodePrefs()->reboot_interval, millis(), + the_mesh.isTransmitting(), board.isOTAUpdateActive())) { + board.reboot(); + } + } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index da4c776c0ea..7072e5636a2 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1,4 +1,5 @@ #include "MyMesh.h" +#include #define REPLY_DELAY_MILLIS 1500 #define PUSH_NOTIFY_DELAY_MILLIS 2000 @@ -681,7 +682,7 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.rx_boosted_gain = 1; // enabled by default; #endif #endif - _prefs.radio_fem_rxgain = 1; + _prefs.radio_fem_rxgain = RADIO_FEM_RXGAIN; // external RX LNA off unless the build or a saved value says otherwise _prefs.radio_fem_txgain = 0; next_post_idx = 0; @@ -723,6 +724,7 @@ void MyMesh::begin(FILESYSTEM *fs) { } radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + _prefs.getRadioPrefs()->applyRxPowerSaving(_prefs.sf, _prefs.bw); radio_driver.setTxPower(_prefs.tx_power_dbm); radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); @@ -1057,12 +1059,14 @@ void MyMesh::loop() { if (set_radio_at && millisHasNowPassed(set_radio_at)) { // apply pending (temporary) radio params set_radio_at = 0; // clear timer radio_driver.setParams(pending_freq, pending_bw, pending_sf, pending_cr); + _prefs.getRadioPrefs()->applyRxPowerSaving(pending_sf, pending_bw); MESH_DEBUG_PRINTLN("Temp radio params"); } if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { // revert radio params to orig revert_radio_at = 0; // clear timer radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + _prefs.getRadioPrefs()->applyRxPowerSaving(_prefs.sf, _prefs.bw); MESH_DEBUG_PRINTLN("Radio params restored"); } @@ -1079,3 +1083,17 @@ void MyMesh::loop() { uptime_millis += now - last_millis; last_millis = now; } + +bool MyMesh::hasPendingWork() const { + return _mgr->getOutboundTotal() > 0 || _mgr->getInboundTotal() > 0 || // rxdelay packets wait in the inbound queue + isTransmitting() || _prefs.getRadioPrefs()->isRxPowerSavingCalibrationActive(); +} + +uint32_t MyMesh::getSleepBudgetMillis() { + IdleSleepBudget budget(millis()); + budget.until(next_local_advert); budget.until(next_flood_advert); + budget.until(set_radio_at); budget.until(revert_radio_at); + budget.until(dirty_contacts_expiry); + if (acl.getNumClients() > 0) budget.until(next_push); + return budget.millis(); +} diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 4dee8bdbf40..f9cfc250796 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -229,4 +229,6 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks { void clearStats() override; void handleCommand(uint32_t sender_timestamp, char* command, char* reply); void loop(); + bool hasPendingWork() const; + uint32_t getSleepBudgetMillis(); }; diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index 65fc55ef7c4..5eb5dbd1237 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -1,7 +1,10 @@ +#include +static PeriodicReboot periodic_reboot; #include // needed for PlatformIO #include #include "MyMesh.h" +#include #ifdef ETHERNET_ENABLED #define ETHERNET_CLI_BANNER "MeshCore Room Server CLI" @@ -166,4 +169,12 @@ void loop() { #ifdef HAS_EXTERNAL_WATCHDOG external_watchdog.loop(); #endif + if (periodic_reboot.due(the_mesh.getNodePrefs()->reboot_interval, millis(), + the_mesh.isTransmitting(), board.isOTAUpdateActive())) { + board.reboot(); + } + sleepRoleIfIdle(the_mesh.getNodePrefs()->powersaving_enabled, + the_mesh.hasPendingWork(), command[0] != 0, + the_mesh.getSleepBudgetMillis()); + } diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index b186066810d..5735dd2e8a7 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -1,4 +1,5 @@ #include "SensorMesh.h" +#include #include #include @@ -820,7 +821,7 @@ SensorMesh::SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::Millise _prefs.gps_enabled = 0; _prefs.gps_interval = 0; _prefs.advert_loc_policy = ADVERT_LOC_PREFS; - _prefs.radio_fem_rxgain = 1; + _prefs.radio_fem_rxgain = RADIO_FEM_RXGAIN; // external RX LNA off unless the build or a saved value says otherwise _prefs.radio_fem_txgain = 0; memset(default_scope.key, 0, sizeof(default_scope.key)); @@ -856,6 +857,7 @@ void SensorMesh::begin(FILESYSTEM* fs) { } radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + _prefs.getRadioPrefs()->applyRxPowerSaving(_prefs.sf, _prefs.bw); radio_driver.setTxPower(_prefs.tx_power_dbm); board.attachDynamicPrefs(_prefs.getCustom()); @@ -1018,12 +1020,14 @@ void SensorMesh::loop() { if (set_radio_at && millisHasNowPassed(set_radio_at)) { // apply pending (temporary) radio params set_radio_at = 0; // clear timer radio_driver.setParams(pending_freq, pending_bw, pending_sf, pending_cr); + _prefs.getRadioPrefs()->applyRxPowerSaving(pending_sf, pending_bw); MESH_DEBUG_PRINTLN("Temp radio params"); } if (revert_radio_at && millisHasNowPassed(revert_radio_at)) { // revert radio params to orig revert_radio_at = 0; // clear timer radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + _prefs.getRadioPrefs()->applyRxPowerSaving(_prefs.sf, _prefs.bw); MESH_DEBUG_PRINTLN("Radio params restored"); } @@ -1114,3 +1118,20 @@ void SensorMesh::loop() { dirty_contacts_expiry = 0; } } + +bool SensorMesh::hasPendingWork() const { + return _mgr->getOutboundTotal() > 0 || _mgr->getInboundTotal() > 0 || // rxdelay packets wait in the inbound queue + isTransmitting() || _prefs.getRadioPrefs()->isRxPowerSavingCalibrationActive(); +} + +uint32_t SensorMesh::getSleepBudgetMillis() { + IdleSleepBudget budget(millis()); + budget.until(next_local_advert); budget.until(next_flood_advert); + budget.until(set_radio_at); budget.until(revert_radio_at); + budget.until(dirty_contacts_expiry); + for (int i = 0; i < num_alert_tasks; i++) budget.until(alert_tasks[i]->send_expiry); + uint32_t now_secs = getRTCClock()->getCurrentTime(); + if (now_secs >= last_read_time + SENSOR_READ_INTERVAL_SECS) return 0; + budget.until(millis() + (last_read_time + SENSOR_READ_INTERVAL_SECS - now_secs) * 1000UL); // next sensor read + return budget.millis(); +} diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 590ca0c7657..e77c9a3f63b 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -51,6 +51,8 @@ class SensorMesh : public mesh::Mesh, public CommonCLICallbacks { SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables); void begin(FILESYSTEM* fs); void loop(); + bool hasPendingWork() const; + uint32_t getSleepBudgetMillis(); void handleCommand(ClientInfo* from, uint32_t sender_timestamp, char* command, char* reply); // CommonCLI callbacks diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index 66991daada5..d35e2378b5f 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -1,4 +1,7 @@ +#include +static PeriodicReboot periodic_reboot; #include "SensorMesh.h" +#include #ifdef DISPLAY_CLASS #include "UITask.h" @@ -164,4 +167,12 @@ void loop() { #ifdef HAS_EXTERNAL_WATCHDOG external_watchdog.loop(); #endif + if (periodic_reboot.due(the_mesh.getNodePrefs()->reboot_interval, millis(), + the_mesh.isTransmitting(), board.isOTAUpdateActive())) { + board.reboot(); + } + sleepRoleIfIdle(the_mesh.getNodePrefs()->powersaving_enabled, + the_mesh.hasPendingWork(), command[0] != 0, + the_mesh.getSleepBudgetMillis()); + } diff --git a/platformio.ini b/platformio.ini index f296f838213..87efd7e0cf1 100644 --- a/platformio.ini +++ b/platformio.ini @@ -19,8 +19,7 @@ monitor_speed = 115200 lib_deps = SPI Wire - ;jgromes/RadioLib @ ^7.7.1 - https://github.com/jgromes/RadioLib.git#6d8934836678d8894e3d556550475b37dce3e2b6 + jgromes/RadioLib @ 7.8.1 rweather/Crypto @ ^0.4.0 adafruit/RTClib @ ^2.1.3 melopero/Melopero RV3028 @ ^1.1.0 @@ -58,6 +57,9 @@ build_src_filter = [esp32_base] extends = arduino_base platform = platformio/espressif32@6.11.0 +; PowerSaving Arduino Core 2.0.17 for low power ESP32 boards +; https://github.com/IoTThinks/powersaving-arduino-lib +platform_packages = framework-arduinoespressif32 @ https://github.com/IoTThinks/powersaving-arduino-lib/releases/download/2.0.17/esp32-2.0.17.zip monitor_filters = esp32_exception_decoder extra_scripts = merge-bin.py build_flags = ${arduino_base.build_flags} @@ -73,8 +75,14 @@ lib_deps = ; esp32c6 uses arduino framework 3.x ; WARNING: experimental. May not work as stable as other platforms. [esp32c6_base] -extends = esp32_base +extends = arduino_base platform = https://github.com/pioarduino/platform-espressif32/releases/download/53.03.13-1/platform-espressif32.zip +monitor_filters = esp32_exception_decoder +extra_scripts = merge-bin.py +build_flags = + ${arduino_base.build_flags} + -D ESP32_PLATFORM +build_src_filter = ${arduino_base.build_src_filter} ; ----------------- NRF52 --------------------- @@ -92,7 +100,10 @@ build_flags = ${arduino_base.build_flags} -D NRF52_PLATFORM -D LFS_NO_ASSERT=1 -D EXTRAFS=1 - -D USE_CC310_HW_CRYPTO=1 +; CC310 hardware crypto (upstream PR #2824) disabled until its benefit is proven: +; no measurable current difference on RAK4631 with PowerSaving, and per-call +; nRFCrypto begin()/end() was questioned in review. Re-enable by uncommenting. +; -D USE_CC310_HW_CRYPTO=1 lib_deps = ${arduino_base.lib_deps} https://github.com/oltaco/CustomLFS#0.2.3 @@ -167,13 +178,15 @@ build_flags = -std=c++17 -I src -I test/mocks test_build_src = yes -test_ignore = test_kiss_modem +test_ignore = test_kiss_modem, test_ssd1306 build_src_filter = -<*> +<../src/Utils.cpp> +<../src/Packet.cpp> +<../src/helpers/ConfigSerializer.cpp> +<../src/helpers/DynamicConfigSerializer.cpp> + +<../src/helpers/radiolib/RXPowerSaving.cpp> + +<../src/helpers/radiolib/RXPowerSavingCLI.cpp> lib_deps = google/googletest @ 1.17.0 @@ -191,3 +204,12 @@ build_src_filter = +<../examples/kiss_modem/KissModem.cpp> lib_deps = google/googletest @ 1.17.0 + +[env:native_ssd1306] +extends = env:native +test_ignore = +test_filter = test_ssd1306 +build_flags = -std=c++17 -I test/display_mocks -I test/mocks -I src +; the suite #includes SSD1306Display.cpp itself; the display mocks shadow the +; Arduino headers the env:native sources need, so build none of them +build_src_filter = -<*> diff --git a/src/Dispatcher.h b/src/Dispatcher.h index aad6cba3ec2..76fc22a70f1 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -98,6 +98,7 @@ class PacketManager { virtual Packet* removeOutboundByIdx(int i) = 0; virtual void queueInbound(Packet* packet, uint32_t scheduled_for) = 0; virtual Packet* getNextInbound(uint32_t now) = 0; + virtual int getInboundTotal() const { return 0; } // delayed (rxdelay) packets not yet processed }; typedef uint32_t DispatcherAction; @@ -173,6 +174,7 @@ class Dispatcher { virtual unsigned long getDutyCycleWindowMs() const { return 3600000; } public: + bool isTransmitting() const { return outbound != nullptr; } void begin(); void loop(); diff --git a/src/MeshCore.h b/src/MeshCore.h index b9cfb418283..8c9c66802a5 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -37,13 +37,25 @@ #define BRIDGE_DEBUG_PRINTLN(...) {} #endif +#if POWERSAVING_DEBUG && ARDUINO + #include + #define POWERSAVING_DEBUG_PRINT(F, ...) Serial.printf("POWERSAVING: " F, ##__VA_ARGS__) + #define POWERSAVING_DEBUG_PRINTLN(F, ...) Serial.printf("POWERSAVING: " F "\n", ##__VA_ARGS__) +#else + #define POWERSAVING_DEBUG_PRINT(...) {} + #define POWERSAVING_DEBUG_PRINTLN(...) {} +#endif + namespace mesh { #define BD_STARTUP_NORMAL 0 // getStartupReason() codes #define BD_STARTUP_RX_PACKET 1 class MainBoard { +protected: + bool _ota_update_active = false; public: + virtual bool isOTAUpdateActive() const { return _ota_update_active; } virtual uint16_t getBattMilliVolts() = 0; virtual float getMCUTemperature() { return NAN; } virtual bool setAdcMultiplier(float multiplier) { return false; }; @@ -52,6 +64,8 @@ class MainBoard { virtual void onBeforeTransmit() { } virtual void onAfterTransmit() { } virtual void reboot() = 0; + virtual void shutdownPeripherals() { } + virtual void enterDeepSleep(uint32_t secs) { if (secs == 0) powerOff(); } virtual void powerOff() { /* no op */ } // Called by example setup() functions to signal that boot is complete. // Boards may override to stop a boot-indicator LED sequence or similar. @@ -59,6 +73,7 @@ class MainBoard { virtual void onBootComplete() { /* no op */ } virtual uint32_t getIRQGpio() { return -1; } // not supported. Returns DIO1 (SX1262) and DIO0 (SX127x) virtual void sleep(uint32_t secs) { /* no op */ } + virtual void sleepMillis(uint32_t ms) { if (ms) sleep(0); } virtual uint32_t getGpio() { return 0; } virtual void setGpio(uint32_t values) {} virtual uint8_t getStartupReason() const = 0; diff --git a/src/helpers/ArduinoHelpers.cpp b/src/helpers/ArduinoHelpers.cpp new file mode 100644 index 00000000000..dcc6039f1ad --- /dev/null +++ b/src/helpers/ArduinoHelpers.cpp @@ -0,0 +1,7 @@ +#include +#ifdef NRF52_PLATFORM +extern "C" { +__attribute__((section(".persistent_magic"))) volatile uint32_t persistent_magic; +__attribute__((section(".persistent_data"))) volatile uint32_t persistent_time; +} +#endif diff --git a/src/helpers/ArduinoHelpers.h b/src/helpers/ArduinoHelpers.h index 97596daa31f..ba03b7ce4e8 100644 --- a/src/helpers/ArduinoHelpers.h +++ b/src/helpers/ArduinoHelpers.h @@ -3,19 +3,59 @@ #include #include +#ifdef NRF52_PLATFORM +#define CLOCK_MAGIC_NUM 0xAA55CC33 +#define RTC_TIME_MIN 1772323200 // 1 Mar 2026 + +extern "C" { +extern volatile uint32_t persistent_magic; +extern volatile uint32_t persistent_time; +} +#endif + class VolatileRTCClock : public mesh::RTCClock { uint32_t base_time; uint64_t accumulator; - unsigned long prev_millis; + uint32_t prev_millis; + public: - VolatileRTCClock() { base_time = 1715770351; accumulator = 0; prev_millis = millis(); } // 15 May 2024, 8:50pm + VolatileRTCClock() { +#ifdef NRF52_PLATFORM + if (persistent_magic == CLOCK_MAGIC_NUM && persistent_time >= RTC_TIME_MIN) { + base_time = persistent_time; + } else { + base_time = RTC_TIME_MIN; + } +#else + base_time = 1715770351; +#endif + + accumulator = 0; + prev_millis = millis(); + } + uint32_t getCurrentTime() override { return base_time + accumulator/1000; } - void setCurrentTime(uint32_t time) override { base_time = time; accumulator = 0; prev_millis = millis(); } + + void setCurrentTime(uint32_t time) override { + base_time = time; + accumulator = 0; + prev_millis = millis(); + +#ifdef NRF52_PLATFORM + persistent_time = time; + persistent_magic = CLOCK_MAGIC_NUM; +#endif + } void tick() override { - unsigned long now = millis(); + uint32_t now = millis(); accumulator += (now - prev_millis); prev_millis = now; + +#ifdef NRF52_PLATFORM + persistent_time = getCurrentTime(); + persistent_magic = CLOCK_MAGIC_NUM; +#endif } }; diff --git a/src/helpers/BaseSerialInterface.h b/src/helpers/BaseSerialInterface.h index 23933fcb4b0..cb31a47ed46 100644 --- a/src/helpers/BaseSerialInterface.h +++ b/src/helpers/BaseSerialInterface.h @@ -16,6 +16,7 @@ class BaseSerialInterface { virtual bool isConnected() const = 0; virtual void loop() {}; + virtual bool isReadBusy() const { return false; } // received frames still queued (power saving) virtual bool isWriteBusy() const = 0; virtual size_t writeFrame(const uint8_t src[], size_t len) = 0; virtual size_t checkRecvFrame(uint8_t dest[]) = 0; diff --git a/src/helpers/ClientACL.h b/src/helpers/ClientACL.h index 9938bc1e1f6..7be13363759 100644 --- a/src/helpers/ClientACL.h +++ b/src/helpers/ClientACL.h @@ -10,7 +10,7 @@ #define PERM_ACL_READ_WRITE 2 #define PERM_ACL_ADMIN 3 -#define OUT_PATH_UNKNOWN 0xFF +#include struct ClientInfo { mesh::Identity id; diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 04199e24d1d..9a56e1a5efd 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -1,3 +1,4 @@ +#include "PeriodicReboot.h" #include #include "CommonCLI.h" #include "TxtDataHelpers.h" @@ -90,7 +91,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { // Legacy file.read((uint8_t *)&_prefs->bridge_channel, sizeof(_prefs->bridge_channel)); // 135 file.read((uint8_t *)&_prefs->bridge_secret, sizeof(_prefs->bridge_secret)); // 136 file.read((uint8_t *)&_prefs->powersaving_enabled, sizeof(_prefs->powersaving_enabled)); // 152 - file.read(pad, 3); // 153 + file.read((uint8_t *)&_prefs->reboot_interval, sizeof(_prefs->reboot_interval)); // 153 + file.read(pad, 2); // 154 file.read((uint8_t *)&_prefs->gps_enabled, sizeof(_prefs->gps_enabled)); // 156 file.read((uint8_t *)&_prefs->gps_interval, sizeof(_prefs->gps_interval)); // 157 file.read((uint8_t *)&_prefs->advert_loc_policy, sizeof (_prefs->advert_loc_policy)); // 161 @@ -423,7 +425,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re bool enabled = l->isEnabled(); // is EN pin on ? bool fix = l->isValid(); // has fix ? int sats = l->satellitesCount(); - bool active = !strcmp(_sensors->getSettingByKey("gps"), "1"); + const char* gps_setting = _sensors->getSettingByKey("gps"); // NULL when the sensor manager has no "gps" setting + bool active = gps_setting != NULL && strcmp(gps_setting, "1") == 0; if (enabled) { sprintf(reply, "on, %s, %s, %d sats", active?"active":"deactivated", @@ -460,6 +463,35 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } else { strcpy(reply, "off"); } + } else if (strcmp(command, "sensor") == 0) { +#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL) + snprintf(reply, 160, "I2C Wire1: SDA=%d, SCL=%d", (int)ENV_PIN_SDA, (int)ENV_PIN_SCL); +#elif defined(PIN_BOARD_SDA) && defined(PIN_BOARD_SCL) + snprintf(reply, 160, "I2C Wire: SDA=%d, SCL=%d", (int)PIN_BOARD_SDA, (int)PIN_BOARD_SCL); +#elif defined(PIN_WIRE_SDA) && defined(PIN_WIRE_SCL) + snprintf(reply, 160, "I2C Wire: SDA=%d, SCL=%d", (int)PIN_WIRE_SDA, (int)PIN_WIRE_SCL); +#else + snprintf(reply, 160, "I2C GPIOs not defined"); +#endif + size_t used = strlen(reply); +#if defined(PIN_GPS_RX) && defined(PIN_GPS_TX) + snprintf(reply + used, 160 - used, "; GPS RX=%d, TX=%d, %s", (int)PIN_GPS_RX, (int)PIN_GPS_TX, +#if defined(ENV_INCLUDE_GPS) && ENV_INCLUDE_GPS > 0 + "configured"); +#else + "not configured"); +#endif +#else + snprintf(reply + used, 160 - used, "; GPS serial not defined"); +#endif + } else if (strcmp(command, "powerlog") == 0) { + snprintf(reply, 160, "Last reset reason: %s", _board->getResetReasonString(_board->getResetReason())); + if (_board->isPwrMgtInitialised()) { + size_t used = strlen(reply); + snprintf(reply + used, 160 - used, "; shutdown: %s; boot: %u mV", + _board->getShutdownReasonString(_board->getShutdownReason()), + (unsigned)_board->getBootVoltage()); + } } else if (memcmp(command, "log start", 9) == 0) { _callbacks->setLoggingOn(true); strcpy(reply, " logging on"); @@ -486,7 +518,16 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* reply) { const char* config = &command[4]; - if (memcmp(config, "allow.read.only ", 16) == 0) { + if (strncmp(config, "reboot.interval ", 16) == 0) { + uint8_t hours; + if (!PeriodicReboot::parseHours(config + 16, hours)) { + strcpy(reply, "Error: interval range is 0-255 hours"); + } else { + _prefs->reboot_interval = hours; + savePrefs(); + sprintf(reply, "OK - reboot.interval %u h", (unsigned)hours); + } + } else if (memcmp(config, "allow.read.only ", 16) == 0) { _prefs->allow_read_only = memcmp(&config[16], "on", 2) == 0; savePrefs(); strcpy(reply, "OK"); @@ -700,7 +741,9 @@ 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 (memcmp(config, "allow.read.only", 15) == 0) { + if (strcmp(config, "reboot.interval") == 0) { + sprintf(reply, "> %u", (unsigned)_prefs->reboot_interval); + } else if (memcmp(config, "allow.read.only", 15) == 0) { sprintf(reply, "> %s", _prefs->allow_read_only ? "on" : "off"); } else if (memcmp(config, "flood.advert.interval", 21) == 0) { sprintf(reply, "> %d", ((uint32_t) _prefs->flood_advert_interval)); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 2759b0c4f0e..470bf34c5aa 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -57,7 +57,8 @@ class NodePrefs : public ConfigSerializer { uint8_t bridge_channel = 0; // 1-14 (ESP-NOW only) char bridge_secret[16]; // for XOR encryption of bridge packets (ESP-NOW only) // Power setting - uint8_t powersaving_enabled = 0; // boolean + uint8_t reboot_interval = 0; // hours; zero disables automatic restart + uint8_t powersaving_enabled = 1; // boolean // Gps settings uint8_t gps_enabled = 0; uint32_t gps_interval = 0; // in seconds @@ -66,6 +67,7 @@ class NodePrefs : public ConfigSerializer { float adc_multiplier = 0; char owner_info[120]; uint8_t rx_boosted_gain = 0; // power settings + RxPowerSavingConfig rxps; uint8_t radio_fem_rxgain = 0; // LoRa FEM RX gain setting uint8_t radio_fem_txgain = 0; // LoRa FEM TX gain setting uint8_t path_hash_mode = 0; // which path mode to use when sending @@ -78,6 +80,11 @@ class NodePrefs : public ConfigSerializer { NodePrefs* _parent; protected: void structure() override { + def("rxps_en", _parent->rxps.enabled); + def("rxps_rx_us", _parent->rxps.rx_us); + def("rxps_sleep_us", _parent->rxps.sleep_us); + def("rxps_level", _parent->rxps.level); + def("rxps_preamble", _parent->rxps.preamble); def("freq", _parent->freq); def("bw", _parent->bw); def("sf", _parent->sf); @@ -98,6 +105,7 @@ class NodePrefs : public ConfigSerializer { } public: RadioPrefs(NodePrefs* parent) : _parent(parent) { } + RxPowerSavingConfig* getRxPowerSavingConfig() override { return &_parent->rxps; } // CommonRadioPrefs interface float getFreq() const override { return _parent->freq; } void setFreq(float f) override { _parent->freq = f; markDirty(); } @@ -171,6 +179,7 @@ class NodePrefs : public ConfigSerializer { void structure() override { def("adc_mult", _parent->adc_multiplier); def("pwr_sav_en", _parent->powersaving_enabled); + def("pwr_reboot_int", _parent->reboot_interval); } public: PowerPrefs(NodePrefs* parent) : _parent(parent) { } @@ -226,6 +235,7 @@ class NodePrefs : public ConfigSerializer { public: NodePrefs() : ConfigSerializer(), bridge(this), gps(this), radio(this), power(this), repeat(this), room(this), custom(&radio) { + rxps.enabled = 1; // fresh installs: Balanced L6/p16; loaded fields override this node_name[0] = 0; password[0] = 0; guest_password[0] = 0; @@ -234,6 +244,7 @@ class NodePrefs : public ConfigSerializer { } CommonRadioPrefs* getRadioPrefs() { return &radio; } + const CommonRadioPrefs* getRadioPrefs() const { return &radio; } KeyValueStore* getCustom() { return &custom; } bool isDirty() const override { return ConfigSerializer::isDirty() || radio.isDirty() || custom.isDirty(); } diff --git a/src/helpers/CommonRadioPrefs.cpp b/src/helpers/CommonRadioPrefs.cpp index 41b179932a5..944e7686485 100644 --- a/src/helpers/CommonRadioPrefs.cpp +++ b/src/helpers/CommonRadioPrefs.cpp @@ -2,6 +2,30 @@ #include "TxtDataHelpers.h" #include "Utils.h" #include "target.h" +#include "radiolib/RXPowerSavingCLI.h" + +static RxPowerSavingControl* rxpsControl() { +#ifdef WRAPPER_CLASS + return &radio_driver; +#else + return nullptr; +#endif +} + +bool CommonRadioPrefs::applyRxPowerSaving(uint8_t sf, float bw) { + _active_sf = sf; + _active_bw = bw; + RxPowerSavingConfig* config = getRxPowerSavingConfig(); + if (!config) return false; + // Temporary radio parameters must not replace the persisted automatic periods. + RxPowerSavingConfig active = *config; + return applyRxPowerSavingConfig(&active, sf, bw, rxpsControl()); +} + +bool CommonRadioPrefs::isRxPowerSavingCalibrationActive() const { + const auto* control = rxpsControl(); + return control && control->isRxPowerSavingCalibrationActive(); +} bool CommonRadioPrefs::getByKey(const char* key, char* value, size_t max_len) { if (strcmp(key, "fem_rxgain") == 0) { @@ -29,6 +53,42 @@ bool CommonRadioPrefs::setByKey(const char* key, const char* value) { } bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + const bool fem_get = strcmp(command, "get radio.fem.rxgain") == 0; + const bool fem_set = strncmp(command, "set radio.fem.rxgain ", 21) == 0; + if (fem_get || fem_set) { + if (fem_set && strcmp(command + 21, "on") != 0 && strcmp(command + 21, "off") != 0) { + strcpy(reply, "Error: state must be on or off"); + } else if (!board.handleCommand(command, sender_timestamp, reply)) { + strcpy(reply, "Error: unsupported"); + } else if (fem_set && strncmp(reply, "OK", 2) == 0) { + // The board applies the LNA and updates its dynamic preferences. + markDirty(); // also save when the dynamic value lives outside radio.* + } + return true; + } + if (strncmp(command, "get radio.rxps", 14) == 0 && + (command[14] == 0 || command[14] == ' ')) { + const char* suffix = command + 14; + while (*suffix == ' ') ++suffix; + if (*suffix) return false; + auto* config = getRxPowerSavingConfig(); + if (!config) { strcpy(reply, "Error: unsupported"); return true; } + const uint8_t sf = _active_sf ? _active_sf : getSpreadFactor(); + const float bw = _active_sf ? _active_bw : getBandwidth(); + RxPowerSavingConfig active = *config; + normalizeRxPowerSavingConfig(&active, sf, bw, + rxPowerSavingCaptureCost(rxpsControl()), rxPowerSavingTransition(rxpsControl())); + RXPowerSavingCLI::get(&active, rxpsControl(), sf, bw, reply, 160); + return true; + } + if (strncmp(command, "set radio.rxps ", 15) == 0) { + auto* config = getRxPowerSavingConfig(); + if (!config) { strcpy(reply, "Error: unsupported"); return true; } + if (RXPowerSavingCLI::set(command + 15, + _active_sf ? _active_sf : getSpreadFactor(), _active_sf ? _active_bw : getBandwidth(), + config, rxpsControl(), reply, 160)) markDirty(); + return true; + } if (strcmp(command, "get radio") == 0) { char freq[16], bw[16]; strcpy(freq, StrHelper::ftoa(getFreq())); @@ -50,7 +110,14 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest setCodingRate(cr); setFreq(freq); setBandwidth(bw); - strcpy(reply, "OK - reboot to apply"); + auto* rxps = getRxPowerSavingConfig(); + const bool timing_ok = !rxps || !rxps->enabled || normalizeRxPowerSavingConfig( + rxps, sf, bw, rxPowerSavingCaptureCost(rxpsControl()), rxPowerSavingTransition(rxpsControl())); + if (!timing_ok && rxpsControl()) { + rxpsControl()->setRxPowerSaving(false, rxps->rx_us, rxps->sleep_us); + rxpsControl()->setRxPowerSavingConfigError(true); + } + strcpy(reply, timing_ok ? "OK - reboot to apply" : "OK - reboot to apply; RXPS continuous: invalid timing"); } else { strcpy(reply, "Error, invalid radio params"); } @@ -110,7 +177,8 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest } if (strcmp(command, "get cad") == 0) { - sprintf(reply, "> %s", isCadEnabled() ? "on" : "off"); + sprintf(reply, "> %s; channel busy: %lu", isCadEnabled() ? "on" : "off", + (unsigned long)radio_driver.getCADBusyCount()); return true; } if (memcmp(command, "set cad ", 8) == 0) { diff --git a/src/helpers/CommonRadioPrefs.h b/src/helpers/CommonRadioPrefs.h index 0e83ca9dfdb..f79e9d07b58 100644 --- a/src/helpers/CommonRadioPrefs.h +++ b/src/helpers/CommonRadioPrefs.h @@ -1,9 +1,19 @@ #pragma once #include "ConfigSerializer.h" #include "KeyValueStore.h" +#include "radiolib/RXPowerSaving.h" + +// Default for the external LoRa FEM receive LNA on boards that have one (fresh +// installs only; 'set radio.fem.rxgain on|off' changes it at runtime). Off saves +// power; a board or env can build with -D RADIO_FEM_RXGAIN=1 to default it on. +#ifndef RADIO_FEM_RXGAIN + #define RADIO_FEM_RXGAIN 0 +#endif class CommonRadioPrefs : public ConfigSerializer, public KeyValueStore { bool _is_dirty = false; + uint8_t _active_sf = 0; + float _active_bw = 0; protected: CommonRadioPrefs() { } public: @@ -62,6 +72,10 @@ class CommonRadioPrefs : public ConfigSerializer, public KeyValueStore { virtual uint8_t getFEMTxGain() const = 0; virtual void setFEMTxGain(uint8_t g) = 0; + virtual RxPowerSavingConfig* getRxPowerSavingConfig() { return nullptr; } + bool applyRxPowerSaving(uint8_t sf, float bw); + bool isRxPowerSavingCalibrationActive() const; + bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply); bool setByKey(const char* key, const char* value) override; // for dynamic key/value access diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index a55abb264e9..e24a59681b0 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -12,6 +12,7 @@ #include bool ESP32Board::startOTAUpdate(const char* id, char reply[]) { + _ota_update_active = true; inhibit_sleep = true; // prevent sleep during OTA WiFi.softAP("MeshCore-OTA", NULL); @@ -49,7 +50,7 @@ void ESP32Board::powerOff() { enterDeepSleep(0); // Do not wakeup } -void ESP32Board::enterDeepSleep(uint32_t secs) { +void ESP32Board::shutdownPeripherals() { // Power off the display if any #ifdef DISPLAY_CLASS display.turnOff(); @@ -59,11 +60,13 @@ void ESP32Board::enterDeepSleep(uint32_t secs) { radio_driver.powerOff(); // Keep LoRa inactive during deepsleep +#ifdef P_LORA_NSS // not defined for ESP-NOW-only boards digitalWrite(P_LORA_NSS, HIGH); #if defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32C6) gpio_hold_en((gpio_num_t)P_LORA_NSS); #else rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); +#endif #endif // Power off GPS if any @@ -75,6 +78,11 @@ void ESP32Board::enterDeepSleep(uint32_t secs) { Serial.flush(); delay(100); +} + +void ESP32Board::enterDeepSleep(uint32_t secs) { + shutdownPeripherals(); + // Clear stale wakeup sources to avoid ghost wakeup // This is required when Power Management and automatic lightsleep are enabled esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL); diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index 3f95e6923f4..f524ef3e072 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -21,6 +21,8 @@ class ESP32Board : public mesh::MainBoard { protected: uint8_t startup_reason; bool inhibit_sleep = false; + uint32_t button_woke_at = 0; + bool button_sleep_inhibited = false; static inline portMUX_TYPE sleepMux = portMUX_INITIALIZER_UNLOCKED; public: @@ -67,25 +69,41 @@ class ESP32Board : public mesh::MainBoard { } virtual void powerOff() override; - void enterDeepSleep(uint32_t secs); + void shutdownPeripherals() override; + void enterDeepSleep(uint32_t secs) override; uint32_t getIRQGpio() override { +#ifdef P_LORA_DIO_1 return P_LORA_DIO_1; // default for SX1262 +#else + return mesh::MainBoard::getIRQGpio(); // no LoRa radio (ESP-NOW) +#endif } - void sleep(uint32_t secs) override { + void sleep(uint32_t secs) override { sleepMicros(secs * 1000000ULL); } + void sleepMillis(uint32_t ms) override { if (ms) sleepMicros(ms * 1000ULL); } + + void sleepMicros(uint64_t micros) { // Skip if not allow to sleep - if (inhibit_sleep) { + if (button_sleep_inhibited && (uint32_t)(millis() - button_woke_at) >= 30000) { + button_sleep_inhibited = false; + } + if (inhibit_sleep || button_sleep_inhibited) { delay(1); // Give MCU to OTA to run return; } // Set GPIO wakeup - gpio_num_t wakeupPin = (gpio_num_t)getIRQGpio(); + const uint32_t irq_gpio = getIRQGpio(); + if (irq_gpio == (uint32_t)-1) { + delay(1); // no LoRa wake source (ESP-NOW): stay awake so Wi-Fi keeps receiving + return; + } + gpio_num_t wakeupPin = (gpio_num_t)irq_gpio; // Configure timer wakeup - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000ULL); // Wake up periodically to do scheduled jobs + if (micros > 0) { + esp_sleep_enable_timer_wakeup(micros); // Wake up periodically to do scheduled jobs } // Disable CPU interrupt servicing @@ -102,6 +120,11 @@ class ESP32Board : public mesh::MainBoard { esp_sleep_enable_gpio_wakeup(); gpio_wakeup_enable((gpio_num_t)wakeupPin, GPIO_INTR_HIGH_LEVEL); // Wake up when receiving a LoRa packet +#if defined(PIN_USER_BTN) && (PIN_USER_BTN >= 0) + gpio_wakeup_enable((gpio_num_t)PIN_USER_BTN, + USER_BTN_PRESSED == LOW ? GPIO_INTR_LOW_LEVEL : GPIO_INTR_HIGH_LEVEL); +#endif + // MCU enters light sleep esp_light_sleep_start(); @@ -109,6 +132,14 @@ class ESP32Board : public mesh::MainBoard { gpio_wakeup_disable(wakeupPin); gpio_set_intr_type(wakeupPin, GPIO_INTR_POSEDGE); +#if defined(PIN_USER_BTN) && (PIN_USER_BTN >= 0) + gpio_wakeup_disable((gpio_num_t)PIN_USER_BTN); + if (gpio_get_level((gpio_num_t)PIN_USER_BTN) == USER_BTN_PRESSED) { + button_woke_at = millis(); + button_sleep_inhibited = true; + } +#endif + // Enable CPU interrupt servicing portEXIT_CRITICAL(&sleepMux); } diff --git a/src/helpers/IdleSleep.h b/src/helpers/IdleSleep.h new file mode 100644 index 00000000000..6e54782091b --- /dev/null +++ b/src/helpers/IdleSleep.h @@ -0,0 +1,22 @@ +#pragma once +#include + +// Longest single ESP32 light sleep of the Room Server and Sensor, as the Repeater's +// sleep(30) and PowerSaving-v17. A LoRa packet or the user button wakes earlier. +#ifndef IDLE_SLEEP_MAX_MILLIS + #define IDLE_SLEEP_MAX_MILLIS 30000 +#endif + +// Sleep at most IDLE_SLEEP_MAX_MILLIS, shortened to the next role deadline. +class IdleSleepBudget { + uint32_t _now, _budget; +public: + explicit IdleSleepBudget(uint32_t now, uint32_t max_ms = IDLE_SLEEP_MAX_MILLIS) : _now(now), _budget(max_ms) {} + void until(uint32_t deadline) { + if (!deadline) return; + int32_t remaining = (int32_t)(deadline - _now); + if (remaining <= 0) _budget = 0; + else if ((uint32_t)remaining < _budget) _budget = remaining; + } + uint32_t millis() const { return _budget; } +}; diff --git a/src/helpers/MultiSerialInterface.h b/src/helpers/MultiSerialInterface.h index f7742b24c38..7bb827c18b8 100644 --- a/src/helpers/MultiSerialInterface.h +++ b/src/helpers/MultiSerialInterface.h @@ -140,6 +140,23 @@ class MultiSerialInterface : public BaseSerialInterface { } } + bool isReadBusy() const override { + // not busy when disabled + if (!_enabled) { + return false; + } + + // check if any interface is busy + for (auto iface : _interfaces) { + if (iface.instance && iface.instance->isEnabled() && iface.instance->isReadBusy()) { + return true; + } + } + + // nothing busy + return false; + } + bool isWriteBusy() const override { // not busy when disabled if(!_enabled){ diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index f2fa7743a73..4401961c0cf 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -1,5 +1,6 @@ #if defined(NRF52_PLATFORM) #include "NRF52Board.h" +#include "ArduinoHelpers.h" #include #include @@ -390,6 +391,25 @@ void NRF52Board::powerOff() { } } +void NRF52Board::enterDeepSleep(uint32_t secs) { + if (secs == 0) { powerOff(); return; } + shutdownPeripherals(); +#if (__FPU_USED == 1) + __set_FPSCR(__get_FPSCR() & ~(0x0000009F)); + (void)__get_FPSCR(); + NVIC_ClearPendingIRQ(FPU_IRQn); +#endif + // nRF SYSTEMOFF has no timer wake: keep the RTOS/RTC running, then reboot. + // One-second chunks avoid overflow and checkpoint retained time during sleep. + while (secs--) { + vTaskDelay(pdMS_TO_TICKS(1000)); + if (persistent_magic == CLOCK_MAGIC_NUM && persistent_time >= RTC_TIME_MIN) { + ++persistent_time; + } + } + reboot(); +} + bool NRF52Board::getBootloaderVersion(char* out, size_t max_len) { static const char BOOTLOADER_MARKER[] = "UF2 Bootloader "; const uint8_t* flash = (const uint8_t*)0x000FB000; // earliest known info.txt location is 0xFB90B, latest is 0xFCC4B @@ -417,6 +437,7 @@ bool NRF52Board::startOTAUpdate(const char *id, char reply[]) { Bluefruit.configPrphConn(92, BLE_GAP_EVENT_LENGTH_MIN, 16, 16); if (!Bluefruit.begin(1, 0)) return false; + _ota_update_active = true; // Set max power. Accepted values are: -40, -30, -20, -16, -12, -8, -4, 0, 4 Bluefruit.setTxPower(4); diff --git a/src/helpers/NRF52Board.h b/src/helpers/NRF52Board.h index 43944b5f7cf..9ca683d76c9 100644 --- a/src/helpers/NRF52Board.h +++ b/src/helpers/NRF52Board.h @@ -52,7 +52,8 @@ class NRF52Board : public mesh::MainBoard { virtual uint8_t getStartupReason() const override { return startup_reason; } virtual float getMCUTemperature() override; virtual void reboot() override { NVIC_SystemReset(); } - virtual void shutdownPeripherals(); + void shutdownPeripherals() override; + void enterDeepSleep(uint32_t secs) override; virtual void powerOff() override; virtual bool getBootloaderVersion(char* version, size_t max_len) override; virtual bool startOTAUpdate(const char *id, char reply[]) override; diff --git a/src/helpers/OutPath.h b/src/helpers/OutPath.h new file mode 100644 index 00000000000..2596d67a1fe --- /dev/null +++ b/src/helpers/OutPath.h @@ -0,0 +1,131 @@ +#pragma once +#include +#include +#include +#include +#define OUT_PATH_FORCE_FLOOD 0xFE +#define OUT_PATH_UNKNOWN 0xFF + +static char* trimOutPathSpaces(char* s) { + while (*s == ' ') s++; + char* end = s + strlen(s); + while (end > s && end[-1] == ' ') end--; + *end = 0; + return s; +} + +static bool parsePathCommandImpl(char* raw, uint8_t* out_path, uint8_t& out_path_len, const char*& err) { + if (raw == NULL || out_path == NULL) { + err = "Err - bad params"; + return false; + } + + char* spec = trimOutPathSpaces(raw); + if (*spec == 0) { + err = "Err - missing path"; + return false; + } + if (strcmp(spec, "clear") == 0 || strcmp(spec, "-") == 0 || strcmp(spec, "none") == 0) { + out_path_len = OUT_PATH_UNKNOWN; + return true; + } + if (strcmp(spec, "flood") == 0) { + out_path_len = OUT_PATH_FORCE_FLOOD; + return true; + } + if (strcmp(spec, "direct") == 0) { + out_path_len = 0; + return true; + } + + uint8_t hash_size = 0; + uint8_t hop_count = 0; + char* token = spec; + while (token && *token) { + char* comma = strchr(token, ','); + if (comma && comma[1] == 0) { err = "Err - missing hop"; return false; } + if (comma) *comma = 0; + token = trimOutPathSpaces(token); + + int hex_len = strlen(token); + if (!(hex_len == 2 || hex_len == 4 || hex_len == 6)) { + err = "Err - bad params"; + return false; + } + + for (int i = 0; i < hex_len; ++i) { + char c = token[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) { + err = "Err - bad hex"; + return false; + } + } + uint8_t hop_hash_size = (uint8_t)(hex_len / 2); + if (hash_size == 0) { + hash_size = hop_hash_size; + } else if (hash_size != hop_hash_size) { + err = "Err - bad params"; + return false; + } + + if (hop_count >= 63 || (hop_count + 1) * hash_size > MAX_PATH_SIZE) { + err = "Err - bad params"; + return false; + } + if (!mesh::Utils::fromHex(&out_path[hop_count * hash_size], hash_size, token)) { + err = "Err - bad hex"; + return false; + } + + hop_count++; + token = comma ? comma + 1 : NULL; + } + + if (hash_size == 0 || hop_count == 0) { + err = "Err - missing path"; + return false; + } + out_path_len = ((hash_size - 1) << 6) | (hop_count & 63); + return true; +} + +static void formatPathReply(const uint8_t* path, uint8_t path_len, char* out, size_t out_len) { + if (path_len == OUT_PATH_FORCE_FLOOD) { + snprintf(out, out_len, "> flood"); + return; + } + if (path_len == OUT_PATH_UNKNOWN) { + snprintf(out, out_len, "> unknown"); + return; + } + if (!mesh::Packet::isValidPathLen(path_len)) { + snprintf(out, out_len, "> invalid"); + return; + } + if ((path_len & 63) == 0) { + snprintf(out, out_len, "> direct"); + return; + } + + uint8_t hash_size = (path_len >> 6) + 1; + uint8_t hop_count = path_len & 63; + uint8_t byte_len = hop_count * hash_size; + char hex[(MAX_PATH_SIZE * 2) + 1]; + mesh::Utils::toHex(hex, path, byte_len); + snprintf(out, out_len, "> hs=%u hops=%u hex=%s", (uint32_t)hash_size, (uint32_t)hop_count, hex); +} + + +inline bool parsePathCommand(char* raw, uint8_t* out_path, uint8_t& out_path_len, const char*& err) { + if (!raw || !out_path) { err = "Err - bad params"; return false; } + uint8_t proposed[MAX_PATH_SIZE] = {}; + uint8_t proposed_len = OUT_PATH_UNKNOWN; + if (!parsePathCommandImpl(raw, proposed, proposed_len, err)) return false; + memcpy(out_path, proposed, MAX_PATH_SIZE); + out_path_len = proposed_len; + return true; +} + +inline void learnOutPath(uint8_t* stored, uint8_t& stored_len, const uint8_t* path, uint8_t len) { + if (stored_len != OUT_PATH_FORCE_FLOOD) stored_len = mesh::Packet::copyPath(stored, path, len); +} diff --git a/src/helpers/PeriodicReboot.h b/src/helpers/PeriodicReboot.h new file mode 100644 index 00000000000..63915d8e904 --- /dev/null +++ b/src/helpers/PeriodicReboot.h @@ -0,0 +1,22 @@ +#pragma once +#include "Uptime.h" +class PeriodicReboot { + Uptime _uptime; +public: + bool due(uint8_t hours, uint32_t now, bool transmitting, bool ota_active) { + _uptime.tick(now); + return hours != 0 && _uptime.milliseconds() >= (uint64_t)hours * 3600000ULL + && !transmitting && !ota_active; + } + static bool parseHours(const char* text, uint8_t& hours) { + if (!text || !*text) return false; + unsigned value = 0; + for (; *text; ++text) { + if (*text < '0' || *text > '9') return false; + value = value * 10 + (*text - '0'); + if (value > 255) return false; + } + hours = value; + return true; + } +}; diff --git a/src/helpers/RoleSleep.h b/src/helpers/RoleSleep.h new file mode 100644 index 00000000000..9cdd8f5993f --- /dev/null +++ b/src/helpers/RoleSleep.h @@ -0,0 +1,25 @@ +#pragma once +// Include after target.h: these are the role's board, radio and sensor objects. +inline void sleepRoleIfIdle(bool enabled, bool pending, bool command_pending, uint32_t budget_ms) { + if (!enabled || pending || command_pending || !budget_ms || + board.isOTAUpdateActive() || Serial.available()) return; +#ifdef ETHERNET_ENABLED + return; // the Ethernet CLI/task must remain serviced +#endif +#if defined(ESP32_PLATFORM) + if (millis() < 120000) return; // retain the serial setup window from v17 +#if defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT + if (Serial) return; // active USB CDC cannot tolerate explicit ESP32 light sleep +#endif + auto* location = sensors.getLocationProvider(); + if (location && location->isEnabled()) return; // keep GPS UART reception live +#endif +#ifdef WRAPPER_CLASS + // Observation only: isReceiving() would start CAD and disturb duty-cycle RX. + if (radio_driver.hasPendingReceive()) return; +#else + if (radio_driver.isReceiving()) return; +#endif + Serial.flush(); + board.sleepMillis(budget_ms); +} diff --git a/src/helpers/StaticPoolPacketManager.h b/src/helpers/StaticPoolPacketManager.h index f63700e519a..b746662535e 100644 --- a/src/helpers/StaticPoolPacketManager.h +++ b/src/helpers/StaticPoolPacketManager.h @@ -38,4 +38,5 @@ class StaticPoolPacketManager : public mesh::PacketManager { mesh::Packet* removeOutboundByIdx(int i) override; void queueInbound(mesh::Packet* packet, uint32_t scheduled_for) override; mesh::Packet* getNextInbound(uint32_t now) override; + int getInboundTotal() const override { return rx_queue.count(); } }; \ No newline at end of file diff --git a/src/helpers/Uptime.h b/src/helpers/Uptime.h new file mode 100644 index 00000000000..5f54e5479a0 --- /dev/null +++ b/src/helpers/Uptime.h @@ -0,0 +1,10 @@ +#pragma once +#include +// Poll at least once per 32-bit millis() wrap, including when the screen is off. +class Uptime { + uint32_t _last = 0; + uint64_t _total = 0; +public: + void tick(uint32_t now) { _total += (uint32_t)(now - _last); _last = now; } + uint64_t milliseconds() const { return _total; } +}; diff --git a/src/helpers/esp32/ESPNOWRadio.h b/src/helpers/esp32/ESPNOWRadio.h index f474215a155..437569a725e 100644 --- a/src/helpers/esp32/ESPNOWRadio.h +++ b/src/helpers/esp32/ESPNOWRadio.h @@ -24,6 +24,7 @@ class ESPNOWRadio : public mesh::Radio { void onSendFinished() override; bool isInRecvMode() const override; + uint32_t getCADBusyCount() const { return 0; } // ESP-NOW has no LoRa CAD uint32_t getPacketsRecv() const { return n_recv; } uint32_t getPacketsSent() const { return n_sent; } uint32_t getPacketsRecvErrors() const { return n_recv_errors; } @@ -38,7 +39,7 @@ class ESPNOWRadio : public mesh::Radio { * These two functions do nothing for ESP-NOW, but are needed for the * Radio interface. */ - virtual bool setRxBoostedGainMode(bool) { } + virtual bool setRxBoostedGainMode(bool) { return false; } virtual bool getRxBoostedGainMode() const { return false; } uint32_t intID(); diff --git a/src/helpers/esp32/SerialBLEInterface.cpp b/src/helpers/esp32/SerialBLEInterface.cpp index 6371cf33537..2e1343f5470 100644 --- a/src/helpers/esp32/SerialBLEInterface.cpp +++ b/src/helpers/esp32/SerialBLEInterface.cpp @@ -188,6 +188,10 @@ size_t SerialBLEInterface::writeFrame(const uint8_t src[], size_t len) { #define BLE_WRITE_MIN_INTERVAL 60 +bool SerialBLEInterface::isReadBusy() const { + return uxQueueMessagesWaiting(recv_queue) > 0; +} + bool SerialBLEInterface::isWriteBusy() const { return millis() < _last_write + BLE_WRITE_MIN_INTERVAL; // still too soon to start another write? } diff --git a/src/helpers/esp32/SerialBLEInterface.h b/src/helpers/esp32/SerialBLEInterface.h index 9fb6adba943..3298011b2b8 100644 --- a/src/helpers/esp32/SerialBLEInterface.h +++ b/src/helpers/esp32/SerialBLEInterface.h @@ -82,6 +82,7 @@ class SerialBLEInterface : public BaseSerialInterface, BLESecurityCallbacks, BLE bool isConnected() const override; + bool isReadBusy() const override; bool isWriteBusy() const override; size_t writeFrame(const uint8_t src[], size_t len) override; size_t checkRecvFrame(uint8_t dest[]) override; diff --git a/src/helpers/nrf52/SerialBLEInterface.cpp b/src/helpers/nrf52/SerialBLEInterface.cpp index 6ed16bc86d9..b1f5d95ce56 100644 --- a/src/helpers/nrf52/SerialBLEInterface.cpp +++ b/src/helpers/nrf52/SerialBLEInterface.cpp @@ -405,6 +405,10 @@ bool SerialBLEInterface::isConnected() const { return _isDeviceConnected && Bluefruit.connected() > 0; } +bool SerialBLEInterface::isReadBusy() const { + return (recv_queue_len > 0); +} + bool SerialBLEInterface::isWriteBusy() const { return send_queue_len >= (FRAME_QUEUE_SIZE * 2 / 3); } diff --git a/src/helpers/nrf52/SerialBLEInterface.h b/src/helpers/nrf52/SerialBLEInterface.h index de1030548fb..d3cc5055167 100644 --- a/src/helpers/nrf52/SerialBLEInterface.h +++ b/src/helpers/nrf52/SerialBLEInterface.h @@ -66,6 +66,7 @@ class SerialBLEInterface : public BaseSerialInterface { void disable() override; bool isEnabled() const override { return _isEnabled; } bool isConnected() const override; + bool isReadBusy() const override; bool isWriteBusy() const override; size_t writeFrame(const uint8_t src[], size_t len) override; size_t checkRecvFrame(uint8_t dest[]) override; diff --git a/src/helpers/radiolib/CustomLR1110.h b/src/helpers/radiolib/CustomLR1110.h index f0fde435400..8c735a75357 100644 --- a/src/helpers/radiolib/CustomLR1110.h +++ b/src/helpers/radiolib/CustomLR1110.h @@ -2,6 +2,7 @@ #include #include "MeshCore.h" +#include "RXPowerSaving.h" class CustomLR1110 : public LR1110 { uint32_t _preambleMillis = 66; @@ -13,16 +14,27 @@ class CustomLR1110 : public LR1110 { public: CustomLR1110(Module *mod) : LR1110(mod) { } + // See CustomSX1262::begin - same reasoning, LR11x0 flags and calibration. int16_t begin(float freq = 434.0, float bw = 125.0, uint8_t sf = 9, uint8_t cr = 7, uint8_t syncWord = RADIOLIB_LR11X0_LORA_SYNC_WORD_PRIVATE, int8_t power = 10, uint16_t preambleLength = 8, float tcxoVoltage = 1.6) { int16_t state = LR1110::begin(freq, bw, sf, cr, syncWord, power, preambleLength, tcxoVoltage); + if (state == RADIOLIB_ERR_NONE) state = applyMeshCoreTcxoDelay(); // RadioLib begin() defaults to LDO; use the LR1110 DC/DC regulator. if (state == RADIOLIB_ERR_NONE) state = setRegulatorDCDC(); return state; } + int16_t applyMeshCoreTcxoDelay() { + if (tcxoVoltage <= 0.0f) return RADIOLIB_ERR_NONE; + int16_t state = setTCXO(tcxoVoltage, MC_TCXO_DELAY_US); + RADIOLIB_ASSERT(state); + state = calibrate(0x3F); + delay(50); + return state; + } + size_t getPacketLength(bool update) override { size_t len = LR1110::getPacketLength(update); if (len == 0 && getIrqStatus() & RADIOLIB_LR11X0_IRQ_HEADER_ERR) { @@ -38,6 +50,68 @@ class CustomLR1110 : public LR1110 { float getFreqMHz() const { return freqMHz; } + // The sleep -> RX transition RadioLib subtracts from a duty-cycle sleep. + // Protected in the RadioLib base, so the RXPS layer reads it through here. + uint32_t getTcxoDelay() const { return tcxoDelay; } + + // Restores the LF clock configuration RadioLib's begin() installs. Arming + // the duty cycle switches it to RC + BUSY-release (required by Semtech for + // SetRxDutyCycle); without this the change would outlive RXPS being turned + // off, and it also alters what the BUSY pin means to isChipBusy(). + int16_t restoreLfClock() { + return configLfClock( + RADIOLIB_LR11X0_LF_BUSY_RELEASE_DISABLED | RADIOLIB_LR11X0_LF_CLK_XOSC); + } + + // effectiveRxPeriod reports the RX window actually programmed, which may be + // stretched above the requested value to satisfy the extended-period rule. + int16_t startReceiveDutyCycle(uint32_t rxPeriod, uint32_t sleepPeriod, + RadioLibIrqFlags_t irqFlags = RADIOLIB_IRQ_RX_DEFAULT_FLAGS, + RadioLibIrqFlags_t irqMask = RADIOLIB_IRQ_RX_DEFAULT_MASK, + uint32_t* effectiveRxPeriod = nullptr) { + // Keep the conversion local until RadioLib uses 64-bit multiplication; + // its pinned implementation overflows for longer microsecond periods. + uint32_t symbolPeriod = (uint32_t)(((1000.0f * (float)(1UL << this->spreadingFactor)) / + this->bandwidthKhz) + 0.999f); + uint32_t transitionTime = this->tcxoDelay + 1000; + if (sleepPeriod <= transitionTime) return RADIOLIB_ERR_INVALID_SLEEP_PERIOD; + uint32_t programmedSleepPeriod = sleepPeriod - transitionTime; + + uint64_t requiredExtendedPeriod = + ((uint64_t)this->preambleLengthLoRa + 11ULL) * symbolPeriod + 1000ULL; + uint64_t extendedPeriod = 2ULL * rxPeriod + programmedSleepPeriod; + if (extendedPeriod < requiredExtendedPeriod) { + rxPeriod = (uint32_t)((requiredExtendedPeriod - programmedSleepPeriod + 1ULL) / 2ULL); + } + if (effectiveRxPeriod) *effectiveRxPeriod = rxPeriod; + + uint32_t rxPeriodRaw = (uint32_t)(((uint64_t)rxPeriod * 32768UL) / 1000000UL); + uint32_t sleepPeriodRaw = + (uint32_t)(((uint64_t)programmedSleepPeriod * 32768UL) / 1000000UL); + if ((rxPeriodRaw & 0xFF000000) || rxPeriodRaw == 0) return RADIOLIB_ERR_INVALID_RX_PERIOD; + if ((sleepPeriodRaw & 0xFF000000) || sleepPeriodRaw == 0) { + return RADIOLIB_ERR_INVALID_SLEEP_PERIOD; + } + + int16_t state = standby(RADIOLIB_LR11X0_STANDBY_RC); + RADIOLIB_ASSERT(state); + // Semtech requires the RC standby/RTC setup before SetRxDutyCycle. + state = configLfClock(RADIOLIB_LR11X0_LF_CLK_RC | RADIOLIB_LR11X0_LF_BUSY_RELEASE_ENABLED); + RADIOLIB_ASSERT(state); + RadioModeConfig_t cfg = { + .receive = { + .timeout = RADIOLIB_LR11X0_RX_TIMEOUT_INF, + .irqFlags = irqFlags, + .irqMask = irqMask, + .len = 0, + } + }; + + state = stageMode(RADIOLIB_RADIO_MODE_RX, &cfg); + RADIOLIB_ASSERT(state); + return setRxDutyCycle(rxPeriodRaw, sleepPeriodRaw, RADIOLIB_LR11X0_RX_DUTY_CYCLE_MODE_RX); + } + int16_t setRxBoostedGainMode(bool en) { _rx_boosted = en; return LR1110::setRxBoostedGainMode(en); @@ -50,6 +124,11 @@ class CustomLR1110 : public LR1110 { return LR1110::startReceive(RADIOLIB_LR11X0_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); } + bool isChipBusy() { + uint32_t busy = this->mod->getGpio(); + return busy != RADIOLIB_NC && this->mod->hal->digitalRead(busy); + } + bool isReceiving() { uint32_t irq = getIrqStatus(); bool preamble = irq & RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED; // bit 4 diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index e7aaeb937a7..ffa5c6d8516 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -6,9 +6,24 @@ class CustomLR1110Wrapper : public RadioLibWrapper { public: + // Measured on ThinkNode M3 and T1000-E: this family needs roughly one symbol + // more margin than SX126x before a duty-cycled receiver latches a preamble. + float rxPowerSavingCaptureCostSymbols() const override { + return RX_POWERSAVING_CAPTURE_COST_SYMBOLS_LR11X0; + } + + // Same reasoning as the SX126x wrapper. This family never showed either + // duty-cycle failure on the bench, which is unsurprising: its driver has + // always enforced an extended-period rule equivalent to the timer guard the + // SX126x side only just gained. + uint32_t rxPowerSavingTransitionUs() const override { + return ((CustomLR1110 *)_radio)->getTcxoDelay() + 1000; + } + CustomLR1110Wrapper(CustomLR1110& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + prepareForRadioConfig(); ((CustomLR1110 *)_radio)->setFrequency(freq); ((CustomLR1110 *)_radio)->setSpreadingFactor(sf); ((CustomLR1110 *)_radio)->setBandwidth(bw); @@ -20,8 +35,16 @@ class CustomLR1110Wrapper : public RadioLibWrapper { } bool isReceivingPacket() override { + // While duty-cycling, BUSY marks the sleep phase: probing IRQ status over + // SPI there could wake the chip and break the cycle. Only skip the probe + // in that case - outside RXPS this must stay a real channel check, since + // Dispatcher::checkSend() relies on it to avoid transmitting over others. + if (_rx_ps_armed && ((CustomLR1110 *)_radio)->isChipBusy()) return false; return ((CustomLR1110 *)_radio)->isReceiving(); } + bool isChipBusy() override { + return ((CustomLR1110 *)_radio)->isChipBusy(); + } float getCurrentRSSI() override { float rssi = -110; ((CustomLR1110 *)_radio)->getRssiInst(&rssi); @@ -38,12 +61,35 @@ class CustomLR1110Wrapper : public RadioLibWrapper { _radio->setPreambleLength(preambleLengthForSF(getSpreadingFactor())); // overcomes weird issues with small and big pkts } - float getLastRSSI() const override { return ((CustomLR1110 *)_radio)->getRSSI(); } - float getLastSNR() const override { return ((CustomLR1110 *)_radio)->getSNR(); } + bool supportsRxPowerSaving() const override { return true; } + +protected: + int16_t startContinuousReceive() override { + // Also covers a stopDutyCycleHardware() whose clock restore failed. + int16_t err = _radio->standby(); + if (err == RADIOLIB_ERR_NONE) err = ((CustomLR1110*)_radio)->restoreLfClock(); + return err == RADIOLIB_ERR_NONE ? _radio->startReceive() : err; + } + + int16_t armDutyCycle(RadioLibIrqFlags_t irq_flags, RadioLibIrqFlags_t irq_mask, + uint32_t* eff_rx_us, uint32_t* eff_sleep_us) override { + *eff_sleep_us = _rx_ps_sleep_us; // only the RX window can get clamped + return ((CustomLR1110 *)_radio)->startReceiveDutyCycle( + _rx_ps_rx_us, _rx_ps_sleep_us, irq_flags, irq_mask, eff_rx_us); + } + + int16_t stopDutyCycleHardware() override { + const int16_t state = _radio->standby(); + const int16_t clock = ((CustomLR1110 *)_radio)->restoreLfClock(); + return state != RADIOLIB_ERR_NONE ? state : clock; + } + +public: uint8_t getSpreadingFactor() const override { return ((CustomLR1110 *)_radio)->getSpreadingFactor(); } bool setRxBoostedGainMode(bool en) override { + prepareForRadioConfig(); return ((CustomLR1110 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } bool getRxBoostedGainMode() const override { diff --git a/src/helpers/radiolib/CustomLR2021.h b/src/helpers/radiolib/CustomLR2021.h index 0255b93cf29..1257f2ba751 100644 --- a/src/helpers/radiolib/CustomLR2021.h +++ b/src/helpers/radiolib/CustomLR2021.h @@ -68,6 +68,11 @@ class CustomLR2021 : public LR2021 { float getFreqMHz() const { return freqMHz; } + int16_t setRxBoostedGainMode(uint8_t level) { + const int16_t status = LR2021::setRxBoostedGainMode(level); + if (status == RADIOLIB_ERR_NONE) _rx_boosted = level != 0; + return status; + } bool getRxBoostedGainMode() const { return _rx_boosted; } int16_t startReceive() override { diff --git a/src/helpers/radiolib/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index b4ee6c97aa2..45d62c01414 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -2,6 +2,7 @@ #include #include "MeshCore.h" +#include "RXPowerSaving.h" // MC_TCXO_DELAY_US class CustomSX1262 : public SX1262 { uint32_t _preambleMillis = 66; @@ -10,8 +11,40 @@ class CustomSX1262 : public SX1262 { bool _headerSeen = false; public: + // The sleep -> RX transition RadioLib subtracts from a duty-cycle sleep. + // Protected in the RadioLib base, so the RXPS layer reads it through here. + uint32_t getTcxoDelay() const { return tcxoDelay; } + CustomSX1262(Module *mod) : SX1262(mod) { } + // RadioLib's begin() installs its own 5000 us TCXO startup delay and + // setTCXO() does not recalibrate, so MeshCore's value has to be written + // afterwards and the RC calibrations redone against the new clock-gating + // window. Overriding begin() puts that in one place: every variant reaches + // the radio through this class, whether it calls std_init() or begin() + // directly, so no target.cpp or variant .ini has to know about it. + int16_t begin(float freq = 434.0, float bw = 125.0, uint8_t sf = 9, uint8_t cr = 7, + uint8_t syncWord = RADIOLIB_SX126X_SYNC_WORD_PRIVATE, int8_t power = 10, + uint16_t preambleLength = 8, float tcxoVoltage = 1.6, + bool useRegulatorLDO = false) { + int16_t state = SX1262::begin(freq, bw, sf, cr, syncWord, power, preambleLength, + tcxoVoltage, useRegulatorLDO); + if (state == RADIOLIB_ERR_NONE) state = applyMeshCoreTcxoDelay(); + return state; + } + + // Read tcxoVoltage back from the chip object rather than from the argument: + // begin() zeroes it when it falls back to an XTAL, and re-asserting a TCXO + // supply on DIO3 for a board that has none would be worse than a long delay. + int16_t applyMeshCoreTcxoDelay() { + if (tcxoVoltage <= 0.0f) return RADIOLIB_ERR_NONE; + int16_t state = setTCXO(tcxoVoltage, MC_TCXO_DELAY_US); + RADIOLIB_ASSERT(state); + state = calibrate(RADIOLIB_SX126X_CALIBRATE_ALL); + delay(50); + return state; + } + #ifdef RP2040_PLATFORM bool std_init(SPIClassRP2040* spi = NULL) #else @@ -105,6 +138,24 @@ class CustomSX1262 : public SX1262 { return SX1262::startReceive(RADIOLIB_SX126X_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); } + int16_t startReceiveDutyCycle(uint32_t rxPeriod, uint32_t sleepPeriod, + RadioLibIrqFlags_t irqFlags = RADIOLIB_IRQ_RX_DEFAULT_FLAGS, + RadioLibIrqFlags_t irqMask = RADIOLIB_IRQ_RX_DEFAULT_MASK) { + int16_t state = SX1262::startReceiveDutyCycle(rxPeriod, sleepPeriod, irqFlags, irqMask); + if (state == RADIOLIB_ERR_NONE) { + // RadioLib stages RX through standby, which leaves a host-controlled + // RXEN switch in IDLE. Set it back to RX for the whole duty-cycle; + // boards without an external RXEN table are unaffected. + this->mod->setRfSwitchState(Module::MODE_RX); + } + return state; + } + + bool isChipBusy() { + uint32_t busy = this->mod->getGpio(); + return busy != RADIOLIB_NC && this->mod->hal->digitalRead(busy); + } + bool isReceiving() { uint32_t irq = getIrqFlags(); bool preamble = irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED; // bit 2 @@ -148,6 +199,19 @@ class CustomSX1262 : public SX1262 { return false; } + int16_t stopRTC() { + uint8_t rtcStop = 0x00; + int16_t state = writeRegister(RADIOLIB_SX126X_REG_RTC_CTRL, &rtcStop, 1); + RADIOLIB_ASSERT(state); + + uint8_t rtcEvent = 0; + state = readRegister(RADIOLIB_SX126X_REG_EVENT_MASK, &rtcEvent, 1); + RADIOLIB_ASSERT(state); + + rtcEvent |= 0x02; + return writeRegister(RADIOLIB_SX126X_REG_EVENT_MASK, &rtcEvent, 1); + } + void setPreambleMillis(uint32_t preambleMillis) { _preambleMillis = preambleMillis; MESH_DEBUG_PRINTLN("Set _preambleMillis=%u", _preambleMillis); @@ -162,4 +226,4 @@ class CustomSX1262 : public SX1262 { readRegister(RADIOLIB_SX126X_REG_RX_GAIN, &rxGain, 1); return (rxGain == RADIOLIB_SX126X_RX_GAIN_BOOSTED); } -}; \ No newline at end of file +}; diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index be3144716bf..84fe32cf370 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -10,9 +10,19 @@ class CustomSX1262Wrapper : public RadioLibWrapper { public: + // Ask the radio rather than assume RadioLib's 5000 us default: startReceive- + // DutyCycle subtracts tcxoDelay + 1000 from the sleep and the register + // underflows below that, so the floor is a property of this board's TCXO. A + // board without one can duty cycle with a far shorter sleep. The 100 us on + // top clears the 15.625 us register tick that must remain. + uint32_t rxPowerSavingTransitionUs() const override { + return ((CustomSX1262 *)_radio)->getTcxoDelay() + 1000; + } + CustomSX1262Wrapper(CustomSX1262& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } void setParams(float freq, float bw, uint8_t sf, uint8_t cr) override { + prepareForRadioConfig(); ((CustomSX1262 *)_radio)->setFrequency(freq); ((CustomSX1262 *)_radio)->setSpreadingFactor(sf); ((CustomSX1262 *)_radio)->setBandwidth(bw); @@ -23,14 +33,20 @@ class CustomSX1262Wrapper : public RadioLibWrapper { ((CustomSX1262 *)_radio)->setMaxPayloadMillis(pm.payloadMillis); } - bool isReceivingPacket() override { + bool isReceivingPacket() override { + // While duty-cycling, BUSY marks the sleep phase: probing IRQ flags over + // SPI there could wake the chip and break the cycle. Only skip the probe + // in that case - outside RXPS this must stay a real channel check, since + // Dispatcher::checkSend() relies on it to avoid transmitting over others. + if (_rx_ps_armed && ((CustomSX1262 *)_radio)->isChipBusy()) return false; return ((CustomSX1262 *)_radio)->isReceiving(); } + bool isChipBusy() override { + return ((CustomSX1262 *)_radio)->isChipBusy(); + } float getCurrentRSSI() override { return ((CustomSX1262 *)_radio)->getRSSI(false); } - float getLastRSSI() const override { return ((CustomSX1262 *)_radio)->getRSSI(); } - float getLastSNR() const override { return ((CustomSX1262 *)_radio)->getSNR(); } float packetScore(float snr, int packet_len) override { int sf = ((CustomSX1262 *)_radio)->spreadingFactor; @@ -38,10 +54,32 @@ class CustomSX1262Wrapper : public RadioLibWrapper { } uint8_t getSpreadingFactor() const override { return ((CustomSX1262 *)_radio)->spreadingFactor; } virtual void powerOff() override { + if (_rx_ps_armed) stopReceiveDutyCycle(); ((CustomSX1262 *)_radio)->sleep(false); } + bool supportsRxPowerSaving() const override { return true; } + +protected: + int16_t armDutyCycle(RadioLibIrqFlags_t irq_flags, RadioLibIrqFlags_t irq_mask, + uint32_t* eff_rx_us, uint32_t* eff_sleep_us) override { + // RadioLib programs the SX126x with exactly what we ask for (it only + // subtracts the wake-up transition from the sleep period internally). + *eff_rx_us = _rx_ps_rx_us; + *eff_sleep_us = _rx_ps_sleep_us; + return ((CustomSX1262 *)_radio)->startReceiveDutyCycle( + _rx_ps_rx_us, _rx_ps_sleep_us, irq_flags, irq_mask); + } + + int16_t stopDutyCycleHardware() override { + int16_t standby_err = _radio->standby(); + int16_t rtc_err = ((CustomSX1262 *)_radio)->stopRTC(); + return standby_err != RADIOLIB_ERR_NONE ? standby_err : rtc_err; + } + +public: bool setRxBoostedGainMode(bool en) override { + prepareForRadioConfig(); return ((CustomSX1262 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; } bool getRxBoostedGainMode() const override { diff --git a/src/helpers/radiolib/RXPowerSaving.cpp b/src/helpers/radiolib/RXPowerSaving.cpp new file mode 100644 index 00000000000..6a357390dfa --- /dev/null +++ b/src/helpers/radiolib/RXPowerSaving.cpp @@ -0,0 +1,245 @@ +#include "RXPowerSaving.h" +#include + +static uint32_t ceilRxPowerSavingValue(float value) { + uint32_t rounded = (uint32_t)value; + return value > (float)rounded ? rounded + 1 : rounded; +} + +// Both radios program their duty cycle in 15.625 us ticks and truncate the +// microsecond value on the way in, so a period of 10013 us is really 10000 us +// on air while the CLI happily reports 10013. Snapping to the nearest tick and +// returning the smallest microsecond value that still lands on it makes the +// reported periods the ones the hardware runs - which matters here, because the +// register value turned out to be what a duty-cycle defect keys on, not the +// microseconds we asked for. +static uint32_t rxPowerSavingTickToUs(uint64_t tick) { + if (tick == 0) tick = 1; + return (uint32_t)((tick * 125 + 7) / 8); // ceil(tick * 15.625) +} + +static uint32_t snapRxPowerSavingToTick(uint32_t us) { + return rxPowerSavingTickToUs(((uint64_t)us * 8 + 62) / 125); // nearest +} + +// Rounding the listen window to the *nearest* tick can shave a few microseconds +// off, and the timer guard below has no room to give: it must hold with the +// register values the radio actually runs. So that one rounds up. +static uint32_t snapRxPowerSavingUpToTick(uint32_t us) { + return rxPowerSavingTickToUs(((uint64_t)us * 8 + 124) / 125); // ceil +} + +bool isValidRxPowerSavingPeriod(uint32_t us) { + return us >= RX_POWERSAVING_MIN_PERIOD_US && us <= RX_POWERSAVING_MAX_PERIOD_US; +} + +uint8_t rxPowerSavingPreambleForSF(uint8_t sf) { + return sf <= 8 ? 32 : 16; +} + +bool isRxPowerSavingNumeric(const char* value) { + if (value == nullptr || *value == 0) return false; + while (*value) { + if (*value < '0' || *value > '9') return false; + value++; + } + return true; +} + +float rxPowerSavingCaptureCost(const RxPowerSavingControl* control) { + return control != nullptr ? control->rxPowerSavingCaptureCostSymbols() + : RX_POWERSAVING_CAPTURE_COST_SYMBOLS; +} + +uint32_t rxPowerSavingTransition(const RxPowerSavingControl* control) { + return control != nullptr ? control->rxPowerSavingTransitionUs() + : RX_POWERSAVING_TRANSITION_US; +} + +// rxPeriod register values measured to break SetRxDutyCycle on SX1262: the +// radio latches the preamble and then never validates a header, losing 35% of +// packets at SF6 and all of them at SF7. Both were reproduced on two boards +// with an LR1110 witnessing 100% of the same transmissions, and both bands are +// exactly one tick wide - 639 and 641 are lossless. +// +// This list is certainly incomplete: about thirty values were sampled out of +// the thousands a profile can generate. Levels 1-10 do not need it because the +// timer guard makes any value safe; it exists only for the unguarded maximum, +// where it is the one protection left. +static const uint32_t RX_POWERSAVING_BAD_RX_TICKS[] = {320, 640}; + +static uint32_t avoidBadRxPowerSavingTick(uint32_t rx_us) { + const uint32_t tick = (rx_us * 8) / 125; + for (uint32_t bad : RX_POWERSAVING_BAD_RX_TICKS) { + // One tick longer costs 15.625 us of listening and steps clear of the band. + if (tick == bad) return (uint32_t)((((uint64_t)tick + 1) * 125 + 7) / 8); + } + return rx_us; +} + +bool calcRxPowerSavingLevel(uint8_t level, uint8_t sf, float bw, uint8_t preamble, + uint32_t* rx_us, uint32_t* sleep_us, + float capture_cost_symbols, uint32_t transition_us) { + if (rx_us == nullptr || sleep_us == nullptr || level < 1 || + level > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL || sf < 5 || sf > 12 || + !isfinite(bw) || bw <= 0.0f || !isfinite(capture_cost_symbols) || + capture_cost_symbols <= 0 || capture_cost_symbols >= preamble || transition_us >= RX_POWERSAVING_MAX_PERIOD_US || (preamble != 16 && preamble != 32)) { + return false; + } + const bool unguarded = isRxPowerSavingUnguardedLevel(level); + + const float symbol_us = (1000.0f * (float)(1UL << sf)) / bw; + if (!isfinite(symbol_us) || symbol_us * 64.0f > (float)(UINT32_MAX / 8)) return false; + // Worst case a preamble starts the instant an RX window closes, so the part + // that survives into the next window is `preamble - sleep`. The absolute + // limit is therefore reached when that equals the chip's capture cost; the + // unguarded levels are pinned to geometries measured at or past that point, + // which is why they still need it. + const float sleep_edge_symbols = (float)preamble - capture_cost_symbols; + + float sleep_symbols; + float rx_symbols = 0.0f; // only used by the unguarded levels + if (unguarded) { + // Kept on the old interpolation because that is the geometry the bench + // measured: overdrive is the top of the profile, and `riskyWorkingMax` is + // the point past it where delivery started to fall. + float amount = 1.0f; + if (isRxPowerSavingRiskyWorkingMaxLevel(level)) { + const float virtual_level = preamble == 16 ? RX_POWERSAVING_RISKY_WORKING_MAX_VIRTUAL_P16 + : RX_POWERSAVING_RISKY_WORKING_MAX_VIRTUAL_P32; + amount = (virtual_level - 1.0f) / 9.0f; + } + const float rx_start_symbols = preamble == 16 ? 12.0f : 16.0f; + const float sleep_start_symbols = preamble == 16 ? 2.0f : 15.0f; + rx_symbols = rx_start_symbols + amount * (8.0f - rx_start_symbols); + sleep_symbols = sleep_start_symbols + amount * (sleep_edge_symbols - sleep_start_symbols); + } else { + // Guarded levels state their catch directly, so the sleep is just what is + // left of the preamble - no capture cost involved. The pad makes the node + // catch a fifth of a symbol more than promised, which is what keeps the + // generated register pairs off the defective ones. + const float catch_symbols = rxPowerSavingLevelCatch(level, preamble); + // A level that undertakes to catch less than this radio needs to latch is + // not usable: say so rather than return a geometry that cannot work. + if (catch_symbols < capture_cost_symbols) return false; + sleep_symbols = (float)preamble - catch_symbols - RX_POWERSAVING_MARGIN_PAD_SYMBOLS; + if (sleep_symbols < 0.0f) sleep_symbols = 0.0f; + } + + *sleep_us = (uint32_t)(sleep_symbols * symbol_us); + + // Below the hardware floor the duty cycle cannot be armed at all: the driver + // returns -708 and the wrapper quietly runs continuous RX, so the node keeps + // reporting power saving while spending full RX current. Just above that the + // SX1262 arms with ERR_NONE and then detects no preambles at all, which is + // worse. Measured floor: 6050-6100 us dead, 6200 us and up lossless. + const uint32_t min_sleep_us = transition_us + RX_POWERSAVING_MIN_SLEEP_MARGIN_US; + if (*sleep_us < min_sleep_us) { + if ((float)min_sleep_us > sleep_edge_symbols * symbol_us) { + // Even zero margin would not fit above the floor: no level of this + // profile can duty cycle at this SF/BW. Say so instead of returning + // something that will silently fall back. + return false; + } + // The floor eats into the margin. Levels below it collapse onto one point; + // the reply still reports the periods, so the loss of margin is visible. + *sleep_us = min_sleep_us; + } + *sleep_us = transition_us + snapRxPowerSavingToTick(*sleep_us - transition_us); + + // SetRxDutyCycle timer guard, straight out of the SX1261/2 datasheet: on + // preamble detection the radio restarts its timer with 2*rxPeriod + + // sleepPeriod (register values), and the packet dies if the header does not + // arrive inside it. The datasheet states the requirement as + // Tpreamble + Theader <= 2 * rxPeriod + sleepPeriod + // Measured: when it holds, no period value causes trouble. When it is broken + // the SX1262 usually gets away with it - but not always, and the exceptions + // are a single register tick wide. rxPeriod 640 (exactly 10.000 ms) loses + // 60-65% of packets at SF6 and everything at SF7, on two different boards, + // while 639 and 641 are lossless; rxPeriod 320 behaves the same way. Nothing + // in the register value predicts which ticks misbehave, so satisfy the + // condition instead of dodging the values. The listen window is therefore not + // a free parameter on the guarded scale - it is whatever the condition needs. + const float sync_symbols = + sf <= 6 ? RX_POWERSAVING_SYNC_SYMBOLS_LOW_SF : RX_POWERSAVING_SYNC_SYMBOLS; + const float need_us = + ((float)preamble + sync_symbols + RX_POWERSAVING_HEADER_SYMBOLS + + RX_POWERSAVING_TIMER_SLACK_SYMBOLS) * symbol_us; + // Work in the radio's own units: the driver truncates (sleep - transition) to + // 15.625 us ticks, so the microsecond difference overstates the programmed + // sleep by up to one tick - enough to leave the condition short on every + // level of a profile. + const uint32_t sleep_ticks = ((*sleep_us - transition_us) * 8) / 125; + const float programmed_sleep_us = (float)sleep_ticks * RX_POWERSAVING_TICK_US; + + if (unguarded) { + *rx_us = ceilRxPowerSavingValue(rx_symbols * symbol_us); + } else { + const float min_rx_us = (need_us - programmed_sleep_us) / 2.0f; + *rx_us = ceilRxPowerSavingValue(min_rx_us > 0.0f ? min_rx_us : symbol_us); + } + + *rx_us = snapRxPowerSavingUpToTick(*rx_us); + if (unguarded) *rx_us = avoidBadRxPowerSavingTick(*rx_us); + return true; +} + +void ensureRxPowerSavingDefaults(uint32_t* rx_us, uint32_t* sleep_us) { + if (!isValidRxPowerSavingPeriod(*rx_us)) *rx_us = RX_POWERSAVING_DEFAULT_RX_US; + if (!isValidRxPowerSavingPeriod(*sleep_us)) *sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US; +} + +bool recalcRxPowerSavingFromLevel(uint8_t level, uint8_t sf, float bw, uint8_t preamble, + uint32_t* rx_us, uint32_t* sleep_us, + float capture_cost_symbols, uint32_t transition_us) { + if (level < 1 || level > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL) return false; + if (preamble == 0) preamble = rxPowerSavingPreambleForSF(sf); + + uint32_t calculated_rx_us = 0; + uint32_t calculated_sleep_us = 0; + if (!calcRxPowerSavingLevel(level, sf, bw, preamble, &calculated_rx_us, &calculated_sleep_us, + capture_cost_symbols, transition_us) || + !isValidRxPowerSavingPeriod(calculated_rx_us) || + !isValidRxPowerSavingPeriod(calculated_sleep_us)) { + return false; + } + + *rx_us = calculated_rx_us; + *sleep_us = calculated_sleep_us; + return true; +} + +bool normalizeRxPowerSavingConfig(RxPowerSavingConfig* config, uint8_t sf, float bw, + float capture_cost_symbols, uint32_t transition_us) { + if (config == nullptr || sf < 5 || sf > 12 || !isfinite(bw) || bw <= 0) return false; + RxPowerSavingConfig proposed = *config; + proposed.enabled = proposed.enabled ? 1 : 0; + if (proposed.level > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL) { + proposed.level = RX_POWERSAVING_BALANCED_LEVEL; + } + if (proposed.preamble != 0 && proposed.preamble != 16 && proposed.preamble != 32) { + proposed.preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + } + if (proposed.level == 0) { + if (!isValidRxPowerSavingPeriod(proposed.rx_us) || + !isValidRxPowerSavingPeriod(proposed.sleep_us)) return false; + } else if (!recalcRxPowerSavingFromLevel(proposed.level, sf, bw, proposed.preamble, + &proposed.rx_us, &proposed.sleep_us, + capture_cost_symbols, transition_us)) { + return false; + } + *config = proposed; + return true; +} + +bool applyRxPowerSavingConfig(RxPowerSavingConfig* config, uint8_t sf, float bw, + RxPowerSavingControl* control) { + if (config == nullptr) return false; + const bool valid = !config->enabled || normalizeRxPowerSavingConfig( + config, sf, bw, rxPowerSavingCaptureCost(control), rxPowerSavingTransition(control)); + if (control == nullptr) return !config->enabled; + const bool accepted = control->setRxPowerSaving( + config->enabled && valid, config->rx_us, config->sleep_us); + control->setRxPowerSavingConfigError(!valid); + return valid && accepted; +} diff --git a/src/helpers/radiolib/RXPowerSaving.h b/src/helpers/radiolib/RXPowerSaving.h new file mode 100644 index 00000000000..5f52560e42f --- /dev/null +++ b/src/helpers/radiolib/RXPowerSaving.h @@ -0,0 +1,275 @@ +#pragma once + +#include + +static constexpr uint32_t RX_POWERSAVING_DEFAULT_RX_US = 65625UL; +static constexpr uint32_t RX_POWERSAVING_DEFAULT_SLEEP_US = 60000UL; +static constexpr uint32_t RX_POWERSAVING_MIN_PERIOD_US = 1000UL; +static constexpr uint32_t RX_POWERSAVING_MAX_PERIOD_US = 30000000UL; + +// The level scale is a catch dial, in symbols of the sender's preamble. +// +// Level N means: "at least this many symbols of the sender's preamble land +// inside an open RX window." That is the quantity the radio actually needs - +// a preamble is latched once enough of it has been heard - so the level states +// the requirement directly instead of stating a distance from an edge. +// +// The arithmetic follows from that in one step. Worst case, a preamble starts +// the instant an RX window closes, so what survives into the next window is +// `preamble - sleep`. Requiring that to be at least the level's catch gives +// +// sleep = preamble - catch +// +// and the chip's capture cost never enters the geometry. It becomes a +// validation instead: a level is usable when its catch is at least the cost. +// The floor of the scale is 8 symbols precisely because that is what the +// slower of the two families needs (LR11x0 8, SX126x 6), so **every level +// means the same geometry on both radios** - which the previous margin-based +// scale could not do, because the cost sat inside its formula. +// +// The two profiles get their own ladders. On a 16-symbol preamble there are +// only 8 symbols to spend between the floor and the whole preamble, so the +// steps are single symbols; on a 32-symbol preamble there are 24, so the low +// levels can take much larger strides where reception is guaranteed anyway. +static constexpr uint8_t RX_POWERSAVING_GUARDED_LEVELS = 8; +static constexpr float RX_POWERSAVING_LEVEL_CATCH_P16[RX_POWERSAVING_GUARDED_LEVELS] = { + 15.0f, 14.0f, 13.0f, 12.0f, 11.0f, 10.0f, 9.0f, 8.0f +}; +static constexpr float RX_POWERSAVING_LEVEL_CATCH_P32[RX_POWERSAVING_GUARDED_LEVELS] = { + 24.0f, 20.0f, 16.0f, 14.0f, 12.0f, 10.0f, 9.0f, 8.0f +}; + +// Smallest catch any guarded level asks for, and the reason it is 8: the +// LR11x0 needs 8 symbols to latch where the SX126x needs 6. +static constexpr float RX_POWERSAVING_MIN_CATCH_SYMBOLS = 8.0f; + +// On top of the ladder, every guarded level sleeps this much less than the +// arithmetic demands - so it catches 0.2 symbols more than its level promises. +// The reason is not capture safety: it is that specific register pairs the +// calculation lands on are defective (preamble latched, header never +// validated) in the same way isolated rxPeriod ticks 320 and 640 are. Nudging +// every generated geometry off those points cost 0.05 mA and took the worst +// measured cell from 3.33% packet loss to 0.11% across the whole ladder. +// Levels 9 and 10 are exempt - their geometries are pinned to bench +// measurements and shifting them would invalidate that data. +static constexpr float RX_POWERSAVING_MARGIN_PAD_SYMBOLS = 0.2f; + +// Past the guarded scale sit two settings that break the datasheet's +// SetRxDutyCycle timer condition on purpose. Overdrive is lossless and 4-12 +// percentage points cheaper in duty cycle; `riskyWorkingMax` is the measured +// working edge and drops ~2% of packets by design. +static constexpr uint8_t RX_POWERSAVING_OVERDRIVE_LEVEL = 9; +static constexpr uint8_t RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL = 10; +static constexpr float RX_POWERSAVING_RISKY_WORKING_MAX_VIRTUAL_P32 = 11.0f; +static constexpr float RX_POWERSAVING_RISKY_WORKING_MAX_VIRTUAL_P16 = 10.25f; + +// Top of the guarded scale: catches the ladder floor of 8 symbols (8.2 with +// the pad), the cheapest setting that still meets the datasheet condition and +// still works on both radio families. The CLI calls it `max`. +static constexpr uint8_t RX_POWERSAVING_MAX_LEVEL = RX_POWERSAVING_GUARDED_LEVELS; + +static constexpr uint8_t RX_POWERSAVING_CONSERVATIVE_LEVEL = 3; // catches 13 (P16) / 16 (P32) +static constexpr uint8_t RX_POWERSAVING_BALANCED_LEVEL = 6; // catches 10 (P16) / 10 (P32) +static constexpr uint8_t RX_POWERSAVING_PROFILE_PREAMBLE = 16; +static constexpr uint8_t RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES = 3; + +// Symbols of a sender's preamble consumed before a packet can be latched: +// ~2 to latch the preamble plus ~2 lost to RX-window startup, measured on +// SX1262 at SF8/BW62.5. Capture needs sleep <= sender_preamble - this. +// Note this is well below the 8 symbols RadioLib quotes (SX126x.h) - that +// figure is the datasheet's margin for *reliable* latching, not the floor. +// +// Chip dependent, so it belongs with the driver rather than with each board: +// a wrapper overrides rxPowerSavingCaptureCostSymbols() and every variant using +// that radio picks it up. LR1110 needs about one symbol more - on the bench it +// held 300/300 at a margin of 7.78 symbols and started dropping packets at +// 6.89, for both profiles, where SX1262 is clean down to 6. +static constexpr float RX_POWERSAVING_CAPTURE_COST_SYMBOLS = 6.0f; +static constexpr float RX_POWERSAVING_CAPTURE_COST_SYMBOLS_LR11X0 = 8.0f; + +// TCXO startup delay handed to SetDIO3AsTCXOCtrl (SX126x DS 13.3.6) / +// SetTcxoMode (LR11x0 UM 6.3.2). RadioLib defaults it to 5000 us and nothing +// overrode it, so every board paid 5 ms on every duty-cycle wake whatever +// crystal was fitted. Measured lowest working value on five modules: T096 400, +// Tracker V2 300, Waveshare 150, ThinkNode M3 200, T1000-E 200 us - so 1600 +// keeps 4x margin over the worst of them. +// +// One knob for the whole tree, in `arduino_base`, because it is a property of +// the parts MeshCore is built with rather than of any one board. A variant that +// needs its own value defines it in its own build_flags and the later -D wins. +#ifndef MC_TCXO_DELAY_US + #define MC_TCXO_DELAY_US 1600 +#endif + +// Sleep -> RX transition: RadioLib subtracts tcxoDelay + 1000 us from the +// requested sleep before writing the register, and the hardware spends that +// long saving context, restarting the XTAL and locking the PLL. Everything +// below derives from it, so a board that knows its real TCXO delay overrides +// one method and both the sleep floor and the timer guard follow. +static constexpr uint32_t RX_POWERSAVING_TRANSITION_US = MC_TCXO_DELAY_US + 1000; + +// Slack the timer guard leaves on top of the datasheet condition, in symbols. +// +// Closing the condition exactly is not enough. The calculator lands on register +// pairs that satisfy it by a hair and still lose packets: rx=2476/sleep=2455 +// ticks clears it by 22 us at 0.12 symbol and drops 2-6% on every SX1262 tried, +// while moving any one component clears the fault. It is the same family as the +// isolated bad ticks 320 and 640 - the condition does not predict which pairs +// misbehave, so the answer is to stop generating geometries that sit on its +// edge. One whole symbol of slack is what the bench evidence supports. +#ifndef MC_RXPS_TIMER_SLACK_SYMBOLS + #define MC_RXPS_TIMER_SLACK_SYMBOLS 1.0f +#endif +static constexpr float RX_POWERSAVING_TIMER_SLACK_SYMBOLS = MC_RXPS_TIMER_SLACK_SYMBOLS; + +// Both radio families program their duty cycle in these units, and truncate the +// microsecond value on the way in. Everything the timer guard compares has to +// be expressed in whole ticks or it comes out short by a fraction of one. +static constexpr float RX_POWERSAVING_TICK_US = 15.625f; + +// Margin above the transition before a duty cycle actually works; see below. +static constexpr uint32_t RX_POWERSAVING_MIN_SLEEP_MARGIN_US = 250; + +// Symbols between the end of the sender's preamble and the end of the header: +// the LoRa sync word plus the 8-symbol explicit header. SF5/SF6 use a longer +// sync word than SF7 and above. Used by the SetRxDutyCycle timer guard. +static constexpr float RX_POWERSAVING_SYNC_SYMBOLS_LOW_SF = 6.25f; +static constexpr float RX_POWERSAVING_SYNC_SYMBOLS = 4.25f; +static constexpr float RX_POWERSAVING_HEADER_SYMBOLS = 8.0f; + +// Shortest sleep that actually produces a working duty cycle. +// +// RadioLib subtracts tcxoDelay + 1000 us from the requested sleep before +// writing the register. At or below that the register underflows and arming +// fails with RADIOLIB_ERR_INVALID_SLEEP_PERIOD (-708), after which the wrapper +// falls back to continuous RX while the config still says power saving is on. +// Measured on SX1262: the P16 profile fails to arm on levels 1-5 at SF6 and on +// levels 1-2 at SF7, exactly where that bound predicts. +// +// But arming successfully is NOT the boundary. Just above it the SX1262 accepts +// the command, returns ERR_NONE, and then detects **zero** preambles - a +// silently deaf receiver, which is worse than the honest -708. Measured twice, +// on two different SF and RX windows: sleep 6050-6100 us is dead, 6200 us and +// everything above it is lossless (40/40 at every value up to 21617 us). The +// chip needs roughly 200 us of *programmed* sleep, i.e. ~12 register ticks, so +// this leaves 250 us of margin on top of the transition time. +// +// The default assumes the 5000 us TCXO delay RadioLib installs; a wrapper that +// can read its radio overrides this with the board's real value. +static constexpr uint32_t RX_POWERSAVING_MIN_SLEEP_US = + RX_POWERSAVING_TRANSITION_US + RX_POWERSAVING_MIN_SLEEP_MARGIN_US; + +class RxPowerSavingArmRetryState { + uint8_t _consecutive_failures = 0; + +public: + bool canAttempt() const { + return _consecutive_failures < RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES; + } + void recordSuccess() { _consecutive_failures = 0; } + void recordFailure() { + if (_consecutive_failures < RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES) { + _consecutive_failures++; + } + } + void reset() { _consecutive_failures = 0; } + uint8_t consecutiveFailures() const { return _consecutive_failures; } +}; + +struct RxPowerSavingConfig { + uint8_t enabled = 0; + uint32_t rx_us = RX_POWERSAVING_DEFAULT_RX_US; + uint32_t sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US; + uint8_t level = RX_POWERSAVING_BALANCED_LEVEL; + uint8_t preamble = RX_POWERSAVING_PROFILE_PREAMBLE; +}; + +struct RxPowerSavingStatus { + bool supported = false; + bool armed = false; + int16_t last_error = 0; + uint32_t arm_failures = 0; + // Periods the radio actually runs with. A driver may clamp the requested + // values to satisfy a hardware constraint, so these can differ from the + // configured rx_us/sleep_us. Zero means "nothing armed yet". + uint32_t effective_rx_us = 0; + uint32_t effective_sleep_us = 0; +}; + +class RxPowerSavingControl { +public: + virtual ~RxPowerSavingControl() = default; + + virtual bool setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) { + (void)rx_us; + (void)sleep_us; + return !enabled; + } + virtual RxPowerSavingStatus getRxPowerSavingStatus() const { return {}; } + virtual void setRxPowerSavingConfigError(bool invalid) { (void)invalid; } + virtual bool isRxPowerSavingCalibrationActive() const { return false; } + + // Symbols of the sender's preamble consumed before a packet can be latched. + // Overridden per radio family; see RX_POWERSAVING_CAPTURE_COST_SYMBOLS. + virtual float rxPowerSavingCaptureCostSymbols() const { + return RX_POWERSAVING_CAPTURE_COST_SYMBOLS; + } + + // Sleep -> RX transition of this radio, in microseconds. The sleep floor and + // the SetRxDutyCycle timer guard are both derived from it, so a wrapper that + // can read its own tcxoDelay only needs to override this one method. + virtual uint32_t rxPowerSavingTransitionUs() const { + return RX_POWERSAVING_TRANSITION_US; + } +}; + +// Margin this level asks for, in symbols. Only meaningful on the guarded scale. +// Symbols of the sender's preamble this level undertakes to catch. The two +// profiles have separate ladders, so the profile has to be passed in; anything +// other than 16 or 32 is treated as the 16-symbol profile, matching the +// validation in calcRxPowerSavingLevel(). +inline float rxPowerSavingLevelCatch(uint8_t level, uint8_t preamble) { + if (level < 1 || level > RX_POWERSAVING_GUARDED_LEVELS) return 0.0f; + return preamble == 32 ? RX_POWERSAVING_LEVEL_CATCH_P32[level - 1] + : RX_POWERSAVING_LEVEL_CATCH_P16[level - 1]; +} + +inline bool isRxPowerSavingOverdriveLevel(uint8_t level) { + return level == RX_POWERSAVING_OVERDRIVE_LEVEL; +} + +inline bool isRxPowerSavingRiskyWorkingMaxLevel(uint8_t level) { + return level == RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL; +} + +// Both levels past the guarded scale run outside the datasheet's timer +// condition. Worth surfacing wherever a level is reported. +inline bool isRxPowerSavingUnguardedLevel(uint8_t level) { + return level >= RX_POWERSAVING_OVERDRIVE_LEVEL; +} + +bool isValidRxPowerSavingPeriod(uint32_t us); +uint8_t rxPowerSavingPreambleForSF(uint8_t sf); +bool isRxPowerSavingNumeric(const char* value); +// capture_cost_symbols defaults to the SX126x figure so callers without a radio +// handy still compile; anything holding a RxPowerSavingControl should pass +// rxPowerSavingCaptureCost(control) instead. +float rxPowerSavingCaptureCost(const RxPowerSavingControl* control); +uint32_t rxPowerSavingTransition(const RxPowerSavingControl* control); +bool calcRxPowerSavingLevel(uint8_t level, uint8_t sf, float bw, uint8_t preamble, + uint32_t* rx_us, uint32_t* sleep_us, + float capture_cost_symbols = RX_POWERSAVING_CAPTURE_COST_SYMBOLS, + uint32_t transition_us = RX_POWERSAVING_TRANSITION_US); +void ensureRxPowerSavingDefaults(uint32_t* rx_us, uint32_t* sleep_us); +bool recalcRxPowerSavingFromLevel(uint8_t level, uint8_t sf, float bw, uint8_t preamble, + uint32_t* rx_us, uint32_t* sleep_us, + float capture_cost_symbols = RX_POWERSAVING_CAPTURE_COST_SYMBOLS, + uint32_t transition_us = RX_POWERSAVING_TRANSITION_US); +bool normalizeRxPowerSavingConfig(RxPowerSavingConfig* config, uint8_t sf, float bw, + float capture_cost_symbols = RX_POWERSAVING_CAPTURE_COST_SYMBOLS, + uint32_t transition_us = RX_POWERSAVING_TRANSITION_US); + +// The requested configuration survives a timing failure; only the radio falls back. +bool applyRxPowerSavingConfig(RxPowerSavingConfig* config, uint8_t sf, float bw, + RxPowerSavingControl* control); +static constexpr int16_t RX_POWERSAVING_ERR_TIMING = -10001; diff --git a/src/helpers/radiolib/RXPowerSavingCLI.cpp b/src/helpers/radiolib/RXPowerSavingCLI.cpp new file mode 100644 index 00000000000..d9b9b75114a --- /dev/null +++ b/src/helpers/radiolib/RXPowerSavingCLI.cpp @@ -0,0 +1,268 @@ +#include "RXPowerSavingCLI.h" + +#include +#include +#include +#include +#include + +// Suffix printed after the level so an unguarded profile is never mistaken for +// an ordinary one, in both the `set` echo and the `get` report. +static const char* rxPowerSavingLevelTag(uint8_t level) { + if (isRxPowerSavingRiskyWorkingMaxLevel(level)) return "(riskyWorkingMax)"; + if (isRxPowerSavingOverdriveLevel(level)) return "(overdrive)"; + return ""; +} + +static bool parseRxPowerSavingUint32(const char* value, uint32_t* parsed) { + if (parsed == nullptr || !isRxPowerSavingNumeric(value)) return false; + + uint32_t result = 0; + while (*value) { + uint32_t digit = (uint32_t)(*value++ - '0'); + if (result > (UINT32_MAX - digit) / 10U) return false; + result = result * 10U + digit; + } + *parsed = result; + return true; +} + +bool RXPowerSavingCLI::set(const char* value, uint8_t sf, float bw, + RxPowerSavingConfig* config, RxPowerSavingControl* control, + char* reply, size_t reply_size) { + if (value == nullptr || config == nullptr || reply == nullptr || reply_size == 0) return false; + + RxPowerSavingConfig proposed = *config; + uint8_t level = 0; + uint8_t preamble = rxPowerSavingPreambleForSF(sf); + bool level_requested = false; + bool preamble_overridden = false; + + ensureRxPowerSavingDefaults(&proposed.rx_us, &proposed.sleep_us); + + if (strcmp(value, "off") == 0) { + proposed.enabled = 0; + } else if (strcmp(value, "on") == 0 || strcmp(value, "conservative") == 0) { + proposed.enabled = 1; + level = RX_POWERSAVING_CONSERVATIVE_LEVEL; + preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + level_requested = true; + preamble_overridden = true; + } else if (strcmp(value, "max") == 0) { + // Top of the guarded scale. Like every named preset it assumes the worst + // case a mixed network can present - a sender using a 16-symbol preamble - + // so at SF<=8 it is markedly less economical than `level 10`, which follows + // the SF and picks the 32-symbol profile. `max preamble 32` gets that back. + proposed.enabled = 1; + level = RX_POWERSAVING_MAX_LEVEL; + preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + level_requested = true; + preamble_overridden = true; + } else if (strcmp(value, "overdrive") == 0) { + // One step past the guarded scale: measured lossless, outside the + // datasheet's timer condition. Same worst-case preamble as the presets above. + proposed.enabled = 1; + level = RX_POWERSAVING_OVERDRIVE_LEVEL; + preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + level_requested = true; + preamble_overridden = true; + } else if (strcmp(value, "riskyWorkingMax") == 0) { + // This one is not lossless, so the risk is explicit in its name. Same + // worst-case preamble as the presets above. + proposed.enabled = 1; + level = RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL; + preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + level_requested = true; + preamble_overridden = true; + } else if (strcmp(value, "balanced") == 0) { + proposed.enabled = 1; + level = RX_POWERSAVING_BALANCED_LEVEL; + preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + level_requested = true; + preamble_overridden = true; + } else { + char input[128]; + if (strlen(value) >= sizeof(input)) { + snprintf(reply, reply_size, + "ERROR: use off|on|conservative|balanced|max|overdrive|riskyWorkingMax|level <1-10>| "); + return false; + } + strcpy(input, value); + + const char* parts[4]; + int count = mesh::Utils::parseTextParts(input, parts, 4, ' '); + uint32_t first = 0; + uint32_t second = 0; + if (count == 1 && parseRxPowerSavingUint32(parts[0], &first)) { + if (first < 1 || first > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL) { + snprintf(reply, reply_size, "ERROR: level range is 1-%u (or max|overdrive|riskyWorkingMax); preamble is 16 or 32", + RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL); + return false; + } + level = (uint8_t)first; + level_requested = true; + proposed.enabled = 1; + } else if (count >= 2 && strcmp(parts[1], "preamble") == 0 && + (strcmp(parts[0], "max") == 0 || strcmp(parts[0], "overdrive") == 0 || + strcmp(parts[0], "riskyWorkingMax") == 0)) { + if (count != 3 || !parseRxPowerSavingUint32(parts[2], &first) || + (first != 16 && first != 32)) { + snprintf(reply, reply_size, "ERROR: use %s preamble <16|32>", parts[0]); + return false; + } + level = strcmp(parts[0], "overdrive") == 0 ? RX_POWERSAVING_OVERDRIVE_LEVEL + : strcmp(parts[0], "max") == 0 ? RX_POWERSAVING_MAX_LEVEL + : RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL; + preamble = (uint8_t)first; + level_requested = true; + preamble_overridden = true; + proposed.enabled = 1; + } else if (count == 2 && strcmp(parts[0], "level") == 0 && + parseRxPowerSavingUint32(parts[1], &first)) { + if (first < 1 || first > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL) { + snprintf(reply, reply_size, "ERROR: level range is 1-%u (or max|overdrive|riskyWorkingMax); preamble is 16 or 32", + RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL); + return false; + } + level = (uint8_t)first; + level_requested = true; + proposed.enabled = 1; + } else if (count == 4 && strcmp(parts[0], "level") == 0 && + parseRxPowerSavingUint32(parts[1], &first) && + strcmp(parts[2], "preamble") == 0 && + parseRxPowerSavingUint32(parts[3], &second)) { + if (first < 1 || first > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL || + (second != 16 && second != 32)) { + snprintf(reply, reply_size, "ERROR: level range is 1-%u (or max|overdrive|riskyWorkingMax); preamble is 16 or 32", + RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL); + return false; + } + level = (uint8_t)first; + preamble = (uint8_t)second; + level_requested = true; + preamble_overridden = true; + proposed.enabled = 1; + } else if (count == 2 && parseRxPowerSavingUint32(parts[0], &first) && + parseRxPowerSavingUint32(parts[1], &second)) { + proposed.rx_us = first; + proposed.sleep_us = second; + proposed.enabled = 1; + } else { + snprintf(reply, reply_size, + "ERROR: use off|on|conservative|balanced|max|overdrive|riskyWorkingMax|level <1-10>| "); + return false; + } + } + + if (proposed.enabled && (sf < 5 || sf > 12 || !isfinite(bw) || bw <= 0)) { + if (control) { + control->setRxPowerSaving(false, proposed.rx_us, proposed.sleep_us); + control->setRxPowerSavingConfigError(true); + } + snprintf(reply, reply_size, "ERROR: invalid SF/BW for RXPS"); + return false; + } + if (level_requested && + !recalcRxPowerSavingFromLevel(level, sf, bw, preamble, + &proposed.rx_us, &proposed.sleep_us, + rxPowerSavingCaptureCost(control), + rxPowerSavingTransition(control))) { + // Level and preamble were validated above, so the only way to get here is + // that no sleep exists which both arms the hardware and still lets a + // preamble of this length be caught - i.e. the symbol is too short for RXPS + // at this SF/BW. Saying "level range is 1-10" would send the user hunting + // for the wrong thing. + if (control) { + control->setRxPowerSaving(false, proposed.rx_us, proposed.sleep_us); + control->setRxPowerSavingConfigError(true); + } + snprintf(reply, reply_size, "ERROR: RXPS does not fit this SF/BW with preamble %lu", + (unsigned long)preamble); + return false; + } + if (!isValidRxPowerSavingPeriod(proposed.rx_us) || + !isValidRxPowerSavingPeriod(proposed.sleep_us)) { + snprintf(reply, reply_size, "ERROR: range is %lu-%lu us", + (unsigned long)RX_POWERSAVING_MIN_PERIOD_US, + (unsigned long)RX_POWERSAVING_MAX_PERIOD_US); + return false; + } + + bool applied = control != nullptr + ? control->setRxPowerSaving(proposed.enabled != 0, proposed.rx_us, proposed.sleep_us) + : proposed.enabled == 0; + if (!applied) { + snprintf(reply, reply_size, "ERROR: RX powersaving unsupported"); + return false; + } + + if (control) control->setRxPowerSavingConfigError(false); + + if (level_requested) { + proposed.level = level; + proposed.preamble = preamble_overridden ? preamble : 0; + } else if (strcmp(value, "off") != 0) { + proposed.level = 0; + proposed.preamble = 0; + } + + *config = proposed; + snprintf(reply, reply_size, "OK - %s,level=%lu%s,preamble=%lu,rx=%lu,sleep=%lu", + config->enabled ? "on" : "off", (unsigned long)config->level, + rxPowerSavingLevelTag(config->level), + (unsigned long)config->preamble, (unsigned long)config->rx_us, + (unsigned long)config->sleep_us); + return true; +} + +void RXPowerSavingCLI::get(const RxPowerSavingConfig* config, + const RxPowerSavingControl* control, + uint8_t sf, float bw, char* reply, size_t reply_size) { + if (config == nullptr || reply == nullptr || reply_size == 0) return; + + RxPowerSavingStatus status = control != nullptr + ? control->getRxPowerSavingStatus() + : RxPowerSavingStatus{}; + // Preamble symbols the node actually catches. Not simply the number the level + // asked for: the sleep floor can shorten the sleep further, and at SF6 with + // the 16-symbol profile that collapses the first few levels onto the same + // point. Reporting the requested value there would be a lie. + char margin[24]; + margin[0] = 0; + if (status.last_error == 0 && isValidRxPowerSavingPeriod(config->sleep_us) && + config->level >= 1 && config->level <= RX_POWERSAVING_GUARDED_LEVELS && + sf >= 5 && sf <= 12 && isfinite(bw) && bw > 0.0f) { + const uint8_t preamble = config->preamble ? config->preamble : rxPowerSavingPreambleForSF(sf); + const float symbol_us = (1000.0f * (float)(1UL << sf)) / bw; + const float effective = (float)preamble - (float)config->sleep_us / symbol_us; + // No %f on this platform's printf, and rounding the whole and fractional + // parts separately overflows: 4.999 came out as "4.10". + const float scaled = effective * 10.0f + (effective < 0.0f ? -0.5f : 0.5f); + if (isfinite(scaled) && scaled > (float)INT_MIN && scaled < (float)INT_MAX) { + const int tenths = (int)scaled; + snprintf(margin, sizeof(margin), ",catch=%d.%d", tenths / 10, + tenths < 0 ? -(tenths % 10) : tenths % 10); + } + } + + // Overdrive has to be visible here, otherwise it reads as an ordinary level + // and nobody remembers it runs outside the datasheet rule. + int len = snprintf(reply, reply_size, + "> desired=%s,effective=%s,supported=%s,err=%d,fail=%lu,level=%lu%s%s,preamble=%lu,rx=%lu,sleep=%lu", + config->enabled ? "on" : "off", status.armed ? "armed" : "continuous", + status.supported ? "yes" : "no", (int)status.last_error, + (unsigned long)status.arm_failures, (unsigned long)config->level, + rxPowerSavingLevelTag(config->level), margin, + (unsigned long)config->preamble, (unsigned long)config->rx_us, + (unsigned long)config->sleep_us); + + // Only report the periods the hardware really runs with when a driver had to + // clamp them - printing them always would not fit the 160 byte reply buffer. + if (len > 0 && (size_t)len < reply_size && status.armed && + (status.effective_rx_us != config->rx_us || + status.effective_sleep_us != config->sleep_us)) { + snprintf(reply + len, reply_size - (size_t)len, ",erx=%lu,eslp=%lu", + (unsigned long)status.effective_rx_us, + (unsigned long)status.effective_sleep_us); + } +} diff --git a/src/helpers/radiolib/RXPowerSavingCLI.h b/src/helpers/radiolib/RXPowerSavingCLI.h new file mode 100644 index 00000000000..62acfb95acf --- /dev/null +++ b/src/helpers/radiolib/RXPowerSavingCLI.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +#include "RXPowerSaving.h" + +class RXPowerSavingCLI { +public: + static bool set(const char* value, uint8_t sf, float bw, RxPowerSavingConfig* config, + RxPowerSavingControl* control, char* reply, size_t reply_size); + // sf/bw are needed to turn the stored periods back into symbols, which is + // what the level scale is expressed in. + static void get(const RxPowerSavingConfig* config, const RxPowerSavingControl* control, + uint8_t sf, float bw, char* reply, size_t reply_size); +}; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index e4d2ba1c272..30ce3678a1c 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -11,6 +11,12 @@ #define NUM_NOISE_FLOOR_SAMPLES 64 #define SAMPLING_THRESHOLD 14 +#define NF_CALIB_INTERVAL_MS 60000UL +#define NF_CALIB_TIMEOUT_MS 5000UL +#define NF_CALIB_SETTLE_MS 20UL +#define NF_CALIB_SAMPLE_INTERVAL_MS 1UL +#define NF_CALIB_MAX_SAMPLE_ATTEMPTS (NUM_NOISE_FLOOR_SAMPLES * 4U) + static volatile uint8_t state = STATE_IDLE; // this function is called when a complete packet @@ -48,17 +54,32 @@ uint32_t RadioLibWrapper::getRngSeed() { } void RadioLibWrapper::setTxPower(int8_t dbm) { + prepareForRadioConfig(); #if defined(USE_LR2021) + // On LR2021, setOutputPower() writes the PA config and TxParams, which are + // standby-only commands. recvRaw() keeps state == STATE_RX after readData on + // this platform ("LR2021 stays in Rx"), so - unlike the SX126x path, which + // falls back to STATE_IDLE and re-arms on the next recvRaw() - nothing calls + // startReceive() again by itself. Writing the PA config while listening could + // therefore leave the receiver down until the next reboot. Drop to standby + // first and let checkRecv() re-arm Rx, as resetAGC() and + // applySideDetectorConfig() already do. idle(); #endif _radio->setOutputPower(dbm); } void RadioLibWrapper::idle() { + if (_rx_ps_armed) stopReceiveDutyCycle(); _radio->standby(); state = STATE_IDLE; // need another startReceive() } +void RadioLibWrapper::powerOff() { + if (_rx_ps_armed) stopReceiveDutyCycle(); + _radio->sleep(); +} + void RadioLibWrapper::triggerNoiseFloorCalibrate(int threshold) { _threshold = threshold; if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES) { // ignore trigger if currently sampling @@ -72,40 +93,147 @@ void RadioLibWrapper::doResetAGC() { } void RadioLibWrapper::resetAGC() { - // make sure we're not mid-receive of packet! - if ((state & STATE_INT_READY) != 0 || isReceivingPacket()) return; + // make sure we're not mid-receive or mid-transmit of a packet + if (isPacketPendingOrReceiving() || state == STATE_TX_WAIT) return; + + if (_rx_ps_armed) stopReceiveDutyCycle(); doResetAGC(); - state = STATE_IDLE; // trigger a startReceive() + state = STATE_IDLE; - // Reset noise floor sampling so it reconverges from scratch. - // Without this, a stuck _noise_floor of -120 makes the sampling threshold - // too low (-106) to accept normal samples (~-105), self-reinforcing the - // stuck value even after the receiver has recovered. + // Recalibrate synchronously so callers never observe the temporary zero + // used to bypass a stale sampling threshold after an AGC reset. + const int16_t previous_noise_floor = _noise_floor; _noise_floor = 0; _num_floor_samples = 0; _floor_sample_sum = 0; -} -void RadioLibWrapper::loop() { - if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { - if (!isReceivingPacket()) { - int rssi = getCurrentRSSI(); - if (rssi < _noise_floor + SAMPLING_THRESHOLD) { // only consider samples below current floor + sampling THRESHOLD - _num_floor_samples++; - _floor_sample_sum += rssi; + _nf_calib_active = true; // force startReceiveMode() into continuous RX + bool packet_in_progress = false; + int16_t err = startReceiveMode(); + if (err == RADIOLIB_ERR_NONE) { + state = STATE_RX; + delay(NF_CALIB_SETTLE_MS); + + uint16_t sample_attempts = 0; + while (_num_floor_samples < NUM_NOISE_FLOOR_SAMPLES && + sample_attempts++ < NF_CALIB_MAX_SAMPLE_ATTEMPTS) { + if (isPacketPendingOrReceiving()) { + packet_in_progress = true; + break; + } + sampleNoiseFloorOnce(); + if (_num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { + delay(NF_CALIB_SAMPLE_INTERVAL_MS); } } - } else if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES && _floor_sample_sum != 0) { - _noise_floor = _floor_sample_sum / NUM_NOISE_FLOOR_SAMPLES; - if (_noise_floor < -120) { - _noise_floor = -120; // clamp to lower bound of -120dBi + } + + if (!publishNoiseFloor()) { + _noise_floor = previous_noise_floor; + _num_floor_samples = 0; + _floor_sample_sum = 0; + } + + _nf_calib_active = false; + _nf_last_calib = millis(); + + // A frame can start after resetAGC()'s initial idle check. Keep continuous + // RX alive until recvRaw() consumes it; restarting RX here would abort a + // frame that is between preamble detection and RX_DONE. + if (!packet_in_progress) packet_in_progress = isPacketPendingOrReceiving(); + if (!packet_in_progress) requestRestartRecv(); +} + + + + + +// Clear the RX/idle state so the next loop calls startRecv() again, without +// dropping an STATE_INT_READY that the ISR may raise while we are in here. +// A plain `state = STATE_IDLE` loses that flag (and with it, a received +// packet) when setFlag() fires between the test and the store. +void RadioLibWrapper::requestRestartRecv() { + noInterrupts(); + if ((state & ~STATE_INT_READY) != STATE_TX_WAIT) { + state &= STATE_INT_READY; // STATE_IDLE, but keep a pending interrupt + } + interrupts(); +} + +bool RadioLibWrapper::isPacketPendingOrReceiving() { + return (state & STATE_INT_READY) != 0 || isReceivingPacket(); +} + + + + + +void RadioLibWrapper::sampleNoiseFloorOnce() { + int rssi = getCurrentRSSI(); + if (rssi < _noise_floor + SAMPLING_THRESHOLD) { + _num_floor_samples++; + _floor_sample_sum += rssi; + } +} + +bool RadioLibWrapper::publishNoiseFloor() { + if (_num_floor_samples < NUM_NOISE_FLOOR_SAMPLES || _floor_sample_sum == 0) return false; + + _noise_floor = _floor_sample_sum / NUM_NOISE_FLOOR_SAMPLES; + if (_noise_floor < -120) { + _noise_floor = -120; // clamp to lower bound of -120dBi + } + _floor_sample_sum = 0; + + #ifdef MESH_DEBUG_NOISE_FLOOR + MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor); + #endif + return true; +} + +void RadioLibWrapper::noiseFloorCalibCheck() { + unsigned long now = millis(); + if (_nf_calib_active) { + if (!_rx_ps_enabled || (long)(now - _nf_calib_deadline) >= 0) { + endNoiseFloorCalib(now); + } else if (_rx_ps_armed && !isPacketPendingOrReceiving()) { + // A packet may have delayed the RXPS-to-continuous-RX transition. + requestRestartRecv(); } + } else if (_rx_ps_enabled && _rx_ps_armed && state == STATE_RX && + (_nf_last_calib == 0 || now - _nf_last_calib >= NF_CALIB_INTERVAL_MS) && + !isPacketPendingOrReceiving()) { + _nf_calib_active = true; + _nf_calib_deadline = now + NF_CALIB_TIMEOUT_MS; + _nf_sample_from = now + NF_CALIB_SETTLE_MS; + _num_floor_samples = 0; _floor_sample_sum = 0; + if (!isPacketPendingOrReceiving()) requestRestartRecv(); + } +} - #ifdef MESH_DEBUG_NOISE_FLOOR - MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor); - #endif +void RadioLibWrapper::endNoiseFloorCalib(unsigned long now) { + _nf_calib_active = false; + _nf_last_calib = now; + if (!isPacketPendingOrReceiving()) requestRestartRecv(); +} + +void RadioLibWrapper::loop() { + if (_rx_ps_restart_pending && (state & ~STATE_INT_READY) != STATE_TX_WAIT && + !isPacketPendingOrReceiving()) { + _rx_ps_restart_pending = false; + requestRestartRecv(); + } + noiseFloorCalibCheck(); + + if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { + if (!_rx_ps_armed && !(_nf_calib_active && (long)(millis() - _nf_sample_from) < 0) && + !isReceivingPacket()) { + sampleNoiseFloorOnce(); + } + } else if (publishNoiseFloor()) { + if (_nf_calib_active) endNoiseFloorCalib(millis()); } } @@ -113,7 +241,7 @@ void RadioLibWrapper::startRecv() { #if defined(USE_LR2021) _radio->standby(); // without this LR2021 can throw -706 when calling startReceive after hardware CAD when side detectors are enabled #endif - int err = _radio->startReceive(); + int err = startReceiveMode(); if (err == RADIOLIB_ERR_NONE) { state = STATE_RX; } else { @@ -121,6 +249,117 @@ void RadioLibWrapper::startRecv() { } } +int16_t RadioLibWrapper::startReceiveMode() { + // Periodic calibration switches RXPS to continuous RX. Re-check at the + // hardware transition so a preamble that arrived after the scheduler's + // idle check is not aborted by stopReceiveDutyCycle(). + if (_nf_calib_active && _rx_ps_armed && isPacketPendingOrReceiving()) { + return RADIOLIB_ERR_NONE; + } + if (_rx_ps_armed) stopReceiveDutyCycle(); + if (!_rx_ps_enabled || _nf_calib_active) { + _rx_ps_armed = false; + const int16_t err = startContinuousReceive(); + _rx_ps_last_error = err; + return err; + } + + if (!_rx_ps_arm_retry.canAttempt()) { + // arming has failed repeatedly; stop paying for a doomed SPI round-trip on + // every RX restart. A config change (setRxPowerSaving) re-enables retries. + _rx_ps_armed = false; + const int16_t err = startContinuousReceive(); + if (err != RADIOLIB_ERR_NONE) _rx_ps_last_error = err; + return err; + } + + const RadioLibIrqFlags_t irq_flags = + RADIOLIB_IRQ_RX_DEFAULT_FLAGS | + (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED); + const RadioLibIrqFlags_t irq_mask = + (1UL << RADIOLIB_IRQ_RX_DONE) | + (1UL << RADIOLIB_IRQ_TIMEOUT) | + (1UL << RADIOLIB_IRQ_CRC_ERR) | + (1UL << RADIOLIB_IRQ_HEADER_ERR); + + uint32_t eff_rx_us = _rx_ps_rx_us; + uint32_t eff_sleep_us = _rx_ps_sleep_us; + int16_t err = armDutyCycle(irq_flags, irq_mask, &eff_rx_us, &eff_sleep_us); + if (err == RADIOLIB_ERR_NONE) { + _rx_ps_armed = true; + _rx_ps_last_error = RADIOLIB_ERR_NONE; + _rx_ps_arm_retry.recordSuccess(); + _rx_ps_eff_rx_us = eff_rx_us; + _rx_ps_eff_sleep_us = eff_sleep_us; + return err; + } + + _rx_ps_armed = false; + _rx_ps_last_error = err; + _rx_ps_eff_rx_us = 0; + _rx_ps_eff_sleep_us = 0; + n_rx_ps_arm_failures++; + _rx_ps_arm_retry.recordFailure(); + MESH_DEBUG_PRINTLN("RadioLibWrapper: startReceiveDutyCycle(%d), continuous RX fallback", err); + stopDutyCycleHardware(); + int16_t fallback_err = startContinuousReceive(); + if (fallback_err != RADIOLIB_ERR_NONE) _rx_ps_last_error = fallback_err; + return fallback_err; +} + +void RadioLibWrapper::stopReceiveDutyCycle() { + int16_t err = stopDutyCycleHardware(); + _rx_ps_armed = false; + _rx_ps_eff_rx_us = 0; + _rx_ps_eff_sleep_us = 0; + if (err != RADIOLIB_ERR_NONE) _rx_ps_last_error = err; +} + +bool RadioLibWrapper::isPacketReady() { + if (!_rx_ps_armed) return true; + return _radio->checkIrq(RADIOLIB_IRQ_RX_DONE) != 0; +} + +void RadioLibWrapper::prepareForRadioConfig() { + if (!_rx_ps_armed) return; + + stopReceiveDutyCycle(); + requestRestartRecv(); +} + +bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) { + if (enabled && (!isValidRxPowerSavingPeriod(rx_us) || !isValidRxPowerSavingPeriod(sleep_us))) { + _rx_ps_enabled = false; + _rx_ps_last_error = RX_POWERSAVING_ERR_TIMING; + requestRestartRecv(); + return false; + } + if (enabled && !supportsRxPowerSaving()) return false; + + _rx_ps_enabled = enabled; + _rx_ps_rx_us = rx_us; + _rx_ps_sleep_us = sleep_us; + _rx_ps_last_error = RADIOLIB_ERR_NONE; + _rx_ps_arm_retry.reset(); // new config, give the hardware a fresh chance + _rx_ps_restart_pending = true; + if ((state & ~STATE_INT_READY) != STATE_TX_WAIT && !isPacketPendingOrReceiving()) { + _rx_ps_restart_pending = false; + requestRestartRecv(); + } + return true; +} + +RxPowerSavingStatus RadioLibWrapper::getRxPowerSavingStatus() const { + RxPowerSavingStatus status; + status.supported = supportsRxPowerSaving(); + status.armed = _rx_ps_armed; + status.last_error = _rx_ps_config_error ? RX_POWERSAVING_ERR_TIMING : _rx_ps_last_error; + status.arm_failures = n_rx_ps_arm_failures; + status.effective_rx_us = _rx_ps_eff_rx_us; + status.effective_sleep_us = _rx_ps_eff_sleep_us; + return status; +} + bool RadioLibWrapper::isInRecvMode() const { return (state & ~STATE_INT_READY) == STATE_RX; } @@ -128,17 +367,23 @@ bool RadioLibWrapper::isInRecvMode() const { int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { int len = 0; if (state & STATE_INT_READY) { - len = _radio->getPacketLength(); - if (len > 0) { - if (len > sz) { len = sz; } - int err = _radio->readData(bytes, len); - if (err != RADIOLIB_ERR_NONE) { - MESH_DEBUG_PRINTLN("RadioLibWrapper: error: readData(%d)", err); - len = 0; - n_recv_errors++; - } else { - // Serial.print(" readData() -> "); Serial.println(len); - n_recv++; + if (isPacketReady()) { + if (_rx_ps_armed) stopReceiveDutyCycle(); + len = _radio->getPacketLength(); + if (len > 0) { + if (len > sz) { len = sz; } + _last_snr = _radio->getSNR(); + _last_rssi = _radio->getRSSI(); + _last_metrics_valid = true; + int err = _radio->readData(bytes, len); + if (err != RADIOLIB_ERR_NONE) { + MESH_DEBUG_PRINTLN("RadioLibWrapper: error: readData(%d)", err); + len = 0; + n_recv_errors++; + } else { + // Serial.print(" readData() -> "); Serial.println(len); + n_recv++; + } } } #if defined(USE_LR2021) @@ -149,12 +394,7 @@ int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { } if (state != STATE_RX) { - int err = _radio->startReceive(); - if (err == RADIOLIB_ERR_NONE) { - state = STATE_RX; - } else { - MESH_DEBUG_PRINTLN("RadioLibWrapper: error: startReceive(%d)", err); - } + startRecv(); } return len; } @@ -164,6 +404,7 @@ uint32_t RadioLibWrapper::getEstAirtimeFor(int len_bytes) { } bool RadioLibWrapper::startSendRaw(const uint8_t* bytes, int len) { + if (_rx_ps_armed) stopReceiveDutyCycle(); _board->onBeforeTransmit(); int err = _radio->startTransmit((uint8_t *) bytes, len); if (err == RADIOLIB_ERR_NONE) { @@ -197,16 +438,19 @@ int16_t RadioLibWrapper::performChannelScan() { bool RadioLibWrapper::isChannelActive() { // int.thresh: RSSI-based interference detection (relative to noise floor) - if (_threshold != 0 && getCurrentRSSI() > _noise_floor + _threshold) return true; + if (_threshold != 0 && !(_rx_ps_armed && isChipBusy()) && + getCurrentRSSI() > _noise_floor + _threshold) return true; // cad: hardware channel activity detection if (_cad_enabled) { + if (_rx_ps_armed) stopReceiveDutyCycle(); int16_t result = performChannelScan(); // scanChannel() triggers DIO interrupt (CAD done) which sets STATE_INT_READY // via setFlag() ISR. Clear it before restarting RX so recvRaw() doesn't // try to read a non-existent packet and count a spurious recv error. state = STATE_IDLE; startRecv(); + if (result == RADIOLIB_LORA_DETECTED) ++_cad_busy_count; if (result != RADIOLIB_CHANNEL_FREE) return true; } @@ -214,10 +458,12 @@ bool RadioLibWrapper::isChannelActive() { } float RadioLibWrapper::getLastRSSI() const { - return _radio->getRSSI(); + if (_last_metrics_valid) return _last_rssi; + return _rx_ps_armed ? 0 : _radio->getRSSI(); } float RadioLibWrapper::getLastSNR() const { - return _radio->getSNR(); + if (_last_metrics_valid) return _last_snr; + return _rx_ps_armed ? 0 : _radio->getSNR(); } // Approximate SNR threshold per SF for successful reception (based on Semtech datasheets) diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 77dd93116b5..23fa38aee7f 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -2,6 +2,7 @@ #include #include +#include "RXPowerSaving.h" #ifdef USE_CC310_HW_CRYPTO #include @@ -11,28 +12,81 @@ struct PacketMillis { uint32_t payloadMillis; // header-valid -> rx-done deadline }; -class RadioLibWrapper : public mesh::Radio { +class RadioLibWrapper : public mesh::Radio, public RxPowerSavingControl { protected: PhysicalLayer* _radio; mesh::MainBoard* _board; uint32_t n_recv, n_sent, n_recv_errors; + uint32_t _cad_busy_count = 0; int16_t _noise_floor, _threshold; + float _last_rssi, _last_snr; + bool _last_metrics_valid; bool _cad_enabled; uint16_t _num_floor_samples; int32_t _floor_sample_sum; uint8_t _preamble_sf; + bool _rx_ps_restart_pending = false; + bool _rx_ps_config_error = false; + bool _rx_ps_enabled; + bool _rx_ps_armed; + uint32_t _rx_ps_rx_us; + uint32_t _rx_ps_sleep_us; + uint32_t _rx_ps_eff_rx_us; // periods the driver actually armed with + uint32_t _rx_ps_eff_sleep_us; + int16_t _rx_ps_last_error; + uint32_t n_rx_ps_arm_failures; + RxPowerSavingArmRetryState _rx_ps_arm_retry; + + bool _nf_calib_active; + unsigned long _nf_last_calib; + unsigned long _nf_calib_deadline; + unsigned long _nf_sample_from; + void idle(); void startRecv(); + void requestRestartRecv(); + bool isPacketPendingOrReceiving(); + void prepareForRadioConfig(); + void sampleNoiseFloorOnce(); + bool publishNoiseFloor(); + void noiseFloorCalibCheck(); + void endNoiseFloorCalib(unsigned long now); + int16_t startReceiveMode(); + void stopReceiveDutyCycle(); + // eff_rx_us/eff_sleep_us report the periods the driver really programmed, + // which may be clamped to satisfy a hardware constraint. + virtual int16_t armDutyCycle(RadioLibIrqFlags_t irq_flags, + RadioLibIrqFlags_t irq_mask, + uint32_t* eff_rx_us, uint32_t* eff_sleep_us) { + (void)irq_flags; + (void)irq_mask; + (void)eff_rx_us; + (void)eff_sleep_us; + return RADIOLIB_ERR_UNSUPPORTED; + } + virtual int16_t stopDutyCycleHardware() { return _radio->standby(); } + virtual int16_t startContinuousReceive() { return _radio->startReceive(); } + virtual bool isPacketReady(); + virtual bool isChipBusy() { return false; } float packetScoreInt(float snr, int sf, int packet_len); virtual bool isReceivingPacket() =0; virtual void doResetAGC(); public: - RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) : _radio(&radio), _board(&board), _preamble_sf(0) { n_recv = n_sent = 0; } + RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard& board) + : _radio(&radio), _board(&board), _last_rssi(0), _last_snr(0), _last_metrics_valid(false), + _preamble_sf(0), _rx_ps_enabled(false), _rx_ps_armed(false), + _rx_ps_rx_us(RX_POWERSAVING_DEFAULT_RX_US), + _rx_ps_sleep_us(RX_POWERSAVING_DEFAULT_SLEEP_US), _rx_ps_eff_rx_us(0), + _rx_ps_eff_sleep_us(0), _rx_ps_last_error(RADIOLIB_ERR_NONE), + n_rx_ps_arm_failures(0), _nf_calib_active(false), + _nf_last_calib(0), _nf_calib_deadline(0), _nf_sample_from(0) { + n_recv = n_sent = n_recv_errors = 0; + } void begin() override; - virtual void powerOff() { _radio->sleep(); } + virtual void powerOff(); int recvRaw(uint8_t* bytes, int sz) override; uint32_t getEstAirtimeFor(int len_bytes) override; bool startSendRaw(const uint8_t* bytes, int len) override; @@ -40,6 +94,13 @@ class RadioLibWrapper : public mesh::Radio { void onSendFinished() override; bool isInRecvMode() const override; bool isChannelActive(); + virtual bool supportsRxPowerSaving() const { return false; } + void setRxPowerSavingConfigError(bool invalid) override { _rx_ps_config_error = invalid; } + bool setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) override; + RxPowerSavingStatus getRxPowerSavingStatus() const override; + bool isRxPowerSavingCalibrationActive() const override { return _nf_calib_active; } + + bool hasPendingReceive() { return isPacketPendingOrReceiving(); } bool isReceiving() override { if (isReceivingPacket()) return true; @@ -65,10 +126,17 @@ class RadioLibWrapper : public mesh::Radio { void loop() override; + uint32_t getCADBusyCount() const { return _cad_busy_count; } uint32_t getPacketsRecv() const { return n_recv; } uint32_t getPacketsRecvErrors() const { return n_recv_errors; } uint32_t getPacketsSent() const { return n_sent; } - void resetStats() { n_recv = n_sent = n_recv_errors = 0; } + void resetStats() { + n_recv = n_sent = n_recv_errors = n_rx_ps_arm_failures = 0; + _cad_busy_count = 0; + // Clearing diagnostics also grants a fresh set of arm attempts. If they + // fail again, the normal three-failure continuous-RX fallback applies. + _rx_ps_arm_retry.reset(); + } virtual float getLastRSSI() const override; virtual float getLastSNR() const override; diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index 0dc892c3a52..595668c0926 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -165,7 +165,9 @@ static RAK12035_SoilMoisture RAK12035; #endif #ifdef RAK_WISBLOCK_GPS -static uint32_t gpsResetPin = 0; +// -1 = no enable pin; out-of-range values are no-ops in pinMode/digitalWrite, +// while 0 would be a real GPIO (P0.00 = LFXO crystal on nRF52) +static uint32_t gpsResetPin = -1; static bool i2cGPSFlag = false; static bool serialGPSFlag = false; #ifndef TELEM_RAK12500_ADDRESS diff --git a/src/helpers/ui/SSD1306Display.cpp b/src/helpers/ui/SSD1306Display.cpp index ab2d77fbc33..53c53344d59 100644 --- a/src/helpers/ui/SSD1306Display.cpp +++ b/src/helpers/ui/SSD1306Display.cpp @@ -18,36 +18,44 @@ ColorVal UIColor::popup_txt = SSD1306_WHITE; ColorVal UIColor::corp_blue = SSD1306_WHITE; bool SSD1306Display::begin() { - if (!_isOn) { - if (_peripher_power) _peripher_power->claim(); - _isOn = true; + if (!_isOn && _peripher_power) _peripher_power->claim(); + const bool ok = display.begin(SSD1306_SWITCHCAPVCC, DISPLAY_ADDRESS, true, false) + && i2c_probe(Wire, DISPLAY_ADDRESS); + if (!ok) { + if (_peripher_power) _peripher_power->release(); + _isOn = false; + return false; } - #ifdef DISPLAY_ROTATION +#ifdef DISPLAY_ROTATION display.setRotation(DISPLAY_ROTATION); - #endif - return display.begin(SSD1306_SWITCHCAPVCC, DISPLAY_ADDRESS, true, false) && i2c_probe(Wire, DISPLAY_ADDRESS); +#endif + _isOn = true; + return true; } void SSD1306Display::turnOn() { if (!_isOn) { - if (_peripher_power) _peripher_power->claim(); - _isOn = true; // set before begin() to prevent double claim - if (_peripher_power) begin(); // re-init display after power was cut + if (_peripher_power) { + if (!begin()) return; // power was cut: claim it and re-init; released again if no display answers + } else { + // No switched supply, so the panel kept its state. A full begin() would also pulse + // PIN_OLED_RESET, which defaults to 21 = SX126X_POWER_EN (FEM enable) on RAK3401. + _isOn = true; + } } display.ssd1306_command(SSD1306_DISPLAYON); } void SSD1306Display::turnOff() { + if (!_isOn) return; display.ssd1306_command(SSD1306_DISPLAYOFF); - if (_isOn) { - if (_peripher_power) { + if (_peripher_power) { #if PIN_OLED_RESET >= 0 - digitalWrite(PIN_OLED_RESET, LOW); + digitalWrite(PIN_OLED_RESET, LOW); #endif - _peripher_power->release(); - } - _isOn = false; + _peripher_power->release(); } + _isOn = false; } void SSD1306Display::clear() { diff --git a/src/helpers/ui/ST7735Display.cpp b/src/helpers/ui/ST7735Display.cpp index 344f5b1d9d3..774d56cfed2 100644 --- a/src/helpers/ui/ST7735Display.cpp +++ b/src/helpers/ui/ST7735Display.cpp @@ -417,16 +417,51 @@ bool ST7735Display::i2c_probe(TwoWire& wire, uint8_t addr) { #define PIN_TFT_LEDA_CTL_ACTIVE HIGH #endif -// Color scheme -ColorVal UIColor::window_bkg = ST77XX_WHITE; -ColorVal UIColor::title_bkg = ST77XX_BLUE; -ColorVal UIColor::title_txt = ST77XX_WHITE; -ColorVal UIColor::primary_txt = ST77XX_BLACK; -ColorVal UIColor::secondary_txt = (18 << 11) | (36 << 5) | 18; // mid-gray -ColorVal UIColor::warning_txt = ST77XX_ORANGE; -ColorVal UIColor::popup_bkg = ST77XX_CYAN; -ColorVal UIColor::popup_txt = ST77XX_BLACK; -ColorVal UIColor::corp_blue = 0x001A; +// Color scheme - Dark theme before v1.17 +ColorVal UIColor::window_bkg = ST77XX_BLACK; +ColorVal UIColor::title_bkg = ST77XX_BLACK; +ColorVal UIColor::title_txt = ST77XX_GREEN; +ColorVal UIColor::primary_txt = ST77XX_GREEN; +ColorVal UIColor::secondary_txt = ST77XX_YELLOW; +ColorVal UIColor::warning_txt = ST77XX_RED; +ColorVal UIColor::popup_bkg = ST77XX_BLACK; +ColorVal UIColor::popup_txt = ST77XX_WHITE; +ColorVal UIColor::corp_blue = ST77XX_BLUE; + +#ifdef NRF52_PLATFORM + #include +#endif + +// Move SCK/MOSI between the SPI peripheral and plain inputs without touching the bus. +// On Tracker V2 and Wireless Tracker the LoRa radio shares this SPI peripheral (HSPI), and +// SPIClass::end() gates its clock, so the radio's next transfer would never complete. +// pinMode() alone cannot restore them: ESP32 routes the pin back to plain GPIO, and nRF52 +// drops the SCK input buffer that SPIM needs. +static void attachSpiPins() { +#ifdef ESP_PLATFORM + spiAttachSCK(_spi->bus(), PIN_TFT_SCL); + spiAttachMOSI(_spi->bus(), PIN_TFT_SDA); +#elif defined(NRF52_PLATFORM) + // same configuration as SPIClass::begin() + nrf_gpio_cfg(g_ADigitalPinMap[PIN_TFT_SCL], NRF_GPIO_PIN_DIR_OUTPUT, NRF_GPIO_PIN_INPUT_CONNECT, + NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_H0H1, NRF_GPIO_PIN_NOSENSE); + nrf_gpio_cfg(g_ADigitalPinMap[PIN_TFT_SDA], NRF_GPIO_PIN_DIR_OUTPUT, NRF_GPIO_PIN_INPUT_DISCONNECT, + NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_H0H1, NRF_GPIO_PIN_NOSENSE); +#else + pinMode(PIN_TFT_SCL, OUTPUT); + pinMode(PIN_TFT_SDA, OUTPUT); +#endif +} + +static void detachSpiPins() { +#ifdef ESP_PLATFORM + spiDetachSCK(_spi->bus(), PIN_TFT_SCL); + spiDetachMOSI(_spi->bus(), PIN_TFT_SDA); +#else + pinMode(PIN_TFT_SCL, INPUT); + pinMode(PIN_TFT_SDA, INPUT); +#endif +} bool ST7735Display::begin() { if (!sprite) { @@ -512,6 +547,14 @@ void ST7735Display::_resetAndInit() { void ST7735Display::turnOn() { if (!_isOn) { if (_peripher_power) _peripher_power->claim(); + + // Restore GPIOs + pinMode(PIN_TFT_CS, OUTPUT); + pinMode(PIN_TFT_DC, OUTPUT); + pinMode(PIN_TFT_RST, OUTPUT); + pinMode(PIN_TFT_LEDA_CTL, OUTPUT); + attachSpiPins(); + _resetAndInit(); sendCommand(ST77XX_DISPON); @@ -525,9 +568,17 @@ void ST7735Display::turnOff() { if (_isOn) { sendCommand(ST77XX_DISPOFF); - //digitalWrite(PIN_TFT_RST, LOW); + // digitalWrite(PIN_TFT_RST, LOW); // Now turn off the backlight digitalWrite(PIN_TFT_LEDA_CTL, !PIN_TFT_LEDA_CTL_ACTIVE); + + // Stop the signal pins from powering the display after its supply is cut. + pinMode(PIN_TFT_CS, INPUT); + pinMode(PIN_TFT_DC, INPUT); + detachSpiPins(); + pinMode(PIN_TFT_RST, INPUT); + pinMode(PIN_TFT_LEDA_CTL, INPUT); + _isOn = false; if (_peripher_power) _peripher_power->release(); diff --git a/test/display_mocks/Adafruit_GFX.h b/test/display_mocks/Adafruit_GFX.h new file mode 100644 index 00000000000..6f70f09beec --- /dev/null +++ b/test/display_mocks/Adafruit_GFX.h @@ -0,0 +1 @@ +#pragma once diff --git a/test/display_mocks/Adafruit_SSD1306.h b/test/display_mocks/Adafruit_SSD1306.h new file mode 100644 index 00000000000..a452899f3b6 --- /dev/null +++ b/test/display_mocks/Adafruit_SSD1306.h @@ -0,0 +1,21 @@ +#pragma once +#include +#define SSD1306_BLACK 0 +#define SSD1306_WHITE 1 +#define SSD1306_SWITCHCAPVCC 2 +#define SSD1306_DISPLAYON 0xAF +#define SSD1306_DISPLAYOFF 0xAE +class Adafruit_SSD1306 { +public: + inline static bool init_ok=true; + inline static int starts=0, commands=0; + Adafruit_SSD1306(int,int,TwoWire*,int) {} + bool begin(int,int,bool,bool) { ++starts;return init_ok; } + void ssd1306_command(int) { ++commands; } + void setRotation(int) {} void clearDisplay() {} void display() {} + void setTextColor(int) {} void setTextSize(int) {} void cp437(bool) {} + void setCursor(int,int) {} void print(const char*) {} + void fillRect(int,int,int,int,int) {} void drawRect(int,int,int,int,int) {} + void drawBitmap(int,int,const uint8_t*,int,int,int) {} + void getTextBounds(const char*,int,int,int16_t*,int16_t*,uint16_t*,uint16_t*) {} +}; diff --git a/test/display_mocks/Arduino.h b/test/display_mocks/Arduino.h new file mode 100644 index 00000000000..c06ff8f54de --- /dev/null +++ b/test/display_mocks/Arduino.h @@ -0,0 +1,8 @@ +#pragma once +#include_next +#define HIGH 1 +#define LOW 0 +#define OUTPUT 1 +inline int pin_levels[64] = {}; +inline void pinMode(int, int) {} +inline void digitalWrite(int pin, int level) { pin_levels[pin] = level; } diff --git a/test/display_mocks/Wire.h b/test/display_mocks/Wire.h new file mode 100644 index 00000000000..7f7c426c2c4 --- /dev/null +++ b/test/display_mocks/Wire.h @@ -0,0 +1,4 @@ +#pragma once +#include +struct TwoWire { inline static int error=0; void beginTransmission(uint8_t) {} uint8_t endTransmission() { return error; } }; +inline TwoWire Wire; diff --git a/test/host/CMakeLists.txt b/test/host/CMakeLists.txt new file mode 100644 index 00000000000..578bdb8acde --- /dev/null +++ b/test/host/CMakeLists.txt @@ -0,0 +1,46 @@ +# Host tests for the RadioLib wrappers, radio drivers and radio preferences. +# cmake -S test/host -B .pio/host -DRADIOLIB_DIR= +# cmake --build .pio/host && ctest --test-dir .pio/host +cmake_minimum_required(VERSION 3.16) +project(meshcore_host_tests CXX) +if(NOT RADIOLIB_DIR) + message(FATAL_ERROR "Set RADIOLIB_DIR to a RadioLib 7.8.1 source tree, e.g. .pio/libdeps//RadioLib") +endif() +add_compile_definitions(RADIOLIB_GODMODE=1 RADIOLIB_STATIC_ONLY=1) +add_subdirectory(${RADIOLIB_DIR} radiolib) +enable_testing() + +# Keep assertions active even when a caller selects Release. +add_executable(wrapper_lifecycle wrapper_lifecycle.cpp ../../src/helpers/radiolib/RadioLibWrappers.cpp ../../src/helpers/radiolib/RXPowerSaving.cpp) +target_compile_features(wrapper_lifecycle PRIVATE cxx_std_17) +target_compile_options(wrapper_lifecycle PRIVATE -UNDEBUG -include ${CMAKE_CURRENT_SOURCE_DIR}/platform_stub.h) +target_include_directories(wrapper_lifecycle PRIVATE ../../src ../../test/mocks) +target_link_libraries(wrapper_lifecycle RadioLib) +add_test(NAME wrapper_lifecycle COMMAND wrapper_lifecycle) + +add_executable(wrapper_lifecycle_lr2021 wrapper_lifecycle.cpp ../../src/helpers/radiolib/RadioLibWrappers.cpp ../../src/helpers/radiolib/RXPowerSaving.cpp) +target_compile_definitions(wrapper_lifecycle_lr2021 PRIVATE USE_LR2021=1) +target_compile_features(wrapper_lifecycle_lr2021 PRIVATE cxx_std_17) +target_compile_options(wrapper_lifecycle_lr2021 PRIVATE -UNDEBUG -include ${CMAKE_CURRENT_SOURCE_DIR}/platform_stub.h) +target_include_directories(wrapper_lifecycle_lr2021 PRIVATE ../../src ../../test/mocks) +target_link_libraries(wrapper_lifecycle_lr2021 RadioLib) +add_test(NAME wrapper_lifecycle_lr2021 COMMAND wrapper_lifecycle_lr2021) + +add_executable(driver_spi driver_spi.cpp ../../src/helpers/radiolib/RXPowerSaving.cpp) +target_compile_features(driver_spi PRIVATE cxx_std_17) +target_compile_options(driver_spi PRIVATE -UNDEBUG -include ${CMAKE_CURRENT_SOURCE_DIR}/platform_stub.h) +target_include_directories(driver_spi PRIVATE ../../test/mocks ../../src ../../src/helpers/radiolib) +target_link_libraries(driver_spi RadioLib) +add_test(NAME driver_spi COMMAND driver_spi) + +# Real NodePrefs (repeater/room/sensor and companion) with CommonRadioPrefs; needs GoogleTest. +find_package(GTest REQUIRED) +add_executable(config_lifecycle config_lifecycle.cpp + ../../src/helpers/CommonRadioPrefs.cpp ../../src/helpers/ConfigSerializer.cpp + ../../src/helpers/DynamicConfigSerializer.cpp ../../src/helpers/TxtDataHelpers.cpp + ../../src/Utils.cpp ../../src/Packet.cpp + ../../src/helpers/radiolib/RXPowerSaving.cpp ../../src/helpers/radiolib/RXPowerSavingCLI.cpp) +target_compile_features(config_lifecycle PRIVATE cxx_std_17) +target_include_directories(config_lifecycle PRIVATE ../../src config_target ../../test/mocks) +target_link_libraries(config_lifecycle GTest::gtest) # the suite has its own main() +add_test(NAME config_lifecycle COMMAND config_lifecycle) diff --git a/test/host/config_lifecycle.cpp b/test/host/config_lifecycle.cpp new file mode 100644 index 00000000000..79b2554c2b6 --- /dev/null +++ b/test/host/config_lifecycle.cpp @@ -0,0 +1,150 @@ +#include +#include +#include +#include +#define NodePrefs CompanionNodePrefs +#include "../../examples/companion_radio/NodePrefs.h" +#undef NodePrefs +struct TestFileSystem { void mkdir(const char*) {} }; +#define FILESYSTEM TestFileSystem +#include +#include "target.h" +class ReplayStream : public Stream { + const char* _text; + int _pos = 0; + int _len; + +public: + explicit ReplayStream(const char* text) : _text(text), _len(strlen(text)) { } + + int available() override { return _len - _pos; } + int read() override { return _pos < _len ? _text[_pos++] : -1; } + int peek() override { return _pos < _len ? _text[_pos] : -1; } +}; + +class CaptureStream : public Stream { + std::string _text; + + size_t emit(long long value) { + char text[24]; + int length = snprintf(text, sizeof(text), "%lld", value); + return write(reinterpret_cast(text), length); + } + +public: + size_t write(uint8_t value) override { + _text.push_back(static_cast(value)); + return 1; + } + + size_t write(const uint8_t* buffer, size_t size) override { + _text.append(reinterpret_cast(buffer), size); + return size; + } + + size_t print(unsigned char value, int = DEC) override { return emit(value); } + size_t print(int value, int = DEC) override { return emit(value); } + size_t print(unsigned int value, int = DEC) override { return emit(value); } + size_t print(long value, int = DEC) override { return emit(value); } + size_t print(unsigned long value, int = DEC) override { return emit(value); } + size_t print(long long value, int = DEC) override { return emit(value); } + size_t print(unsigned long long value, int = DEC) override { return emit(value); } + + size_t print(double value, int precision = 2) override { + char out[64]; int length=snprintf(out,sizeof(out),"%.*f",precision,value); + return write(reinterpret_cast(out),length); + } + const std::string& text() const { return _text; } +}; + + +template class RadioPrefsTest : public testing::Test { +protected: + T prefs; + void SetUp() override { radio_driver=ConfigTestRadio{};prefs.sf=8;prefs.bw=62.5f; } +}; +using PrefsTypes=testing::Types; +TYPED_TEST_SUITE(RadioPrefsTest,PrefsTypes); +TYPED_TEST(RadioPrefsTest, FreshDefaultsAndSavedOffArePreserved) { + EXPECT_EQ(1,this->prefs.rxps.enabled); EXPECT_EQ(6,this->prefs.rxps.level); EXPECT_EQ(16,this->prefs.rxps.preamble); + ReplayStream input("{radio:{rxps_en:0,rxps_level:8,rxps_preamble:32}}"); + ASSERT_TRUE(this->prefs.loadSerial(input)); EXPECT_EQ(0,this->prefs.rxps.enabled); EXPECT_EQ(8,this->prefs.rxps.level); + ASSERT_TRUE(this->prefs.getRadioPrefs()->applyRxPowerSaving(8,62.5)); EXPECT_FALSE(radio_driver.enabled); +} +TYPED_TEST(RadioPrefsTest, ManualPeriodsRoundTripAndSurviveRetuning) { + auto* p=this->prefs.getRadioPrefs();char reply[160]; + ASSERT_TRUE(p->applyRxPowerSaving(8,62.5)); + ASSERT_TRUE(p->handleCommand("set radio.rxps 12345 23456",0,reply)); ASSERT_EQ(0,this->prefs.rxps.level); + CaptureStream output;ASSERT_TRUE(this->prefs.saveSerial(output)); + TypeParam loaded;ReplayStream input(output.text().c_str());ASSERT_TRUE(loaded.loadSerial(input)); + ASSERT_TRUE(loaded.getRadioPrefs()->applyRxPowerSaving(10,125)); + EXPECT_EQ(12345U,radio_driver.rx);EXPECT_EQ(23456U,radio_driver.sleep); + EXPECT_EQ(1,loaded.rxps.enabled); EXPECT_EQ(0,loaded.rxps.level); +} +TYPED_TEST(RadioPrefsTest, TemporaryRadioFailureRestoresWithoutChangingStoredIntent) { + auto* p=this->prefs.getRadioPrefs();const auto original=this->prefs.rxps; + ASSERT_TRUE(p->applyRxPowerSaving(8,62.5)); auto rx=radio_driver.rx; + EXPECT_FALSE(p->applyRxPowerSaving(6,250)); EXPECT_FALSE(radio_driver.enabled); EXPECT_TRUE(radio_driver.invalid); + EXPECT_EQ(1,this->prefs.rxps.enabled);EXPECT_EQ(original.rx_us,this->prefs.rxps.rx_us); + ASSERT_TRUE(p->applyRxPowerSaving(8,62.5));EXPECT_TRUE(radio_driver.enabled);EXPECT_EQ(rx,radio_driver.rx);EXPECT_FALSE(radio_driver.invalid); +} +TYPED_TEST(RadioPrefsTest, SharedCLIValidatesChangesAndTracksDirtyState) { + auto* p=this->prefs.getRadioPrefs();char reply[160];p->applyRxPowerSaving(8,62.5);this->prefs.clearDirty(); + ASSERT_TRUE(p->handleCommand("set radio.rxps off",0,reply));EXPECT_FALSE(radio_driver.enabled);EXPECT_TRUE(this->prefs.isDirty()); + ASSERT_TRUE(p->handleCommand("set radio.rxps balanced",0,reply));EXPECT_TRUE(radio_driver.enabled); + ASSERT_TRUE(p->handleCommand("get radio.rxps ",0,reply));EXPECT_NE(nullptr,strstr(reply,"desired=on")); + ASSERT_TRUE(p->handleCommand("set radio 869.618,250,6,5",0,reply));EXPECT_FALSE(radio_driver.enabled);EXPECT_TRUE(radio_driver.invalid);EXPECT_EQ(1,this->prefs.rxps.enabled); + EXPECT_EQ(6,this->prefs.sf);EXPECT_NE(nullptr,strstr(reply,"invalid timing")); + ASSERT_TRUE(p->handleCommand("get radio.rxps",0,reply)); + EXPECT_NE(nullptr,strstr(reply,"err=-10001"));EXPECT_EQ(nullptr,strstr(reply,"catch=")); +} +TYPED_TEST(RadioPrefsTest, UnsupportedRXPSDoesNotOverwriteSavedChoice) { + radio_driver.supported=false;char reply[160];this->prefs.rxps.enabled=0; + this->prefs.getRadioPrefs()->handleCommand("set radio.rxps balanced",0,reply); + EXPECT_EQ(0,this->prefs.rxps.enabled);EXPECT_NE(nullptr,strstr(reply,"unsupported")); +} +TEST(CommonPowerPrefs, SavedRebootAndPowerSavingOverrideDefaults) { + NodePrefs p;EXPECT_EQ(1,p.powersaving_enabled);EXPECT_EQ(0,p.reboot_interval); + ReplayStream input("{power:{pwr_sav_en:0,pwr_reboot_int:255}}");ASSERT_TRUE(p.loadSerial(input)); + EXPECT_EQ(0,p.powersaving_enabled);EXPECT_EQ(255,p.reboot_interval); + CaptureStream output;ASSERT_TRUE(p.saveSerial(output)); + NodePrefs restored;ReplayStream saved(output.text().c_str());ASSERT_TRUE(restored.loadSerial(saved)); + EXPECT_EQ(0,restored.powersaving_enabled);EXPECT_EQ(255,restored.reboot_interval); +} +TEST(CompanionPowerPrefs, DefaultOnAndSavedOffRoundTrip) { + CompanionNodePrefs prefs; EXPECT_EQ(1,prefs.powersaving_enabled); + ReplayStream input("{power:{pwr_sav_en:0}}"); ASSERT_TRUE(prefs.loadSerial(input)); + EXPECT_EQ(0,prefs.powersaving_enabled); + CaptureStream output; ASSERT_TRUE(prefs.saveSerial(output)); + CompanionNodePrefs restored; ReplayStream saved(output.text().c_str()); + ASSERT_TRUE(restored.loadSerial(saved)); EXPECT_EQ(0,restored.powersaving_enabled); +} +int main(int argc,char** argv){testing::InitGoogleTest(&argc,argv);return RUN_ALL_TESTS();} + +TYPED_TEST(RadioPrefsTest, FEMDefaultsPersistAndDoNotChangeInternalGainOrTX) { + TypeParam prefs;char reply[160]; + prefs.rx_boosted_gain=1;prefs.radio_fem_txgain=1; + board=ConfigTestBoard{};board.attachDynamicPrefs(prefs.getCustom()); + EXPECT_FALSE(board.lna); + auto* radio=prefs.getRadioPrefs(); + ASSERT_TRUE(radio->handleCommand("set radio.fem.rxgain on",0,reply)); + EXPECT_STREQ("OK",reply);EXPECT_TRUE(board.lna);EXPECT_TRUE(prefs.isDirty()); + CaptureStream saved;prefs.saveSerial(saved); + TypeParam restored;ReplayStream input(saved.text().c_str());restored.loadSerial(input); + board.attachDynamicPrefs(restored.getCustom());EXPECT_TRUE(board.lna); + auto* next=restored.getRadioPrefs(); + next->handleCommand("get radio.fem.rxgain",0,reply);EXPECT_STREQ("> on",reply); + next->handleCommand("set radio.fem.rxgain onion",0,reply);EXPECT_NE(strstr(reply,"Error"),nullptr);EXPECT_TRUE(board.lna); + next->handleCommand("set radio.fem.rxgain off",0,reply);EXPECT_FALSE(board.lna); + EXPECT_EQ(1,restored.rx_boosted_gain);EXPECT_EQ(1,restored.radio_fem_txgain); + CaptureStream saved_off;ASSERT_TRUE(restored.saveSerial(saved_off)); + TypeParam off_restored;ReplayStream off_input(saved_off.text().c_str()); + ASSERT_TRUE(off_restored.loadSerial(off_input)); + board.attachDynamicPrefs(off_restored.getCustom());EXPECT_FALSE(board.lna); + EXPECT_EQ(1,off_restored.rx_boosted_gain);EXPECT_EQ(1,off_restored.radio_fem_txgain); + next=off_restored.getRadioPrefs(); + board.supported=false; + next->handleCommand("set radio.fem.rxgain on",0,reply);EXPECT_STREQ("Error: unsupported",reply); + next->handleCommand("get radio.fem.rxgain",0,reply);EXPECT_STREQ("Error: unsupported",reply); + EXPECT_FALSE(board.lna); +} diff --git a/test/host/config_target/Arduino.h b/test/host/config_target/Arduino.h new file mode 100644 index 00000000000..15295ee6371 --- /dev/null +++ b/test/host/config_target/Arduino.h @@ -0,0 +1,7 @@ +#pragma once +#include_next +#include +#include +#include +#define constrain(x, lo, hi) std::min(std::max((x), (lo)), (hi)) +inline char* ltoa(long value, char* out, int) { std::sprintf(out,"%ld",value);return out; } diff --git a/test/host/config_target/CayenneLPP.h b/test/host/config_target/CayenneLPP.h new file mode 100644 index 00000000000..7c168a5ec75 --- /dev/null +++ b/test/host/config_target/CayenneLPP.h @@ -0,0 +1,2 @@ +#pragma once +class CayenneLPP; diff --git a/test/host/config_target/target.h b/test/host/config_target/target.h new file mode 100644 index 00000000000..b026aba548c --- /dev/null +++ b/test/host/config_target/target.h @@ -0,0 +1,39 @@ +#pragma once +#include +struct ConfigTestRadio : RxPowerSavingControl { + bool supported=true, enabled=false, invalid=false; + uint32_t rx=0, sleep=0; + bool setRxPowerSaving(bool on,uint32_t r,uint32_t s) override { + if(on && !supported) return false; + enabled=on;rx=r;sleep=s;return true; + } + void setRxPowerSavingConfigError(bool error) override {invalid=error;} + RxPowerSavingStatus getRxPowerSavingStatus() const override { + RxPowerSavingStatus s;s.supported=supported;s.armed=enabled; + s.last_error=invalid ? RX_POWERSAVING_ERR_TIMING : 0;return s; + } + bool setRxBoostedGainMode(bool) {return true;} + void setTxPower(int8_t) {} + uint32_t getCADBusyCount() const {return 0;} +}; +#define WRAPPER_CLASS ConfigTestRadio +inline ConfigTestRadio radio_driver; + +#include +#include +struct ConfigTestBoard { + bool supported = true, lna = false; + KeyValueStore* prefs = nullptr; + void attachDynamicPrefs(KeyValueStore* p) { + prefs=p;char value[8]={};p->getByKey("fem_rxgain",value,sizeof(value));lna=strcmp(value,"1")==0; + } + bool handleCommand(const char* cmd,uint32_t,char* reply) { + if (!supported) return false; + if (strcmp(cmd,"get radio.fem.rxgain")==0) {strcpy(reply,lna ? "> on" : "> off");return true;} + if (strncmp(cmd,"set radio.fem.rxgain ",21)==0) { + lna=strcmp(cmd+21,"on")==0;prefs->setByKey("fem_rxgain",lna ? "1" : "0");strcpy(reply,"OK");return true; + } + return false; + } +}; +inline ConfigTestBoard board; diff --git a/test/host/driver_spi.cpp b/test/host/driver_spi.cpp new file mode 100644 index 00000000000..34e7358d149 --- /dev/null +++ b/test/host/driver_spi.cpp @@ -0,0 +1,115 @@ +#include +#include +#include +#include "CustomSX1262.h" +#include "CustomLR1110.h" +class TraceHal : public RadioLibHal { +public: + bool lr=false; uint16_t pending=0; uint8_t error=0; unsigned long now=0; + std::vector> traces; + uint32_t pins[32]={}; + TraceHal(bool islr): RadioLibHal(0,1,0,1,1,2),lr(islr) {} + void pinMode(uint32_t,uint32_t) override {} + void digitalWrite(uint32_t p,uint32_t v) override {if(p<32)pins[p]=v;} + uint32_t digitalRead(uint32_t) override {return 0;} + void attachInterrupt(uint32_t,void(*)(void),uint32_t) override {} + void detachInterrupt(uint32_t) override {} + void delay(RadioLibTime_t t) override {now+=t*1000;} + void delayMicroseconds(RadioLibTime_t t) override {now+=t;} + RadioLibTime_t millis() override {return now++/1000;} + RadioLibTime_t micros() override {return now++;} + long pulseIn(uint32_t,uint32_t,RadioLibTime_t) override {return 0;} + void spiBegin() override {} + void spiBeginTransaction() override {} + void spiEndTransaction() override {} + void spiEnd() override {} + void spiTransfer(uint8_t* out,size_t len,uint8_t* in) override { + traces.emplace_back(out,out+len); memset(in,0,len); + if(lr){ + if(len)in[0]=error?error:0x04; + if(len>=2 && out[0]){pending=(uint16_t(out[0])<<8)|out[1];} + else if(pending && len>=2){ + if(pending==RADIOLIB_LR11X0_CMD_GET_PACKET_TYPE)in[1]=RADIOLIB_LR11X0_PACKET_TYPE_LORA; + pending=0; + } + }else{ + if(len>1)in[1]=error?error:0x24; + if(len>2 && out[0]==RADIOLIB_SX126X_CMD_GET_PACKET_TYPE)in[2]=RADIOLIB_SX126X_PACKET_TYPE_LORA; + } + } + void output(const char* name,int result,uint32_t eff=0){ + printf("%s result=%d eff=%u",name,result,eff); + for(auto& t:traces){printf(" |");for(auto b:t)printf("%02x",b);} + printf("\n"); traces.clear(); + } +}; +struct LRProbe : CustomLR1110 { + using CustomLR1110::CustomLR1110; + using LRxxxx::tcxoDelay; + using LRxxxx::spreadingFactor; + using LRxxxx::bandwidthKhz; + using LRxxxx::preambleLengthLoRa; + using LRxxxx::headerType; + using LRxxxx::implicitLen; + using LRxxxx::crcTypeLoRa; + using LRxxxx::invertIQEnabled; +}; +int main(){ + TraceHal hs(false),hl(true); Module ms(&hs,1,2,3,4),ml(&hl,1,2,3,4); + CustomSX1262 sx(&ms); LRProbe lr(&ml); + ms.spiConfig.stream=true;ms.spiConfig.statusPos=1; + ms.spiConfig.widths[RADIOLIB_MODULE_SPI_WIDTH_ADDR]=Module::BITS_16; + ms.spiConfig.widths[RADIOLIB_MODULE_SPI_WIDTH_CMD]=Module::BITS_8; + ms.spiConfig.parseStatusCb=SX126x::SPIparseStatus; + ms.spiConfig.cmds[RADIOLIB_MODULE_SPI_COMMAND_READ]=RADIOLIB_SX126X_CMD_READ_REGISTER; + ms.spiConfig.cmds[RADIOLIB_MODULE_SPI_COMMAND_WRITE]=RADIOLIB_SX126X_CMD_WRITE_REGISTER; + ms.spiConfig.cmds[RADIOLIB_MODULE_SPI_COMMAND_NOP]=0; + ms.spiConfig.cmds[RADIOLIB_MODULE_SPI_COMMAND_STATUS]=RADIOLIB_SX126X_CMD_GET_STATUS; + ml.spiConfig.widths[RADIOLIB_MODULE_SPI_WIDTH_ADDR]=Module::BITS_32; + sx.tcxoDelay=lr.tcxoDelay=1600; + sx.spreadingFactor=lr.spreadingFactor=8;sx.bandwidthKhz=lr.bandwidthKhz=62.5; + sx.preambleLengthLoRa=lr.preambleLengthLoRa=16; + sx.headerType=lr.headerType=0;sx.implicitLen=lr.implicitLen=255; + sx.crcTypeLoRa=lr.crcTypeLoRa=1;sx.invertIQEnabled=lr.invertIQEnabled=false; + sx.setRfSwitchPins(10,11); + for(auto periods:std::vector>{{1000,1000},{1000,2600},{1000,2850},{30000,20000},{200000,200000},{30000000,30000000}}){ + char name[90];snprintf(name,sizeof(name),"SX rx=%u sleep=%u",periods.first,periods.second); + int r=sx.startReceiveDutyCycle(periods.first,periods.second);hs.output(name,r); + if(periods.second>2600)assert(r==0 && hs.pins[10]==1 && hs.pins[11]==0); + else assert(r==RADIOLIB_ERR_INVALID_SLEEP_PERIOD); + uint32_t eff=0;snprintf(name,sizeof(name),"LR rx=%u sleep=%u",periods.first,periods.second); + r=lr.startReceiveDutyCycle(periods.first,periods.second,RADIOLIB_IRQ_RX_DEFAULT_FLAGS,RADIOLIB_IRQ_RX_DEFAULT_MASK,&eff); + if(periods.first==200000 && r==0) { + bool found=false; + for(auto& cmd:hl.traces) if(cmd.size()>=8 && cmd[0]==0x02 && cmd[1]==0x14) { + unsigned ticks=(unsigned(cmd[2])<<16)|(unsigned(cmd[3])<<8)|cmd[4]; + assert(ticks==6553);found=true; + } + assert(found); + } + hl.output(name,r,eff); + if(periods.second>2600)assert(r==0); + else assert(r==RADIOLIB_ERR_INVALID_SLEEP_PERIOD); + } + int st=sx.getStatus(); assert(st==0x24); hs.output("SX getStatus",st); + hs.error=0x28; st=sx.startReceiveDutyCycle(30000,20000); + assert(st==RADIOLIB_ERR_SPI_CMD_INVALID);hs.output("SX injected command error",st);hs.error=0; + hl.error=0x02; st=lr.startReceiveDutyCycle(30000,20000); + assert(st==RADIOLIB_ERR_SPI_CMD_INVALID);hl.output("LR injected command error",st);hl.error=0; + st=sx.stopRTC();assert(st==0);hs.output("SX stopRTC",st); + st=lr.restoreLfClock();assert(st==0); + assert(!hl.traces.empty() && hl.traces.front()==std::vector({0x01,0x16,0x01})); + hl.output("LR restoreLF",st); + unsigned valid=0,invalid=0; + for(float bw:{7.8f,10.4f,15.6f,20.8f,31.25f,41.7f,62.5f,125.0f,250.0f,500.0f}) + for(uint8_t sf=5;sf<=12;sf++)for(uint8_t p:{16,32})for(uint8_t level=1;level<=10;level++) + for(float cost:{6.f,8.f})for(uint32_t transition:{1000u,2600u,6000u}){ + uint32_t rx=0,sl=0;bool ok=recalcRxPowerSavingFromLevel(level,sf,bw,p,&rx,&sl,cost,transition); + if(ok) { + assert(isValidRxPowerSavingPeriod(rx) && isValidRxPowerSavingPeriod(sl)); + assert(sl>transition); + valid++; + } else invalid++; + } + printf("PROFILE MATRIX valid=%u rejected=%u\n",valid,invalid); +} diff --git a/test/host/platform_stub.h b/test/host/platform_stub.h new file mode 100644 index 00000000000..c5b94d286cf --- /dev/null +++ b/test/host/platform_stub.h @@ -0,0 +1,20 @@ +#pragma once +#include +#include +#include +#include +using std::min; using std::max; +inline uint32_t radio_test_millis = 100; +inline unsigned long millis() { return radio_test_millis; } +inline void delay(unsigned long ms) { radio_test_millis += ms; } +class SPIClass { public: void begin() {} }; +struct SerialStub { template void print(T) {} template void println(T) {} }; +inline SerialStub Serial; +#define LORA_FREQ 869.618f +#define LORA_BW 62.5f +#define LORA_SF 8 +#define LORA_TX_POWER 14 +inline long random(long a,long) { return a; } +inline void noInterrupts() {} +inline void interrupts() {} +#define LR2021_IRQ_DIO 9 diff --git a/test/host/wrapper_lifecycle.cpp b/test/host/wrapper_lifecycle.cpp new file mode 100644 index 00000000000..1db28f033af --- /dev/null +++ b/test/host/wrapper_lifecycle.cpp @@ -0,0 +1,106 @@ +#include +#include +#include "helpers/radiolib/RadioLibWrappers.h" +struct TestBoard : mesh::MainBoard { + uint16_t getBattMilliVolts() override {return 3700;} + const char* getManufacturerName() const override {return "test";} + void reboot() override {} + uint8_t getStartupReason() const override {return BD_STARTUP_NORMAL;} +}; +struct TestPhysical : PhysicalLayer { + void (*irq)() = nullptr; + int continuous=0, standbys=0, resets=0, power_calls=0; + int16_t continuous_result=0; + int16_t setOutputPower(int8_t) override {++power_calls;return 0;} + int16_t scan_result=RADIOLIB_CHANNEL_FREE; + Module* getMod() override {return nullptr;} + void setPacketReceivedAction(void (*func)()) override {irq=func;} + int16_t setPreambleLength(size_t) override {return 0;} + int16_t startReceive() override {++continuous;return continuous_result;} + int16_t standby() override {++standbys;return 0;} + int16_t sleep() override {++resets;return 0;} + int16_t startTransmit(const uint8_t*,size_t,uint8_t) override {return 0;} + int16_t finishTransmit() override {return 0;} + int16_t scanChannel() override {if(irq)irq();return scan_result;} + size_t getPacketLength(bool) override {return 3;} + int16_t readData(uint8_t* data,size_t len) override {memset(data,0x42,len);return 0;} + float getRSSI() override {return -110;} + float getSNR() override {return 4;} +}; +struct TestWrapper : RadioLibWrapper { + bool receiving=false; + bool inject_frame=false; + int arms=0, stops=0; + int16_t arm_result=0; + TestWrapper(TestPhysical& p,TestBoard& b):RadioLibWrapper(p,b) {} + bool supportsRxPowerSaving() const override {return true;} + bool isReceivingPacket() override {return receiving;} + bool isPacketReady() override {return true;} + float getCurrentRSSI() override {if(inject_frame){receiving=true;inject_frame=false;}return -110;} + void setParams(float,float,uint8_t,uint8_t) override {prepareForRadioConfig();} + int16_t armDutyCycle(RadioLibIrqFlags_t,RadioLibIrqFlags_t,uint32_t* rx,uint32_t* sl) override { + ++arms;*rx=_rx_ps_rx_us;*sl=_rx_ps_sleep_us;return arm_result; + } + int16_t stopDutyCycleHardware() override {++stops;return 0;} + void restart() {requestRestartRecv();} + void pump() {uint8_t b[256];recvRaw(b,sizeof(b));} +}; +int main() { +#ifdef USE_LR2021 + TestBoard board;TestPhysical phy;TestWrapper w(phy,board);w.begin();w.pump(); + int before=phy.continuous;int standby_before=phy.standbys; + w.setTxPower(14);assert(phy.power_calls==1);assert(phy.standbys==standby_before+1); + w.pump();assert(phy.continuous>before); + puts("PASS LR2021 setTxPower enters standby and re-arms continuous RX"); + phy.continuous_result=-707;w.restart();w.pump();assert(w.getRxPowerSavingStatus().last_error==-707); + phy.continuous_result=0;w.restart();w.pump();assert(w.getRxPowerSavingStatus().last_error==0); + puts("PASS LR2021 continuous RX error and recovery"); + return 0; +#else + TestBoard board;TestPhysical phy;TestWrapper w(phy,board);w.begin(); + assert(w.setRxPowerSaving(true,30000,20000));w.pump();assert(w.getRxPowerSavingStatus().armed); + uint8_t pkt[3]={};assert(w.startSendRaw(pkt,3));assert(!w.getRxPowerSavingStatus().armed); + phy.irq();assert(w.isSendComplete());w.onSendFinished();w.pump();assert(w.getRxPowerSavingStatus().armed); + puts("PASS TX -> RXPS"); + w.setCADEnabled(true);phy.scan_result=RADIOLIB_LORA_DETECTED;assert(w.isChannelActive());assert(w.getRxPowerSavingStatus().armed); + assert(w.getCADBusyCount()==1); + phy.scan_result=RADIOLIB_CHANNEL_FREE;assert(!w.isChannelActive());assert(w.getCADBusyCount()==1); + phy.scan_result=-706;assert(w.isChannelActive());assert(w.getCADBusyCount()==1); + puts("PASS CAD -> RXPS and busy counter excludes errors/free channel"); + w.arm_result=-708;w.restart();int before=phy.continuous;w.pump(); + assert(!w.getRxPowerSavingStatus().armed);assert(phy.continuous>before);assert(w.getRxPowerSavingStatus().last_error==-708); + puts("PASS arm failure -> continuous RX with error"); + assert(w.setRxPowerSaving(false,0,0));w.pump();assert(!w.getRxPowerSavingStatus().armed); + w.setRxPowerSavingConfigError(true);assert(w.getRxPowerSavingStatus().last_error==RX_POWERSAVING_ERR_TIMING); + w.setRxPowerSavingConfigError(false);w.arm_result=0; + assert(w.setRxPowerSaving(true,30000,20000));w.pump();assert(w.getRxPowerSavingStatus().armed); + puts("PASS off/on and configuration error separation"); + w.arm_result=-708;assert(w.setRxPowerSaving(true,30000,20000));int arms_before=w.arms; + for(int i=0;i<8;++i){w.restart();w.pump();} + assert(w.arms-arms_before==3);assert(!w.getRxPowerSavingStatus().armed); + puts("PASS retry limit: three failures then continuous RX"); + w.arm_result=0;assert(w.setRxPowerSaving(true,30000,20000));w.pump(); + assert(w.getRxPowerSavingStatus().armed); + w.receiving=true;int stops_before=w.stops; + assert(w.setRxPowerSaving(false,30000,20000));w.loop();w.pump(); + assert(w.stops==stops_before);assert(w.getRxPowerSavingStatus().armed); + w.receiving=false;phy.irq();w.pump();assert(!w.getRxPowerSavingStatus().armed); + puts("PASS mode change preserves in-flight RX"); + assert(w.setRxPowerSaving(true,30000,20000));w.loop();w.pump(); + w.inject_frame=true;w.resetAGC(); + assert(w.receiving);assert(!w.getRxPowerSavingStatus().armed); + int continuous_after=phy.continuous;w.pump();assert(phy.continuous==continuous_after); + w.receiving=false;phy.irq();w.pump();assert(w.getRxPowerSavingStatus().armed); + puts("PASS frame starting during AGC calibration is retained"); + w.resetAGC();assert(w.getNoiseFloor()==-110);w.pump();assert(w.getRxPowerSavingStatus().armed); + puts("PASS successful AGC calibration restores RXPS and publishes floor"); + assert(w.setRxPowerSaving(false,30000,20000));w.loop();w.pump(); + int standby_before=phy.standbys; + w.setTxPower(14);assert(phy.power_calls==1);assert(phy.standbys==standby_before); + puts("PASS setTxPower leaves continuous RX alone on SX126x/LR11x0"); + phy.continuous_result=-707;w.restart();w.pump(); + assert(w.getRxPowerSavingStatus().last_error==-707); + phy.continuous_result=0;w.restart();w.pump();assert(w.getRxPowerSavingStatus().last_error==0); + puts("PASS continuous RX failure and recovery are reported"); +#endif +} diff --git a/test/test_idle_sleep/test_idle_sleep.cpp b/test/test_idle_sleep/test_idle_sleep.cpp new file mode 100644 index 00000000000..e5968447c60 --- /dev/null +++ b/test/test_idle_sleep/test_idle_sleep.cpp @@ -0,0 +1,22 @@ +#include +#include +TEST(IdleSleep, ShortensToEarliestDeadline) { + IdleSleepBudget budget(1000); + budget.until(5000);budget.until(1006);budget.until(1009); + EXPECT_EQ(6U,budget.millis()); +} +TEST(IdleSleep, ReadyWorkPreventsSleepingAndDisabledTimerDoesNot) { + IdleSleepBudget budget(1000);budget.until(0);EXPECT_EQ((uint32_t)IDLE_SLEEP_MAX_MILLIS,budget.millis()); + budget.until(1000);EXPECT_EQ(0U,budget.millis()); + IdleSleepBudget overdue(1000);overdue.until(999);EXPECT_EQ(0U,overdue.millis()); +} +TEST(IdleSleep, TimerWrapKeepsFutureAndPastDeadlinesSeparate) { + IdleSleepBudget before(0xfffffffeU);before.until(3);EXPECT_EQ(5U,before.millis()); + IdleSleepBudget after(2);after.until(0xfffffffeU);EXPECT_EQ(0U,after.millis()); +} +TEST(IdleSleep, FarDeadlineIsCappedAtTheRoleMaximum) { + IdleSleepBudget budget(1000);budget.until(1000+10*IDLE_SLEEP_MAX_MILLIS); + EXPECT_EQ((uint32_t)IDLE_SLEEP_MAX_MILLIS,budget.millis()); + IdleSleepBudget sixty(5000,60000);sixty.until(5000+45000);EXPECT_EQ(45000U,sixty.millis()); +} +int main(int argc,char**argv) {testing::InitGoogleTest(&argc,argv);return RUN_ALL_TESTS();} diff --git a/test/test_outpath/test_outpath.cpp b/test/test_outpath/test_outpath.cpp new file mode 100644 index 00000000000..c4879a5886c --- /dev/null +++ b/test/test_outpath/test_outpath.cpp @@ -0,0 +1,77 @@ +#include +#include +#include +#include +#include +#include +#include +// Only storage and key derivation are faked. ClientACL's real record format runs below. +class File { + std::shared_ptr> data;size_t offset=0; +public: + File(std::shared_ptr> d={}):data(d){} + explicit operator bool()const{return bool(data);} + size_t read(uint8_t* out,size_t n) {n=std::min(n,data->size()-offset);memcpy(out,data->data()+offset,n);offset+=n;return n;} + size_t write(const uint8_t* in,size_t n) {data->insert(data->end(),in,in+n);return n;} + void close(){} +}; +struct FakeFS { + std::map>> files; + void mkdir(const char*){} + bool exists(const char* key){return files.count(key)>0;} + void remove(const char* key){files.erase(key);} + File open(const char* key,const char* mode="r",bool=false) { + if (*mode=='w')files[key]=std::make_shared>(); + return File(files[key]); + } +}; +#define FILESYSTEM FakeFS +#include "../../src/helpers/ClientACL.cpp" +namespace mesh { +Identity::Identity(){memset(pub_key,0,sizeof(pub_key));} +LocalIdentity::LocalIdentity():Identity(){} +void LocalIdentity::calcSharedSecret(uint8_t* out,const uint8_t*)const{memset(out,0,PUB_KEY_SIZE);} +} +static bool parse(const char* text,uint8_t* path,uint8_t& len) { + std::vector input(text,text+strlen(text)+1);const char* err=nullptr; + return parsePathCommand(input.data(),path,len,err); +} +TEST(OutPath, ManualHashWidthsAndModes) { + uint8_t path[MAX_PATH_SIZE]={},len=0;char reply[160]; + for (const auto& spec: {std::string("aa,bb"),std::string("aabb,ccdd"),std::string("aabbcc,ddeeff")}) { + ASSERT_TRUE(parse(spec.c_str(),path,len));EXPECT_TRUE(mesh::Packet::isValidPathLen(len));EXPECT_EQ(2,len&63); + } + ASSERT_TRUE(parse(" flood ",path,len));EXPECT_EQ(OUT_PATH_FORCE_FLOOD,len); + formatPathReply(path,len,reply,sizeof(reply));EXPECT_STREQ("> flood",reply); + ASSERT_TRUE(parse("clear",path,len));EXPECT_EQ(OUT_PATH_UNKNOWN,len); + ASSERT_TRUE(parse("direct",path,len));EXPECT_EQ(0,len); +} +TEST(OutPath, InvalidPathDoesNotModifyExistingRoute) { + uint8_t path[MAX_PATH_SIZE],len=7;memset(path,0x55,sizeof(path)); + for (const char* spec:{"", "aa,", ",aa", "aa,,bb", "aa,bbbb", "zz", "a", "aabbccdd"}) { + EXPECT_FALSE(parse(spec,path,len))<out_path,client->out_path_len)); + } + acl.save(&fs); + ClientACL restored;restored.load(&fs,self);ASSERT_EQ(5,restored.getNumClients()); + for(int i=0;i<5;++i) { + EXPECT_EQ(acl.getClientByIdx(i)->out_path_len,restored.getClientByIdx(i)->out_path_len); + EXPECT_EQ(0,memcmp(acl.getClientByIdx(i)->out_path,restored.getClientByIdx(i)->out_path,MAX_PATH_SIZE)); + } +} +int main(int argc,char**argv) {testing::InitGoogleTest(&argc,argv);return RUN_ALL_TESTS();} diff --git a/test/test_periodic_reboot/test_periodic_reboot.cpp b/test/test_periodic_reboot/test_periodic_reboot.cpp new file mode 100644 index 00000000000..7999611c957 --- /dev/null +++ b/test/test_periodic_reboot/test_periodic_reboot.cpp @@ -0,0 +1,26 @@ +#include +#include "helpers/PeriodicReboot.h" +TEST(PeriodicReboot, DeadlineDefersDuringTransmitAndOTA) { + PeriodicReboot timer; + EXPECT_FALSE(timer.due(1,3599999,false,false)); + EXPECT_FALSE(timer.due(1,3600000,true,false)); + EXPECT_FALSE(timer.due(1,3600001,false,true)); + EXPECT_TRUE(timer.due(1,3600002,false,false)); + EXPECT_FALSE(timer.due(0,3600003,false,false)); +} +TEST(PeriodicReboot, LongUptimeAndRolloverKeepTheDeadline) { + PeriodicReboot timer; + EXPECT_FALSE(timer.due(255,900000000,false,false)); + EXPECT_TRUE(timer.due(255,918000000,false,false)); + EXPECT_FALSE(timer.due(0,0xFFFFFFF0U,false,false)); + EXPECT_TRUE(timer.due(255,0x10U,false,false)); +} +TEST(PeriodicReboot, StrictRangeAndNoPartialMutation) { + uint8_t hours=7; + for(const char* bad : {"", "-1", "abc", "256", "9999999999999", "1x"}) { + EXPECT_FALSE(PeriodicReboot::parseHours(bad,hours)); EXPECT_EQ(7,hours); + } + EXPECT_TRUE(PeriodicReboot::parseHours("0",hours)); EXPECT_EQ(0,hours); + EXPECT_TRUE(PeriodicReboot::parseHours("255",hours)); EXPECT_EQ(255,hours); +} +int main(int argc,char**argv) { testing::InitGoogleTest(&argc,argv);return RUN_ALL_TESTS(); } diff --git a/test/test_retained_clock/test_retained_clock.cpp b/test/test_retained_clock/test_retained_clock.cpp new file mode 100644 index 00000000000..ce92c03d3e2 --- /dev/null +++ b/test/test_retained_clock/test_retained_clock.cpp @@ -0,0 +1,26 @@ +#include +#define NRF52_PLATFORM +inline void randomSeed(long) {} +inline long random(long low, long) {return low;} +#include +extern "C" { volatile uint32_t persistent_magic; volatile uint32_t persistent_time; } +TEST(RetainedClock, ValidTimeSurvivesSoftwareRestart) { + persistent_magic=0;g_mock_millis=0; + VolatileRTCClock first;first.setCurrentTime(RTC_TIME_MIN+123); + g_mock_millis=1500;first.tick();EXPECT_EQ(RTC_TIME_MIN+124,first.getCurrentTime()); + g_mock_millis=0;VolatileRTCClock restarted; + EXPECT_EQ(RTC_TIME_MIN+124,restarted.getCurrentTime()); +} +TEST(RetainedClock, InvalidMagicOrTimeUsesSeed) { + persistent_magic=0;persistent_time=RTC_TIME_MIN+123; + VolatileRTCClock bad_magic;EXPECT_EQ(RTC_TIME_MIN,bad_magic.getCurrentTime()); + persistent_magic=CLOCK_MAGIC_NUM;persistent_time=RTC_TIME_MIN-1; + VolatileRTCClock bad_time;EXPECT_EQ(RTC_TIME_MIN,bad_time.getCurrentTime()); +} +TEST(RetainedClock, MillisRolloverDoesNotJumpTime) { + persistent_magic=0;g_mock_millis=0xFFFFFF00U; + VolatileRTCClock clock; + g_mock_millis=744;clock.tick();EXPECT_EQ(RTC_TIME_MIN+1,clock.getCurrentTime()); + EXPECT_EQ(clock.getCurrentTime(),persistent_time); +} +int main(int argc,char**argv) {testing::InitGoogleTest(&argc,argv);return RUN_ALL_TESTS();} diff --git a/test/test_rx_power_saving/test_rx_power_saving.cpp b/test/test_rx_power_saving/test_rx_power_saving.cpp new file mode 100644 index 00000000000..c17c875c445 --- /dev/null +++ b/test/test_rx_power_saving/test_rx_power_saving.cpp @@ -0,0 +1,894 @@ +#include + +#include +#include + +#include "helpers/radiolib/RXPowerSaving.h" +#include "helpers/radiolib/RXPowerSavingCLI.h" + +class FakeRxPowerSavingControl : public RxPowerSavingControl { +public: + bool accept = true; + bool set_called = false; + bool requested_enabled = false; + uint32_t requested_rx_us = 0; + uint32_t requested_sleep_us = 0; + RxPowerSavingStatus status; + + bool setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) override { + set_called = true; + requested_enabled = enabled; + requested_rx_us = rx_us; + requested_sleep_us = sleep_us; + return accept; + } + + RxPowerSavingStatus getRxPowerSavingStatus() const override { return status; } +}; + +TEST(RxPowerSaving, DefaultsKeepRepeaterDisabledWithBalancedIntent) { + const RxPowerSavingConfig config; + + EXPECT_EQ(config.enabled, 0); + EXPECT_EQ(config.level, RX_POWERSAVING_BALANCED_LEVEL); + EXPECT_EQ(config.preamble, RX_POWERSAVING_PROFILE_PREAMBLE); + EXPECT_EQ(config.rx_us, RX_POWERSAVING_DEFAULT_RX_US); + EXPECT_EQ(config.sleep_us, RX_POWERSAVING_DEFAULT_SLEEP_US); +} + +TEST(RxPowerSaving, BaseControlRejectsEnableAndAcceptsContinuousRx) { + RxPowerSavingControl control; + + EXPECT_FALSE(control.setRxPowerSaving(true, 12345, 23456)); + EXPECT_TRUE(control.setRxPowerSaving(false, 12345, 23456)); + + const RxPowerSavingStatus status = control.getRxPowerSavingStatus(); + EXPECT_FALSE(status.supported); + EXPECT_FALSE(status.armed); + EXPECT_EQ(status.arm_failures, 0U); + EXPECT_EQ(status.effective_rx_us, 0U); + EXPECT_EQ(status.effective_sleep_us, 0U); +} + +TEST(RxPowerSaving, ArmRetryStopsAfterThreeFailuresAndSuccessResetsIt) { + RxPowerSavingArmRetryState retry; + + EXPECT_TRUE(retry.canAttempt()); + for (uint8_t expected = 1; expected <= RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES; expected++) { + retry.recordFailure(); + EXPECT_EQ(retry.consecutiveFailures(), expected); + } + EXPECT_FALSE(retry.canAttempt()); + + // Saturate instead of wrapping if a caller records another failure. + retry.recordFailure(); + EXPECT_EQ(retry.consecutiveFailures(), RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES); + + retry.recordSuccess(); + EXPECT_EQ(retry.consecutiveFailures(), 0); + EXPECT_TRUE(retry.canAttempt()); +} + +TEST(RxPowerSaving, ClearingRetryStateGrantsThreeFreshAttempts) { + RxPowerSavingArmRetryState retry; + for (uint8_t i = 0; i < RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES; i++) { + retry.recordFailure(); + } + ASSERT_FALSE(retry.canAttempt()); + + retry.reset(); + EXPECT_TRUE(retry.canAttempt()); + EXPECT_EQ(retry.consecutiveFailures(), 0); +} + +TEST(RxPowerSaving, RejectsInvalidProfileInputs) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + + EXPECT_FALSE(calcRxPowerSavingLevel(0, 10, 250.0f, 16, &rx_us, &sleep_us)); + // 8 is the top of the guarded scale, 9 overdrive, 10 the practical maximum. + EXPECT_TRUE(calcRxPowerSavingLevel(8, 10, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_TRUE(calcRxPowerSavingLevel(9, 10, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_TRUE(calcRxPowerSavingLevel(10, 10, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_FALSE(calcRxPowerSavingLevel(11, 10, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_FALSE(calcRxPowerSavingLevel(5, 4, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_FALSE(calcRxPowerSavingLevel(5, 10, 0.0f, 16, &rx_us, &sleep_us)); + EXPECT_FALSE(calcRxPowerSavingLevel(5, 10, 250.0f, 24, &rx_us, &sleep_us)); +} + +TEST(RxPowerSaving, AcceptsPeriodBoundariesOnly) { + EXPECT_FALSE(isValidRxPowerSavingPeriod(RX_POWERSAVING_MIN_PERIOD_US - 1)); + EXPECT_TRUE(isValidRxPowerSavingPeriod(RX_POWERSAVING_MIN_PERIOD_US)); + EXPECT_TRUE(isValidRxPowerSavingPeriod(RX_POWERSAVING_MAX_PERIOD_US)); + EXPECT_FALSE(isValidRxPowerSavingPeriod(RX_POWERSAVING_MAX_PERIOD_US + 1)); +} + +TEST(RxPowerSaving, RepairsInvalidPersistedPeriodsIndependently) { + uint32_t rx_us = 0; + uint32_t sleep_us = 12345; + + ensureRxPowerSavingDefaults(&rx_us, &sleep_us); + EXPECT_EQ(rx_us, RX_POWERSAVING_DEFAULT_RX_US); + EXPECT_EQ(sleep_us, 12345U); + + rx_us = 23456; + sleep_us = RX_POWERSAVING_MAX_PERIOD_US + 1; + ensureRxPowerSavingDefaults(&rx_us, &sleep_us); + EXPECT_EQ(rx_us, 23456U); + EXPECT_EQ(sleep_us, RX_POWERSAVING_DEFAULT_SLEEP_US); +} + +TEST(RxPowerSaving, NormalizesPersistedConfigBeforeApplying) { + RxPowerSavingConfig config; + config.enabled = 7; + config.level = 255; + config.preamble = 24; + config.rx_us = 0; + config.sleep_us = 0; + + normalizeRxPowerSavingConfig(&config, 10, 250.0f); + + EXPECT_EQ(config.enabled, 1); + EXPECT_EQ(config.level, RX_POWERSAVING_BALANCED_LEVEL); + EXPECT_EQ(config.preamble, RX_POWERSAVING_PROFILE_PREAMBLE); + EXPECT_EQ(config.rx_us, 49329U); + EXPECT_EQ(config.sleep_us, 23757U); +} + +TEST(RxPowerSaving, NumericInputIsStrictDecimal) { + EXPECT_TRUE(isRxPowerSavingNumeric("0")); + EXPECT_TRUE(isRxPowerSavingNumeric("123456")); + EXPECT_FALSE(isRxPowerSavingNumeric(nullptr)); + EXPECT_FALSE(isRxPowerSavingNumeric("")); + EXPECT_FALSE(isRxPowerSavingNumeric("-1")); + EXPECT_FALSE(isRxPowerSavingNumeric("1.5")); + EXPECT_FALSE(isRxPowerSavingNumeric("12x")); + EXPECT_FALSE(isRxPowerSavingNumeric(" 12")); +} + +TEST(RxPowerSaving, CompanionProfileIsBalancedPreambleSixteen) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_BALANCED_LEVEL, 10, 250.0f, 16, + &rx_us, &sleep_us)); + EXPECT_EQ(rx_us, 49329U); + EXPECT_EQ(sleep_us, 23757U); +} + +TEST(RxPowerSaving, NamedProfilesSitWhereTheScaleSaysTheyDo) { + EXPECT_EQ(RX_POWERSAVING_CONSERVATIVE_LEVEL, 3); + EXPECT_EQ(rxPowerSavingLevelCatch(RX_POWERSAVING_CONSERVATIVE_LEVEL, 16), 13.0f); + EXPECT_EQ(rxPowerSavingLevelCatch(RX_POWERSAVING_CONSERVATIVE_LEVEL, 32), 16.0f); + EXPECT_EQ(RX_POWERSAVING_BALANCED_LEVEL, 6); + EXPECT_EQ(rxPowerSavingLevelCatch(RX_POWERSAVING_BALANCED_LEVEL, 16), 10.0f); + EXPECT_EQ(rxPowerSavingLevelCatch(RX_POWERSAVING_BALANCED_LEVEL, 32), 10.0f); + // The floor is shared by both profiles, and is what makes a level mean the + // same geometry on an SX126x and an LR11x0. + EXPECT_EQ(rxPowerSavingLevelCatch(RX_POWERSAVING_MAX_LEVEL, 16), + RX_POWERSAVING_MIN_CATCH_SYMBOLS); + EXPECT_EQ(rxPowerSavingLevelCatch(RX_POWERSAVING_MAX_LEVEL, 32), + RX_POWERSAVING_MIN_CATCH_SYMBOLS); + EXPECT_EQ(RX_POWERSAVING_PROFILE_PREAMBLE, 16); + + // Margins fall monotonically and end at zero; nothing outside the guarded + // scale reports one. + for (uint8_t lv = 2; lv <= RX_POWERSAVING_GUARDED_LEVELS; lv++) { + EXPECT_LT(rxPowerSavingLevelCatch(lv, 16), rxPowerSavingLevelCatch(lv - 1, 16)); + EXPECT_LT(rxPowerSavingLevelCatch(lv, 32), rxPowerSavingLevelCatch(lv - 1, 32)); + } + EXPECT_EQ(rxPowerSavingLevelCatch(RX_POWERSAVING_OVERDRIVE_LEVEL, 16), 0.0f); + EXPECT_EQ(rxPowerSavingLevelCatch(0, 16), 0.0f); +} + +TEST(RxPowerSaving, HigherLevelTradesListenTimeForSleepTime) { + uint32_t conservative_rx_us = 0; + uint32_t conservative_sleep_us = 0; + uint32_t aggressive_rx_us = 0; + uint32_t aggressive_sleep_us = 0; + + ASSERT_TRUE(calcRxPowerSavingLevel(1, 10, 250.0f, 16, + &conservative_rx_us, &conservative_sleep_us)); + ASSERT_TRUE(calcRxPowerSavingLevel(10, 10, 250.0f, 16, + &aggressive_rx_us, &aggressive_sleep_us)); + EXPECT_GT(conservative_rx_us, aggressive_rx_us); + EXPECT_LT(conservative_sleep_us, aggressive_sleep_us); +} + +TEST(RxPowerSaving, AutoPreambleTracksSpreadingFactor) { + EXPECT_EQ(rxPowerSavingPreambleForSF(7), 32); + EXPECT_EQ(rxPowerSavingPreambleForSF(8), 32); + EXPECT_EQ(rxPowerSavingPreambleForSF(9), 16); + EXPECT_EQ(rxPowerSavingPreambleForSF(12), 16); +} + +TEST(RxPowerSaving, LevelIntentRetunesAfterRadioChange) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + + ASSERT_TRUE(recalcRxPowerSavingFromLevel(5, 8, 62.5f, 16, &rx_us, &sleep_us)); + uint32_t old_rx_us = rx_us; + uint32_t old_sleep_us = sleep_us; + + ASSERT_TRUE(recalcRxPowerSavingFromLevel(5, 9, 62.5f, 16, &rx_us, &sleep_us)); + // Sleep is pure symbols, so it doubles with the symbol time. The listen + // window no longer does: the SetRxDutyCycle timer guard adds half of the + // sleep->RX transition, which is a fixed number of microseconds and does not + // scale with SF. So the window grows, but by less than a factor of two. + EXPECT_NEAR((double)sleep_us, (double)old_sleep_us * 2.0, 32.0); + EXPECT_GT(rx_us, old_rx_us); + EXPECT_LT((double)rx_us, (double)old_rx_us * 2.0); +} + +TEST(RxPowerSaving, AutomaticPreambleRetunesAcrossSfBoundary) { + uint32_t automatic_rx_us = 0; + uint32_t automatic_sleep_us = 0; + uint32_t explicit_rx_us = 0; + uint32_t explicit_sleep_us = 0; + + ASSERT_TRUE(recalcRxPowerSavingFromLevel(5, 8, 250.0f, 0, + &automatic_rx_us, &automatic_sleep_us)); + ASSERT_TRUE(calcRxPowerSavingLevel(5, 8, 250.0f, 32, + &explicit_rx_us, &explicit_sleep_us)); + EXPECT_EQ(automatic_rx_us, explicit_rx_us); + EXPECT_EQ(automatic_sleep_us, explicit_sleep_us); + + ASSERT_TRUE(recalcRxPowerSavingFromLevel(5, 9, 250.0f, 0, + &automatic_rx_us, &automatic_sleep_us)); + ASSERT_TRUE(calcRxPowerSavingLevel(5, 9, 250.0f, 16, + &explicit_rx_us, &explicit_sleep_us)); + EXPECT_EQ(automatic_rx_us, explicit_rx_us); + EXPECT_EQ(automatic_sleep_us, explicit_sleep_us); +} + +TEST(RxPowerSaving, ManualTimingsAreNotRetuned) { + uint32_t rx_us = 12345; + uint32_t sleep_us = 23456; + + EXPECT_FALSE(recalcRxPowerSavingFromLevel(0, 10, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_EQ(rx_us, 12345U); + EXPECT_EQ(sleep_us, 23456U); +} + +TEST(RxPowerSavingCLI, AppliesNamedAndManualProfiles) { + RxPowerSavingConfig config; + FakeRxPowerSavingControl control; + char reply[192]; + + ASSERT_TRUE(RXPowerSavingCLI::set("conservative", 10, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_EQ(config.level, 3); + EXPECT_EQ(config.preamble, 16); + + ASSERT_TRUE(RXPowerSavingCLI::set("balanced", 10, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_TRUE(control.set_called); + EXPECT_TRUE(control.requested_enabled); + EXPECT_EQ(config.enabled, 1); + EXPECT_EQ(config.level, RX_POWERSAVING_BALANCED_LEVEL); + EXPECT_EQ(config.level, 6); + EXPECT_EQ(config.preamble, RX_POWERSAVING_PROFILE_PREAMBLE); + EXPECT_EQ(config.rx_us, 49329U); + EXPECT_EQ(config.sleep_us, 23757U); + + control.set_called = false; + ASSERT_TRUE(RXPowerSavingCLI::set("12345 23456", 10, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_TRUE(control.set_called); + EXPECT_EQ(config.level, 0); + EXPECT_EQ(config.preamble, 0); + EXPECT_EQ(config.rx_us, 12345U); + EXPECT_EQ(config.sleep_us, 23456U); +} + +TEST(RxPowerSavingCLI, StoresAutomaticPreambleIntentForLevel) { + RxPowerSavingConfig config; + FakeRxPowerSavingControl control; + char reply[192]; + + ASSERT_TRUE(RXPowerSavingCLI::set("level 5", 8, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_EQ(config.level, 5); + EXPECT_EQ(config.preamble, 0); + + uint32_t expected_rx_us = 0; + uint32_t expected_sleep_us = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(5, 8, 250.0f, 32, + &expected_rx_us, &expected_sleep_us)); + EXPECT_EQ(config.rx_us, expected_rx_us); + EXPECT_EQ(config.sleep_us, expected_sleep_us); +} + +TEST(RxPowerSavingCLI, DoesNotPersistRejectedOrInvalidChanges) { + RxPowerSavingConfig config; + RxPowerSavingConfig original = config; + FakeRxPowerSavingControl control; + char reply[192]; + + EXPECT_FALSE(RXPowerSavingCLI::set("level 0", 10, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_EQ(config.enabled, original.enabled); + EXPECT_EQ(config.level, original.level); + EXPECT_STREQ(reply, "ERROR: level range is 1-10 (or max|overdrive|riskyWorkingMax); preamble is 16 or 32"); + + control.accept = false; + EXPECT_FALSE(RXPowerSavingCLI::set("balanced", 10, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_EQ(config.enabled, original.enabled); + EXPECT_EQ(config.level, original.level); + EXPECT_STREQ(reply, "ERROR: RX powersaving unsupported"); +} + +TEST(RxPowerSavingCLI, RejectsValuesBeforeNarrowingOrOverflow) { + RxPowerSavingConfig config; + const RxPowerSavingConfig original = config; + FakeRxPowerSavingControl control; + char reply[192]; + + const char* invalid_values[] = { + "261", // used to alias to level 5 after uint8_t truncation + "level 266", // used to alias to level 10 + "level 5 preamble 272", // used to alias to preamble 16 + "4294967296 1000", // one above UINT32_MAX + "999999999999999999999999999", // must not wrap during parsing + }; + + for (const char* value : invalid_values) { + control.set_called = false; + EXPECT_FALSE(RXPowerSavingCLI::set(value, 10, 250.0f, &config, &control, + reply, sizeof(reply))) << value; + EXPECT_FALSE(control.set_called) << value; + EXPECT_EQ(config.enabled, original.enabled) << value; + EXPECT_EQ(config.level, original.level) << value; + EXPECT_EQ(config.preamble, original.preamble) << value; + EXPECT_EQ(config.rx_us, original.rx_us) << value; + EXPECT_EQ(config.sleep_us, original.sleep_us) << value; + } +} + +TEST(RxPowerSavingCLI, DisablingKeepsTimingIntent) { + RxPowerSavingConfig config; + config.enabled = 1; + config.level = 7; + config.preamble = 32; + config.rx_us = 34567; + config.sleep_us = 45678; + FakeRxPowerSavingControl control; + char reply[192]; + + ASSERT_TRUE(RXPowerSavingCLI::set("off", 10, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_FALSE(control.requested_enabled); + EXPECT_EQ(config.enabled, 0); + EXPECT_EQ(config.level, 7); + EXPECT_EQ(config.preamble, 32); + EXPECT_EQ(config.rx_us, 34567U); + EXPECT_EQ(config.sleep_us, 45678U); +} + +TEST(RxPowerSavingCLI, FormatsDesiredAndEffectiveStateSeparately) { + RxPowerSavingConfig config; + config.enabled = 1; + FakeRxPowerSavingControl control; + control.status = {true, false, -706, 2, 0, 0}; + char reply[192]; + + RXPowerSavingCLI::get(&config, &control, 10, 250.0f, reply, sizeof(reply)); + + EXPECT_NE(std::strstr(reply, "desired=on,effective=continuous,supported=yes"), nullptr); + EXPECT_NE(std::strstr(reply, "err=-706,fail=2"), nullptr); + EXPECT_EQ(std::strstr(reply, "erx="), nullptr); // nothing armed, nothing to report +} + +TEST(RxPowerSavingCLI, ReportsClampedPeriodsOnlyWhenTheyDiffer) { + RxPowerSavingConfig config; + config.enabled = 1; + FakeRxPowerSavingControl control; + char reply[192]; + + // driver armed exactly what was asked for -> no extra fields + control.status = {true, true, 0, 0, config.rx_us, config.sleep_us}; + RXPowerSavingCLI::get(&config, &control, 10, 250.0f, reply, sizeof(reply)); + EXPECT_NE(std::strstr(reply, "effective=armed"), nullptr); + EXPECT_EQ(std::strstr(reply, "erx="), nullptr); + + // driver had to stretch the RX window -> surface the real values + control.status = {true, true, 0, 0, config.rx_us + 4000, config.sleep_us}; + RXPowerSavingCLI::get(&config, &control, 10, 250.0f, reply, sizeof(reply)); + EXPECT_NE(std::strstr(reply, "erx=69625,eslp=60000"), nullptr); +} + +TEST(RxPowerSavingCLI, GetDoesNotMutateStoredConfig) { + RxPowerSavingConfig config; + config.rx_us = 0; // invalid, e.g. from an older persisted prefs file + config.sleep_us = 999; + FakeRxPowerSavingControl control; + char reply[192]; + + RXPowerSavingCLI::get(&config, &control, 10, 250.0f, reply, sizeof(reply)); + + EXPECT_EQ(config.rx_us, 0U); + EXPECT_EQ(config.sleep_us, 999U); +} + + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} + +// Measured on SX1262 at SF6/BW62.5: the P16 profile's own sleep is below the +// driver's arming floor on levels 1-5 (and on levels 1-2 at SF7), where +// startReceiveDutyCycle answers -708 and the wrapper falls back to continuous +// RX while the config still reports power saving as on. +TEST(RxPowerSaving, ShortSleepIsRaisedToTheArmingFloor) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + + // SF6/BW62.5: level 1 of the P16 profile asks for 2 symbols = 2048 us. + ASSERT_TRUE(calcRxPowerSavingLevel(1, 6, 62.5f, 16, &rx_us, &sleep_us)); + EXPECT_EQ(sleep_us, RX_POWERSAVING_MIN_SLEEP_US); + // Raising it must not break capture: the budget is preamble - cost symbols. + EXPECT_LE(sleep_us, (uint32_t)((16.0f - RX_POWERSAVING_CAPTURE_COST_SYMBOLS) * 1024.0f)); + + // Only level 2 still lands under the floor. Before MC_TCXO_DELAY_US dropped + // the transition from 6000 to 2600 us the floor was 6250 us and swallowed + // levels 2-6 as well, so most of the 16-symbol ladder collapsed onto one + // point at SF6 - the node quietly slept longer, and caught fewer symbols, + // than the level it was set to promised. + for (uint8_t lv = 2; lv <= 2; lv++) { + uint32_t rx_n = 0; + uint32_t sleep_n = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(lv, 6, 62.5f, 16, &rx_n, &sleep_n)) << (int)lv; + EXPECT_EQ(sleep_n, RX_POWERSAVING_MIN_SLEEP_US) << (int)lv; + EXPECT_EQ(rx_n, rx_us) << (int)lv; + } + + // Level 4 sleeps 3.8 symbols, which now fits above the floor, so it gets its + // own point and saves more power than the collapsed ones. + uint32_t rx5_us = 0; + uint32_t sleep5_us = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(4, 6, 62.5f, 16, &rx5_us, &sleep5_us)); + EXPECT_GT(sleep5_us, RX_POWERSAVING_MIN_SLEEP_US); + EXPECT_LT((double)rx5_us / (rx5_us + sleep5_us), + (double)rx_us / (rx_us + sleep_us)); +} + +TEST(RxPowerSaving, RejectsProfilesWhoseFloorWouldBreakCapture) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + + // SF6/BW250: the symbol is 256 us, so the whole P16 capture budget is + // 10 symbols = 2560 us - under the arming floor. No level can duty cycle + // here, and the caller has to hear that rather than get a silent fallback. + // + // This used to be SF7/BW250, which the lower transition has since brought + // within reach: its budget is 5120 us against a floor that fell from 6250 to + // 2850, so the whole P16 ladder became armable there. The refusal path still + // needs a setting that is genuinely out of reach, so the example moved down + // one SF rather than away. + EXPECT_FALSE(calcRxPowerSavingLevel(1, 6, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_FALSE(calcRxPowerSavingLevel(RX_POWERSAVING_MAX_LEVEL, 6, 250.0f, 16, + &rx_us, &sleep_us)); + + // The P32 profile has 26 symbols of budget at the same setting and survives. + EXPECT_TRUE(calcRxPowerSavingLevel(1, 6, 250.0f, 32, &rx_us, &sleep_us)); + EXPECT_GE(sleep_us, RX_POWERSAVING_MIN_SLEEP_US); + + // And the setting that moved: SF7/BW250 with P16 now arms on every level. + EXPECT_TRUE(calcRxPowerSavingLevel(1, 7, 250.0f, 16, &rx_us, &sleep_us)); + EXPECT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_MAX_LEVEL, 7, 250.0f, 16, + &rx_us, &sleep_us)); +} + +TEST(RxPowerSaving, TransitionTimeComesFromTheRadioLikeCaptureCost) { + class SlowTcxoControl : public RxPowerSavingControl { + public: + uint32_t rxPowerSavingTransitionUs() const override { return 11750; } + } slow; + + EXPECT_EQ(rxPowerSavingTransition(nullptr), RX_POWERSAVING_TRANSITION_US); + EXPECT_EQ(rxPowerSavingTransition(&slow), 11750u); + + // SF7 rather than SF6: a 12 ms floor does not fit inside the 10-symbol + // capture budget of a 1024 us symbol, which is the rejection case above. + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(1, 7, 62.5f, 16, &rx_us, &sleep_us, + RX_POWERSAVING_CAPTURE_COST_SYMBOLS, + rxPowerSavingTransition(&slow))); + // Floor is transition + margin, snapped to a whole 15.625 us tick. + EXPECT_GE(sleep_us, 12000u); + EXPECT_LT(sleep_us, 12020u); +} + +TEST(RxPowerSavingCLI, ReportsWhenNoLevelCanDutyCycleAtThisRadioSetting) { + RxPowerSavingConfig config; + FakeRxPowerSavingControl control; + char reply[160]; + + // SF6/BW250 with the P16 profile: nothing between the arming floor and the + // capture budget, so the level must be refused with a message that names the + // real reason instead of blaming the level range. + EXPECT_FALSE(RXPowerSavingCLI::set("level 5 preamble 16", 6, 250.0f, &config, &control, + reply, sizeof(reply))); + EXPECT_NE(strstr(reply, "SF/BW"), nullptr); + EXPECT_TRUE(control.set_called); + EXPECT_FALSE(control.requested_enabled); + EXPECT_EQ(config.enabled, 0); +} + +// SX1261/2 datasheet, SetRxDutyCycle: on preamble detection the radio restarts +// its timer with 2*rxPeriod + sleepPeriod and requires +// Tpreamble + Theader <= 2 * rxPeriod + sleepPeriod +// Measured on two SX1262 boards: when that holds nothing goes wrong, and when +// it is broken the chip usually gets away with it - except at isolated register +// values one tick wide (rxPeriod 320 and 640) where it loses 35-100% of the +// packets it has already latched. Satisfying the condition is the only defence +// that does not depend on knowing every bad tick. +TEST(RxPowerSaving, EveryLevelSatisfiesTheDutyCycleTimerCondition) { + const uint8_t sfs[] = {6, 7, 8, 10, 12}; + const float bws[] = {62.5f, 250.0f}; + const uint8_t preambles[] = {16, 32}; + + for (uint8_t sf : sfs) { + for (float bw : bws) { + for (uint8_t preamble : preambles) { + for (uint8_t level = 1; level <= RX_POWERSAVING_GUARDED_LEVELS; level++) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + if (!calcRxPowerSavingLevel(level, sf, bw, preamble, &rx_us, &sleep_us)) { + continue; // rejected outright; nothing is armed, nothing to check + } + const float symbol_us = (1000.0f * (float)(1UL << sf)) / bw; + const float sync = sf <= 6 ? RX_POWERSAVING_SYNC_SYMBOLS_LOW_SF + : RX_POWERSAVING_SYNC_SYMBOLS; + const float need = + ((float)preamble + sync + RX_POWERSAVING_HEADER_SYMBOLS) * symbol_us; + // Compare on the register values the radio really runs, not on the + // microseconds we asked for: the driver truncates to 15.625 us ticks. + const uint32_t rx_ticks = (rx_us * 8) / 125; + const uint32_t sleep_ticks = + ((sleep_us - RX_POWERSAVING_TRANSITION_US) * 8) / 125; + const float restarted = (2.0f * rx_ticks + sleep_ticks) * 15.625f; + EXPECT_GE(restarted, need) + << "SF" << (int)sf << " BW" << bw << " P" << (int)preamble + << " level " << (int)level; + // And the periods handed out must themselves be whole ticks, so the + // CLI reports what the hardware runs. + EXPECT_EQ((rx_ticks * 125 + 7) / 8, rx_us); + } + } + } + } +} + +// Level 11 is the measured maximum: the geometry the bench ran before the timer +// guard existed, kept because the guard costs 4 to 12 percentage points of duty +// cycle. 8 symbols of listening, sleep right at the capture budget. +TEST(RxPowerSaving, OverdriveIsTheMeasuredGeometryAtEverySf) { + const uint8_t sfs[] = {6, 7, 8, 10}; + for (uint8_t sf : sfs) { + for (uint8_t preamble : {16, 32}) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_OVERDRIVE_LEVEL, sf, 62.5f, + (uint8_t)preamble, &rx_us, &sleep_us)) + << "SF" << (int)sf << " P" << preamble; + const float symbol_us = (1000.0f * (float)(1UL << sf)) / 62.5f; + EXPECT_NEAR(rx_us / symbol_us, 8.0f /* rx_edge_symbols */, 0.02f); + EXPECT_NEAR(sleep_us / symbol_us, + preamble - RX_POWERSAVING_CAPTURE_COST_SYMBOLS, 0.02f); + // The whole point: 23.5% duty on P32, 44.4% on P16, at every SF. + const double duty = 100.0 * rx_us / (rx_us + sleep_us); + EXPECT_NEAR(duty, preamble == 32 ? 23.5 : 44.4, 0.2); + } + } +} + +TEST(RxPowerSaving, OverdriveDeliberatelyBreaksTheTimerCondition) { + // Stated as a test so nobody later "fixes" it into compliance by accident: + // level 11 exists precisely because it sits outside the datasheet rule, and + // it was measured lossless there on three boards across SF6, SF7 and SF8. + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_OVERDRIVE_LEVEL, 6, 62.5f, 32, + &rx_us, &sleep_us)); + const float symbol_us = (1000.0f * 64.0f) / 62.5f; + const float need = (32.0f + RX_POWERSAVING_SYNC_SYMBOLS_LOW_SF + + RX_POWERSAVING_HEADER_SYMBOLS) * symbol_us; + const uint32_t rx_ticks = (rx_us * 8) / 125; + const uint32_t sleep_ticks = ((sleep_us - RX_POWERSAVING_TRANSITION_US) * 8) / 125; + EXPECT_LT((2.0f * rx_ticks + sleep_ticks) * RX_POWERSAVING_TICK_US, need); + + // and the top of the guarded scale must still satisfy it + uint32_t g_rx = 0; + uint32_t g_sleep = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_MAX_LEVEL, 6, 62.5f, 32, &g_rx, &g_sleep)); + const uint32_t g_rx_ticks = (g_rx * 8) / 125; + const uint32_t g_sleep_ticks = ((g_sleep - RX_POWERSAVING_TRANSITION_US) * 8) / 125; + EXPECT_GE((2.0f * g_rx_ticks + g_sleep_ticks) * RX_POWERSAVING_TICK_US, need); + EXPECT_LT(rx_us, g_rx); // and it really does listen less +} + +TEST(RxPowerSaving, OverdriveStepsOffTheKnownBadRegisterTicks) { + // 8 symbols land on rxPeriod tick 320 when the symbol is 625 us. No standard + // bandwidth produces that, which is why production never hit it - but the + // unguarded path has no other protection, so the check has to work. + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_OVERDRIVE_LEVEL, 6, 102.4f, 32, + &rx_us, &sleep_us)); + EXPECT_EQ((rx_us * 8) / 125, 321u); // nudged one tick clear of 320 + EXPECT_EQ(rx_us, 5016u); + + // The guarded scale needs no such help and must be left alone. + uint32_t g_rx = 0; + uint32_t g_sleep = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(10, 6, 102.4f, 32, &g_rx, &g_sleep)); + EXPECT_NE((g_rx * 8) / 125, 320u); +} + +TEST(RxPowerSavingCLI, OverdriveAndMaxPresetsSelectTheRightLevels) { + RxPowerSavingConfig config; + FakeRxPowerSavingControl control; + char reply[160]; + + // `max` is the top of the guarded scale, and like every named preset it + // assumes a 16-symbol sender, so at SF<=8 it is deliberately less economical + // than `level 8`, which follows the SF onto the 32-symbol profile. + ASSERT_TRUE(RXPowerSavingCLI::set("max", 8, 62.5f, &config, &control, + reply, sizeof(reply))); + EXPECT_EQ(config.level, RX_POWERSAVING_MAX_LEVEL); + EXPECT_EQ(config.level, 8); + EXPECT_EQ(rxPowerSavingLevelCatch(config.level, 16), RX_POWERSAVING_MIN_CATCH_SYMBOLS); + EXPECT_EQ(config.preamble, 16); + EXPECT_EQ(strstr(reply, "overdrive"), nullptr); + const uint32_t max_rx = config.rx_us; + const uint32_t max_sleep = config.sleep_us; + + ASSERT_TRUE(RXPowerSavingCLI::set("level 8", 8, 62.5f, &config, &control, + reply, sizeof(reply))); + EXPECT_EQ(config.preamble, 0); + EXPECT_LT((double)config.rx_us / (config.rx_us + config.sleep_us), + (double)max_rx / (max_rx + max_sleep)); + + // `overdrive` is one step past it, on the same worst-case preamble. + ASSERT_TRUE(RXPowerSavingCLI::set("overdrive", 8, 62.5f, &config, &control, + reply, sizeof(reply))); + EXPECT_EQ(config.level, RX_POWERSAVING_OVERDRIVE_LEVEL); + EXPECT_EQ(config.preamble, 16); + EXPECT_NE(strstr(reply, "overdrive"), nullptr); + EXPECT_LT(config.rx_us, max_rx); // and really does listen less + + control.status.supported = true; + control.status.armed = true; + RXPowerSavingCLI::get(&config, &control, 10, 250.0f, reply, sizeof(reply)); + EXPECT_NE(strstr(reply, "(overdrive)"), nullptr); + + // Both names take an explicit preamble, which is how the 32-symbol profile + // is reached without giving up the preset. + ASSERT_TRUE(RXPowerSavingCLI::set("overdrive preamble 32", 8, 62.5f, &config, + &control, reply, sizeof(reply))); + EXPECT_EQ(config.level, RX_POWERSAVING_OVERDRIVE_LEVEL); + EXPECT_EQ(config.preamble, 32); + ASSERT_TRUE(RXPowerSavingCLI::set("max preamble 32", 8, 62.5f, &config, + &control, reply, sizeof(reply))); + EXPECT_EQ(config.level, RX_POWERSAVING_MAX_LEVEL); + EXPECT_EQ(config.preamble, 32); + + EXPECT_FALSE(RXPowerSavingCLI::set("overdrive preamble 24", 8, 62.5f, &config, + &control, reply, sizeof(reply))); + EXPECT_FALSE(RXPowerSavingCLI::set("max preamble", 8, 62.5f, &config, + &control, reply, sizeof(reply))); + EXPECT_FALSE(RXPowerSavingCLI::set("level 11", 8, 62.5f, &config, &control, + reply, sizeof(reply))); +} + +TEST(RxPowerSaving, RiskyWorkingMaxIsTheMeasuredEdgeAndCostsDelivery) { + // The bench walked the profile past level 10 until delivery came back: + // virtual 11.0 for P32, 10.25 for P16. Both put the sleep beyond the capture + // budget, which is the whole reason they lose packets - 196/200 and 197/200 + // at SF8 with an LR1110 witnessing every transmission. + const float symbol_us = (1000.0f * 256.0f) / 62.5f; // SF8 / BW62.5 + + uint32_t rx32 = 0; + uint32_t sleep32 = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL, 8, 62.5f, 32, + &rx32, &sleep32)); + EXPECT_NEAR(rx32 / symbol_us, 7.111f, 0.02f); + EXPECT_NEAR(sleep32 / symbol_us, 27.222f, 0.02f); + EXPECT_GT(sleep32 / symbol_us, 32.0f - RX_POWERSAVING_CAPTURE_COST_SYMBOLS); + + uint32_t rx16 = 0; + uint32_t sleep16 = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL, 8, 62.5f, 16, + &rx16, &sleep16)); + EXPECT_NEAR(rx16 / symbol_us, 7.889f, 0.02f); + EXPECT_NEAR(sleep16 / symbol_us, 10.222f, 0.02f); + EXPECT_GT(sleep16 / symbol_us, 16.0f - RX_POWERSAVING_CAPTURE_COST_SYMBOLS); + + // It must sleep more than overdrive, or it would have no reason to exist. + uint32_t o_rx = 0; + uint32_t o_sleep = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(RX_POWERSAVING_OVERDRIVE_LEVEL, 8, 62.5f, 32, + &o_rx, &o_sleep)); + EXPECT_GT((double)sleep32 / (rx32 + sleep32), (double)o_sleep / (o_rx + o_sleep)); + + // Both levels past the guarded scale skip the timer guard. + EXPECT_TRUE(isRxPowerSavingUnguardedLevel(RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL)); + EXPECT_TRUE(isRxPowerSavingUnguardedLevel(RX_POWERSAVING_OVERDRIVE_LEVEL)); + EXPECT_FALSE(isRxPowerSavingUnguardedLevel(RX_POWERSAVING_MAX_LEVEL)); +} + +TEST(RxPowerSavingCLI, RiskyWorkingMaxUsesOneNameEverywhere) { + RxPowerSavingConfig config; + FakeRxPowerSavingControl control; + char reply[160]; + + ASSERT_TRUE(RXPowerSavingCLI::set("riskyWorkingMax", 8, 62.5f, &config, + &control, reply, sizeof(reply))); + EXPECT_EQ(config.level, RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL); + EXPECT_EQ(config.preamble, 16); + EXPECT_NE(strstr(reply, "(riskyWorkingMax)"), nullptr); + + ASSERT_TRUE(RXPowerSavingCLI::set("riskyWorkingMax preamble 32", 8, 62.5f, + &config, &control, reply, sizeof(reply))); + EXPECT_EQ(config.preamble, 32); + + control.status.supported = true; + control.status.armed = true; + RXPowerSavingCLI::get(&config, &control, 10, 250.0f, reply, sizeof(reply)); + EXPECT_NE(strstr(reply, "(riskyWorkingMax)"), nullptr); + + // The name remains case-sensitive and has no shortcut. + EXPECT_FALSE(RXPowerSavingCLI::set("riskyworkingmax", 8, 62.5f, &config, &control, + reply, sizeof(reply))); +} + +// The whole point of the scale: level N means "a sender's preamble may be N +// symbols shorter than this profile assumes and still be caught". That has to +// hold on every SF, bandwidth and profile, because being dimensionless is the +// reason the scale is expressed this way rather than in microseconds or in +// milliamperes - both of which differ per board. +TEST(RxPowerSaving, EveryGuardedLevelDeliversTheMarginItPromises) { + const uint8_t sfs[] = {7, 8, 9, 10, 12}; + const float bws[] = {62.5f, 125.0f, 250.0f}; + const uint8_t preambles[] = {16, 32}; + + for (uint8_t sf : sfs) { + for (float bw : bws) { + for (uint8_t preamble : preambles) { + for (uint8_t level = 1; level <= RX_POWERSAVING_GUARDED_LEVELS; level++) { + uint32_t rx_us = 0; + uint32_t sleep_us = 0; + if (!calcRxPowerSavingLevel(level, sf, bw, preamble, &rx_us, &sleep_us)) { + continue; // rejected outright, nothing to check + } + if (sleep_us == RX_POWERSAVING_MIN_SLEEP_US) { + continue; // the floor ate the margin; reported by the CLI, not a bug + } + const float symbol_us = (1000.0f * (float)(1UL << sf)) / bw; + const float caught = (float)preamble - (float)sleep_us / symbol_us; + // The delivered catch is the ladder value plus the pad that keeps + // every generated geometry off the defective register pairs. + EXPECT_NEAR(caught, + rxPowerSavingLevelCatch(level, preamble) + RX_POWERSAVING_MARGIN_PAD_SYMBOLS, + 0.05f) + << "SF" << (int)sf << " BW" << bw << " P" << (int)preamble + << " level " << (int)level; + } + } + } + } +} + +TEST(RxPowerSaving, ALevelMeansTheSameGeometryOnBothRadioFamilies) { + // This is what the catch scale buys that the old margin scale could not: the + // capture cost is no longer part of the arithmetic, so a level produces the + // same periods whether the driver declares 6 symbols (SX126x) or 8 (LR11x0). + // Before, the same level number meant two different sleeps on the two chips. + for (uint8_t level = 1; level <= RX_POWERSAVING_GUARDED_LEVELS; level++) { + for (uint8_t preamble : {16, 32}) { + uint32_t rx_sx = 0, sleep_sx = 0, rx_lr = 0, sleep_lr = 0; + ASSERT_TRUE(calcRxPowerSavingLevel(level, 8, 62.5f, preamble, &rx_sx, &sleep_sx, + RX_POWERSAVING_CAPTURE_COST_SYMBOLS)); + ASSERT_TRUE(calcRxPowerSavingLevel(level, 8, 62.5f, preamble, &rx_lr, &sleep_lr, + RX_POWERSAVING_CAPTURE_COST_SYMBOLS_LR11X0)); + EXPECT_EQ(sleep_sx, sleep_lr) << "level " << (int)level << " P" << (int)preamble; + EXPECT_EQ(rx_sx, rx_lr) << "level " << (int)level << " P" << (int)preamble; + } + } +} + +TEST(RxPowerSaving, FailedNormalizationCannotAcceptStalePeriods) { + RxPowerSavingConfig config; + config.enabled = 1; + const auto before = config; + EXPECT_FALSE(normalizeRxPowerSavingConfig(&config, 6, 250)); + EXPECT_EQ(before.rx_us, config.rx_us); + EXPECT_EQ(before.sleep_us, config.sleep_us); + EXPECT_EQ(1, config.enabled); +} +TEST(RxPowerSaving, RejectsNonFiniteAndExtremeBandwidthBeforeIntegerConversion) { + RxPowerSavingConfig config; + for (float bw : {0.0f, -1.0f, 1.0e-30f, std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN()}) { + EXPECT_FALSE(normalizeRxPowerSavingConfig(&config, 8, bw)); + } + EXPECT_FALSE(normalizeRxPowerSavingConfig(&config, 4, 62.5f)); + EXPECT_FALSE(normalizeRxPowerSavingConfig(&config, 13, 62.5f)); +} +TEST(RxPowerSaving, NormalizePreservesManualTimingsAcrossRadioChanges) { + RxPowerSavingConfig config; + config.enabled = 1; config.level = 0; config.rx_us = 12345; config.sleep_us = 23456; + for (uint8_t sf : {6, 8, 12}) { + ASSERT_TRUE(normalizeRxPowerSavingConfig(&config, sf, 250)); + EXPECT_EQ(12345U, config.rx_us); EXPECT_EQ(23456U, config.sleep_us); + } + config.rx_us = 0; + EXPECT_FALSE(normalizeRxPowerSavingConfig(&config, 8, 250)); + EXPECT_EQ(0U, config.rx_us); +} +TEST(RxPowerSaving, InvalidTimingFallsBackThenRecoversWithoutLosingIntent) { + RxPowerSavingConfig config; + FakeRxPowerSavingControl control; + config.enabled = 1; + ASSERT_TRUE(applyRxPowerSavingConfig(&config, 8, 62.5, &control)); + EXPECT_TRUE(control.requested_enabled); + ASSERT_FALSE(applyRxPowerSavingConfig(&config, 6, 250, &control)); + EXPECT_FALSE(control.requested_enabled); + EXPECT_EQ(1, config.enabled); EXPECT_EQ(6, config.level); + ASSERT_TRUE(applyRxPowerSavingConfig(&config, 8, 62.5, &control)); + EXPECT_TRUE(control.requested_enabled); + config.enabled = 0; + ASSERT_TRUE(applyRxPowerSavingConfig(&config, 6, 250, &control)); + EXPECT_FALSE(control.requested_enabled); +} + +TEST(RxPowerSaving, NarrowBandwidthIsNotRejectedByAnOverconservativeOverflowGuard) { + RxPowerSavingConfig config; + config.enabled = 1; + for (uint8_t level = 1; level <= 10; ++level) { + config.level = level; + ASSERT_TRUE(normalizeRxPowerSavingConfig(&config, 12, 7.8f)); + EXPECT_TRUE(isValidRxPowerSavingPeriod(config.rx_us)); + EXPECT_TRUE(isValidRxPowerSavingPeriod(config.sleep_us)); + } +} + +TEST(RxPowerSaving, ManualCLIRejectsInvalidRadioAndOffRemainsAvailable) { + RxPowerSavingConfig config;config.enabled=1; + FakeRxPowerSavingControl control;char reply[160]; + for(float bw:{0.0f, -1.0f, std::numeric_limits::infinity(), std::numeric_limits::quiet_NaN()}) { + EXPECT_FALSE(RXPowerSavingCLI::set("12345 23456",8,bw,&config,&control,reply,sizeof(reply))); + EXPECT_FALSE(control.requested_enabled);EXPECT_EQ(1,config.enabled); + } + EXPECT_FALSE(RXPowerSavingCLI::set("12345 23456",4,125,&config,&control,reply,sizeof(reply))); + EXPECT_TRUE(RXPowerSavingCLI::set("off",4,0,&config,&control,reply,sizeof(reply))); + EXPECT_EQ(0,config.enabled); +} +TEST(RxPowerSaving, DiagnosticRetainsIntentStateAndErrorForExtremeInputs) { + RxPowerSavingConfig config;config.enabled=1;config.level=RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL; + config.rx_us=RX_POWERSAVING_MAX_PERIOD_US;config.sleep_us=RX_POWERSAVING_MAX_PERIOD_US; + FakeRxPowerSavingControl control;control.status.last_error=-10001;control.status.arm_failures=UINT32_MAX; + char reply[160]; + for(float bw:{std::numeric_limits::infinity(),std::numeric_limits::quiet_NaN(),1.0e30f,62.5f}) { + RXPowerSavingCLI::get(&config,&control,8,bw,reply,sizeof(reply)); + EXPECT_NE(nullptr,strstr(reply,"desired=on"));EXPECT_NE(nullptr,strstr(reply,"effective=continuous")); + EXPECT_NE(nullptr,strstr(reply,"err=-10001"));EXPECT_NE(nullptr,strstr(reply,"fail=4294967295")); + } + config.level=6; + RXPowerSavingCLI::get(&config,&control,8,1.0e30f,reply,sizeof(reply)); + EXPECT_EQ(nullptr,strstr(reply,"catch=")); +} + +TEST(RxPowerSaving, RejectsImpossibleCaptureCostBeforeConvertingPeriods) { + for(float cost:{16.0f,32.0f,1.0e30f,std::numeric_limits::infinity(),std::numeric_limits::quiet_NaN()}) { + for(uint8_t level=1;level<=10;level++) { + uint32_t rx=12345,sleep=23456; + EXPECT_FALSE(calcRxPowerSavingLevel(level,8,62.5f,16,&rx,&sleep,cost,2600)); + EXPECT_EQ(12345u,rx);EXPECT_EQ(23456u,sleep); + } + } +} diff --git a/test/test_serial_idle/test_serial_idle.cpp b/test/test_serial_idle/test_serial_idle.cpp new file mode 100644 index 00000000000..2cec47d7082 --- /dev/null +++ b/test/test_serial_idle/test_serial_idle.cpp @@ -0,0 +1,26 @@ +#include +#include +struct QueuedSerial : BaseSerialInterface { + bool enabled=false, queued=false; + void enable() override {enabled=true;} + void disable() override {enabled=false;} + bool isEnabled() const override {return enabled;} + bool isConnected() const override {return enabled;} + bool isReadBusy() const override {return queued;} + bool isWriteBusy() const override {return false;} + size_t writeFrame(const uint8_t*,size_t len) override {return len;} + size_t checkRecvFrame(uint8_t*) override {bool had=queued;queued=false;return had?1:0;} +}; +TEST(CompanionIdle, QueuedFrameHoldsIdleUntilConsumed) { + MultiSerialInterface interfaces;QueuedSerial ble;uint8_t buffer[8]; + ASSERT_TRUE(interfaces.addInterface(InterfaceType::Bluetooth,&ble));interfaces.enable(); + ble.queued=true;EXPECT_TRUE(interfaces.isReadBusy()); + EXPECT_EQ(1U,interfaces.checkRecvFrame(buffer));EXPECT_FALSE(interfaces.isReadBusy()); +} +TEST(CompanionIdle, DisabledInterfaceDoesNotBlockOtherInterfaces) { + MultiSerialInterface interfaces;QueuedSerial ble,usb; + interfaces.addInterface(InterfaceType::Bluetooth,&ble);interfaces.addInterface(InterfaceType::USB,&usb); + interfaces.enable();ble.queued=true;interfaces.disableBluetooth();EXPECT_FALSE(interfaces.isReadBusy()); + usb.queued=true;EXPECT_TRUE(interfaces.isReadBusy());interfaces.disable();EXPECT_FALSE(interfaces.isReadBusy()); +} +int main(int argc,char** argv){testing::InitGoogleTest(&argc,argv);return RUN_ALL_TESTS();} diff --git a/test/test_ssd1306/test_ssd1306.cpp b/test/test_ssd1306/test_ssd1306.cpp new file mode 100644 index 00000000000..4f9ad787592 --- /dev/null +++ b/test/test_ssd1306/test_ssd1306.cpp @@ -0,0 +1,53 @@ +#include +#include "helpers/ui/SSD1306Display.cpp" +class OLED : public testing::Test { +protected: + RefCountedDigitalPin power{7}; + SSD1306Display display{&power}; + void SetUp() override { power.begin(); Wire.error=0; Adafruit_SSD1306::init_ok=true; Adafruit_SSD1306::starts=Adafruit_SSD1306::commands=0; } +}; +TEST_F(OLED, FailedAllocationReleasesClaimAndDoesNotSendOn) { + Adafruit_SSD1306::init_ok=false; display.turnOn(); + EXPECT_FALSE(display.isOn()); EXPECT_EQ(LOW,pin_levels[7]); EXPECT_EQ(0,Adafruit_SSD1306::commands); +} +TEST_F(OLED, FailedProbeCanRetryAndRepeatedBeginDoesNotLeakClaim) { + Wire.error=4; EXPECT_FALSE(display.begin()); EXPECT_EQ(LOW,pin_levels[7]); + Wire.error=0; EXPECT_TRUE(display.begin()); EXPECT_TRUE(display.begin()); + display.turnOff(); EXPECT_EQ(LOW,pin_levels[7]); EXPECT_FALSE(display.isOn()); +} +TEST_F(OLED, FailureDuringReinitializationReleasesExistingClaim) { + ASSERT_TRUE(display.begin()); Wire.error=4; EXPECT_FALSE(display.begin()); + EXPECT_FALSE(display.isOn()); EXPECT_EQ(LOW,pin_levels[7]); +} +TEST_F(OLED, SharedSupplySurvivesFailureAndRepeatedOff) { + power.claim(); Wire.error=4; EXPECT_FALSE(display.begin()); + EXPECT_EQ(HIGH,pin_levels[7]); display.turnOff(); display.turnOff(); + EXPECT_EQ(HIGH,pin_levels[7]); power.release(); EXPECT_EQ(LOW,pin_levels[7]); +} +TEST_F(OLED, RepeatedPowerCyclesReinitializeOnceAndBalanceClaims) { + power.claim(); + for(int i=0;i<5;++i) { + display.turnOn(); display.turnOn(); EXPECT_TRUE(display.isOn()); + EXPECT_EQ(i+1,Adafruit_SSD1306::starts); + display.turnOff(); display.turnOff(); EXPECT_EQ(HIGH,pin_levels[7]); + } + power.release(); EXPECT_EQ(LOW,pin_levels[7]); +} +TEST(OLEDWithoutSwitchedPower, WakeDoesNotReinitializeOrPulseReset) { + Wire.error=0; Adafruit_SSD1306::init_ok=true; Adafruit_SSD1306::starts=Adafruit_SSD1306::commands=0; + SSD1306Display display; + ASSERT_TRUE(display.begin()); EXPECT_EQ(1,Adafruit_SSD1306::starts); + for(int i=0;i<3;++i) { + display.turnOff(); EXPECT_FALSE(display.isOn()); + display.turnOn(); EXPECT_TRUE(display.isOn()); + } + EXPECT_EQ(1,Adafruit_SSD1306::starts); // begin() pulses PIN_OLED_RESET; only the boot init may do that +} +TEST(OLEDWithoutSwitchedPower, MissingPanelIsNotRetriedOnWake) { + Wire.error=4; Adafruit_SSD1306::init_ok=true; Adafruit_SSD1306::starts=Adafruit_SSD1306::commands=0; + SSD1306Display display; + EXPECT_FALSE(display.begin()); display.turnOn(); display.turnOff(); display.turnOn(); + EXPECT_EQ(1,Adafruit_SSD1306::starts); + Wire.error=0; +} +int main(int argc,char**argv) { testing::InitGoogleTest(&argc,argv);return RUN_ALL_TESTS(); } diff --git a/test/test_uptime/test_uptime.cpp b/test/test_uptime/test_uptime.cpp new file mode 100644 index 00000000000..10d18df94a7 --- /dev/null +++ b/test/test_uptime/test_uptime.cpp @@ -0,0 +1,8 @@ +#include +#include "helpers/Uptime.h" +TEST(Uptime, IncludesTimeBeforeFirstPollAndSurvivesRollover) { + Uptime t; t.tick(1234); EXPECT_EQ(1234U,t.milliseconds()); + t.tick(0xFFFFFFF0U); t.tick(0x10U); EXPECT_EQ(0x100000010ULL,t.milliseconds()); + t.tick(0x10U); EXPECT_EQ(0x100000010ULL,t.milliseconds()); +} +int main(int argc,char**argv) { testing::InitGoogleTest(&argc,argv);return RUN_ALL_TESTS(); } diff --git a/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h b/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h index 0b0f701c264..36e588b9985 100644 --- a/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h +++ b/variants/gat562_30s_mesh_kit/GAT56230SMeshKitBoard.h @@ -43,11 +43,11 @@ class GAT56230SMeshKitBoard : public NRF52BoardDCDC { } #endif - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); uint32_t button_pin = PIN_BUTTON1; nrf_gpio_cfg_input(button_pin, NRF_GPIO_PIN_PULLUP); nrf_gpio_cfg_sense_set(button_pin, NRF_GPIO_PIN_SENSE_LOW); - NRF52Board::powerOff(); } }; diff --git a/variants/gat562_30s_mesh_kit/platformio.ini b/variants/gat562_30s_mesh_kit/platformio.ini index 326e3f705cb..410edf9ed29 100644 --- a/variants/gat562_30s_mesh_kit/platformio.ini +++ b/variants/gat562_30s_mesh_kit/platformio.ini @@ -1,5 +1,6 @@ [GAT562_30S_Mesh_Kit] extends = nrf52_base +board_build.ldscript = boards/nrf52840_s140_v6.ld board = rak4631 board_check = true build_flags = ${nrf52_base.build_flags} diff --git a/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h b/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h index 66c770994d4..67e9df92b84 100644 --- a/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h +++ b/variants/gat562_mesh_evb_pro/GAT562EVBProBoard.h @@ -43,11 +43,11 @@ class GAT562EVBProBoard : public NRF52BoardDCDC { } #endif - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); uint32_t button_pin = PIN_BUTTON1; nrf_gpio_cfg_input(button_pin, NRF_GPIO_PIN_PULLUP); nrf_gpio_cfg_sense_set(button_pin, NRF_GPIO_PIN_SENSE_LOW); - NRF52Board::powerOff(); } }; diff --git a/variants/gat562_mesh_evb_pro/platformio.ini b/variants/gat562_mesh_evb_pro/platformio.ini index 280deb1f0f6..a5170ba5d41 100644 --- a/variants/gat562_mesh_evb_pro/platformio.ini +++ b/variants/gat562_mesh_evb_pro/platformio.ini @@ -1,5 +1,6 @@ [GAT562_Mesh_EVB_Pro] extends = nrf52_base +board_build.ldscript = boards/nrf52840_s140_v6.ld board = rak4631 board_check = true build_flags = ${nrf52_base.build_flags} diff --git a/variants/gat562_mesh_tracker_pro/GAT562MeshTrackerProBoard.h b/variants/gat562_mesh_tracker_pro/GAT562MeshTrackerProBoard.h index 39b55911a1b..7bab9d57706 100644 --- a/variants/gat562_mesh_tracker_pro/GAT562MeshTrackerProBoard.h +++ b/variants/gat562_mesh_tracker_pro/GAT562MeshTrackerProBoard.h @@ -43,11 +43,11 @@ class GAT562MeshTrackerProBoard : public NRF52BoardDCDC { } #endif - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); uint32_t button_pin = PIN_BUTTON1; nrf_gpio_cfg_input(button_pin, NRF_GPIO_PIN_PULLUP); nrf_gpio_cfg_sense_set(button_pin, NRF_GPIO_PIN_SENSE_LOW); - NRF52Board::powerOff(); } }; diff --git a/variants/gat562_mesh_tracker_pro/platformio.ini b/variants/gat562_mesh_tracker_pro/platformio.ini index 1141cee622f..60c39522838 100644 --- a/variants/gat562_mesh_tracker_pro/platformio.ini +++ b/variants/gat562_mesh_tracker_pro/platformio.ini @@ -1,5 +1,6 @@ [GAT562_Mesh_Tracker_Pro] extends = nrf52_base +board_build.ldscript = boards/nrf52840_s140_v6.ld board = rak4631 board_check = true build_flags = ${nrf52_base.build_flags} diff --git a/variants/gat562_mesh_watch13/GAT56MeshWatch13Board.h b/variants/gat562_mesh_watch13/GAT56MeshWatch13Board.h index 805ca67f242..045ffeeae1c 100644 --- a/variants/gat562_mesh_watch13/GAT56MeshWatch13Board.h +++ b/variants/gat562_mesh_watch13/GAT56MeshWatch13Board.h @@ -34,11 +34,11 @@ class GAT56MeshWatch13Board : public NRF52BoardDCDC { } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); uint32_t button_pin = PIN_BUTTON1; nrf_gpio_cfg_input(button_pin, NRF_GPIO_PIN_PULLUP); nrf_gpio_cfg_sense_set(button_pin, NRF_GPIO_PIN_SENSE_LOW); - NRF52Board::powerOff(); } }; diff --git a/variants/gat562_mesh_watch13/platformio.ini b/variants/gat562_mesh_watch13/platformio.ini index d8ca3052786..2f4fb526bf5 100644 --- a/variants/gat562_mesh_watch13/platformio.ini +++ b/variants/gat562_mesh_watch13/platformio.ini @@ -1,5 +1,6 @@ [GAT562_Mesh_Watch13] extends = nrf52_base +board_build.ldscript = boards/nrf52840_s140_v6.ld board = rak4631 board_check = true build_flags = ${nrf52_base.build_flags} diff --git a/variants/heltec_rc32/HeltecRC32Board.cpp b/variants/heltec_rc32/HeltecRC32Board.cpp index 59920d238f0..42bf2339dfc 100644 --- a/variants/heltec_rc32/HeltecRC32Board.cpp +++ b/variants/heltec_rc32/HeltecRC32Board.cpp @@ -31,9 +31,6 @@ void HeltecRC32Board::begin() { } } -void HeltecRC32Board::powerOff() { - enterDeepSleep(0); -} void HeltecRC32Board::onBeforeTransmit() { digitalWrite(P_LORA_TX_LED, HIGH); diff --git a/variants/heltec_rc32/HeltecRC32Board.h b/variants/heltec_rc32/HeltecRC32Board.h index 5b2093c8f6e..e53111e71c6 100644 --- a/variants/heltec_rc32/HeltecRC32Board.h +++ b/variants/heltec_rc32/HeltecRC32Board.h @@ -21,7 +21,6 @@ class HeltecRC32Board : public ESP32Board { void begin(); void onBeforeTransmit() override; void onAfterTransmit() override; - void powerOff() override; uint16_t getBattMilliVolts() override; bool setAdcMultiplier(float multiplier) override; float getAdcMultiplier() const override { return adc_mult; } diff --git a/variants/heltec_t096/T096Board.cpp b/variants/heltec_t096/T096Board.cpp index ea26d2cc15a..a4d65a0e155 100644 --- a/variants/heltec_t096/T096Board.cpp +++ b/variants/heltec_t096/T096Board.cpp @@ -111,10 +111,10 @@ void T096Board::variant_shutdown() { nrf_gpio_cfg_default(PIN_GPS_TX); } -void T096Board::powerOff() { +void T096Board::shutdownPeripherals() { + NRF52Board::shutdownPeripherals(); loRaFEMControl.setSleepModeEnable(); nrf_gpio_cfg_default(PIN_GPS_EN); // 363uA down to 39uA - NRF52Board::powerOff(); } const char* T096Board::getManufacturerName() const { diff --git a/variants/heltec_t096/T096Board.h b/variants/heltec_t096/T096Board.h index e926c8a6a31..70e2693e56b 100644 --- a/variants/heltec_t096/T096Board.h +++ b/variants/heltec_t096/T096Board.h @@ -32,5 +32,5 @@ class T096Board : public NRF52BoardDCDC { void onAfterTransmit(void) override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override ; - void powerOff() override; + void shutdownPeripherals() override; }; diff --git a/variants/heltec_t1/T1Board.cpp b/variants/heltec_t1/T1Board.cpp index 1ef5b766b34..275c1466de0 100644 --- a/variants/heltec_t1/T1Board.cpp +++ b/variants/heltec_t1/T1Board.cpp @@ -97,9 +97,9 @@ void T1Board::variant_shutdown() { digitalWrite(PIN_BAT_CTL, !ADC_CTRL_ENABLED); } -void T1Board::powerOff() { +void T1Board::shutdownPeripherals() { + NRF52Board::shutdownPeripherals(); variant_shutdown(); - NRF52Board::powerOff(); } const char* T1Board::getManufacturerName() const { diff --git a/variants/heltec_t1/T1Board.h b/variants/heltec_t1/T1Board.h index 9b46fd11a89..3e9afd1b914 100644 --- a/variants/heltec_t1/T1Board.h +++ b/variants/heltec_t1/T1Board.h @@ -22,5 +22,5 @@ class T1Board : public NRF52BoardDCDC { void onAfterTransmit() override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override; - void powerOff() override; + void shutdownPeripherals() override; }; diff --git a/variants/heltec_t114/T114Board.h b/variants/heltec_t114/T114Board.h index bd76d3de44c..e5b1bc8fdb2 100644 --- a/variants/heltec_t114/T114Board.h +++ b/variants/heltec_t114/T114Board.h @@ -46,11 +46,11 @@ class T114Board : public NRF52BoardDCDC { return "Heltec T114"; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); #ifdef LED_PIN digitalWrite(LED_PIN, HIGH); #endif - NRF52Board::powerOff(); } }; diff --git a/variants/heltec_t114/platformio.ini b/variants/heltec_t114/platformio.ini index e795bc7fa5c..74a512a6725 100644 --- a/variants/heltec_t114/platformio.ini +++ b/variants/heltec_t114/platformio.ini @@ -255,3 +255,18 @@ lib_deps = extends = Heltec_t114 build_src_filter = ${Heltec_t114.build_src_filter} +<../examples/kiss_modem/> + +[env:Heltec_t114_sensor] +extends = Heltec_t114_with_display +build_src_filter = ${Heltec_t114_with_display.build_src_filter} + +<../examples/simple_sensor> +build_flags = + ${Heltec_t114_with_display.build_flags} + -D ADVERT_NAME='"Heltec_T114 Sensor"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +; -D POWERSAVING_DEBUG=1 diff --git a/variants/heltec_tower_v2/HeltecTowerV2Board.cpp b/variants/heltec_tower_v2/HeltecTowerV2Board.cpp index 04213bda8c5..9bf290fc59b 100644 --- a/variants/heltec_tower_v2/HeltecTowerV2Board.cpp +++ b/variants/heltec_tower_v2/HeltecTowerV2Board.cpp @@ -88,7 +88,8 @@ const char* HeltecTowerV2Board::getManufacturerName() const { return "Heltec Tower V2"; } -void HeltecTowerV2Board::powerOff() { +void HeltecTowerV2Board::shutdownPeripherals() { + NRF52Board::shutdownPeripherals(); pinMode(PIN_GPS_EN, OUTPUT); digitalWrite(PIN_GPS_EN, !PIN_GPS_EN_ACTIVE); pinMode(PIN_GPS_STANDBY, OUTPUT); @@ -99,5 +100,4 @@ void HeltecTowerV2Board::powerOff() { pinMode(PIN_BAT_CTL, OUTPUT); digitalWrite(PIN_BAT_CTL, LOW); variant_shutdown(); - sd_power_system_off(); } diff --git a/variants/heltec_tower_v2/HeltecTowerV2Board.h b/variants/heltec_tower_v2/HeltecTowerV2Board.h index 6912acdec7a..b5e7f12718a 100644 --- a/variants/heltec_tower_v2/HeltecTowerV2Board.h +++ b/variants/heltec_tower_v2/HeltecTowerV2Board.h @@ -20,5 +20,5 @@ class HeltecTowerV2Board : public NRF52BoardDCDC { void onAfterTransmit() override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override; - void powerOff() override; + void shutdownPeripherals() override; }; diff --git a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp index 753824a05ae..d5bffcb9313 100644 --- a/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp +++ b/variants/heltec_tracker_v2/HeltecTrackerV2Board.cpp @@ -35,12 +35,12 @@ void HeltecTrackerV2Board::begin() { loRaFEMControl.setRxModeEnable(); } - void HeltecTrackerV2Board::powerOff() { + void HeltecTrackerV2Board::shutdownPeripherals() { + ESP32Board::shutdownPeripherals(); // Turn off PA digitalWrite(P_LORA_PA_POWER, LOW); rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER); - ESP32Board::powerOff(); } uint16_t HeltecTrackerV2Board::getBattMilliVolts() { diff --git a/variants/heltec_tracker_v2/HeltecTrackerV2Board.h b/variants/heltec_tracker_v2/HeltecTrackerV2Board.h index 5fed2b65559..091a39d651e 100644 --- a/variants/heltec_tracker_v2/HeltecTrackerV2Board.h +++ b/variants/heltec_tracker_v2/HeltecTrackerV2Board.h @@ -23,7 +23,7 @@ class HeltecTrackerV2Board : public ESP32Board { void onBeforeTransmit(void) override; void onAfterTransmit(void) override; - void powerOff() override; + void shutdownPeripherals() override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override ; diff --git a/variants/heltec_tracker_v2/platformio.ini b/variants/heltec_tracker_v2/platformio.ini index 5fd9edabe7a..a0c48cd8fd1 100644 --- a/variants/heltec_tracker_v2/platformio.ini +++ b/variants/heltec_tracker_v2/platformio.ini @@ -225,3 +225,26 @@ lib_deps = extends = Heltec_tracker_v2 build_src_filter = ${Heltec_tracker_v2.build_src_filter} +<../examples/kiss_modem/> + +[env:heltec_tracker_v2_repeater_bridge_rs232] +extends = Heltec_tracker_v2 +build_flags = + ${Heltec_tracker_v2.build_flags} + -D ADVERT_NAME='"RS232 Bridge"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_RS232_BRIDGE=Serial2 + -D WITH_RS232_BRIDGE_RX=44 + -D WITH_RS232_BRIDGE_TX=43 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Heltec_tracker_v2.build_src_filter} + + + + + +<../examples/simple_repeater> +lib_deps = + ${Heltec_tracker_v2.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 diff --git a/variants/heltec_v4/HeltecV4Board.cpp b/variants/heltec_v4/HeltecV4Board.cpp index 794257d76ad..948c3dfaa12 100644 --- a/variants/heltec_v4/HeltecV4Board.cpp +++ b/variants/heltec_v4/HeltecV4Board.cpp @@ -32,12 +32,12 @@ void HeltecV4Board::begin() { loRaFEMControl.setRxModeEnable(); } - void HeltecV4Board::powerOff() { + void HeltecV4Board::shutdownPeripherals() { + ESP32Board::shutdownPeripherals(); // Turn off PA digitalWrite(P_LORA_PA_POWER, LOW); rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER); - ESP32Board::powerOff(); } uint16_t HeltecV4Board::getBattMilliVolts() { diff --git a/variants/heltec_v4/HeltecV4Board.h b/variants/heltec_v4/HeltecV4Board.h index 79a690e8407..1f83eee7c5b 100644 --- a/variants/heltec_v4/HeltecV4Board.h +++ b/variants/heltec_v4/HeltecV4Board.h @@ -29,7 +29,7 @@ class HeltecV4Board : public ESP32Board { void onBeforeTransmit(void) override; void onAfterTransmit(void) override; - void powerOff() override; + void shutdownPeripherals() override; uint16_t getBattMilliVolts() override; bool setAdcMultiplier(float multiplier) override { if (multiplier == 0.0f) { diff --git a/variants/heltec_v4/platformio.ini b/variants/heltec_v4/platformio.ini index 76e8e714e68..11897c5aa4f 100644 --- a/variants/heltec_v4/platformio.ini +++ b/variants/heltec_v4/platformio.ini @@ -7,6 +7,7 @@ build_flags = -I variants/heltec_v4 -D HELTEC_LORA_V4 -D USE_SX1262 + -D ARDUINO_USB_MODE=1 ; ARDUINO_USB_MODE in heltec_v4.json has no effect -D ESP32_CPU_FREQ=80 -D RADIO_CLASS=CustomSX1262 -D WRAPPER_CLASS=CustomSX1262Wrapper @@ -392,6 +393,13 @@ lib_deps = ${heltec_v4_tft.lib_deps} densaugeo/base64 @ ~1.4.0 +[env:heltec_v4_expansionkit_tft_companion_radio_ble] +extends = env:heltec_v4_tft_companion_radio_ble +build_flags = + ${env:heltec_v4_tft_companion_radio_ble.build_flags} + -D ENV_PIN_SDA=4 + -D ENV_PIN_SCL=3 + [env:heltec_v4_tft_companion_radio_wifi] extends = heltec_v4_tft build_flags = diff --git a/variants/heltec_v4_r8/HeltecV4R8Board.cpp b/variants/heltec_v4_r8/HeltecV4R8Board.cpp index ea2cfbedb4a..31ef3a15b17 100644 --- a/variants/heltec_v4_r8/HeltecV4R8Board.cpp +++ b/variants/heltec_v4_r8/HeltecV4R8Board.cpp @@ -39,30 +39,12 @@ void HeltecV4R8Board::onAfterTransmit(void) { loRaFEMControl.setRxModeEnable(); } -void HeltecV4R8Board::enterDeepSleep(uint32_t secs, int pin_wake_btn) { - esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); - - rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); - rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); - - rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); - loRaFEMControl.setRxModeEnableWhenMCUSleep(); - - if (pin_wake_btn < 0) { - esp_sleep_enable_ext1_wakeup((1L << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); - } else { - esp_sleep_enable_ext1_wakeup((1L << P_LORA_DIO_1) | (1L << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); - } - - if (secs > 0) { - esp_sleep_enable_timer_wakeup(secs * 1000000); - } - - esp_deep_sleep_start(); -} - -void HeltecV4R8Board::powerOff() { - enterDeepSleep(0); +void HeltecV4R8Board::shutdownPeripherals() { + ESP32Board::shutdownPeripherals(); + loRaFEMControl.setSleepModeEnable(); + digitalWrite(P_LORA_PA_POWER, LOW); + rtc_gpio_hold_en((gpio_num_t)P_LORA_PA_POWER); + rtc_gpio_hold_en((gpio_num_t)P_LORA_KCT8103L_PA_CSD); } uint16_t HeltecV4R8Board::getBattMilliVolts() { diff --git a/variants/heltec_v4_r8/HeltecV4R8Board.h b/variants/heltec_v4_r8/HeltecV4R8Board.h index 7ab29f96200..03cbf10dbc4 100644 --- a/variants/heltec_v4_r8/HeltecV4R8Board.h +++ b/variants/heltec_v4_r8/HeltecV4R8Board.h @@ -30,8 +30,7 @@ class HeltecV4R8Board : public ESP32Board { void onBeforeTransmit(void) override; void onAfterTransmit(void) override; - void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); - void powerOff() override; + void shutdownPeripherals() override; uint16_t getBattMilliVolts() override; bool setAdcMultiplier(float multiplier) override { if (multiplier == 0.0f) { diff --git a/variants/keepteen_lt1/platformio.ini b/variants/keepteen_lt1/platformio.ini index 11dc214b09f..e35a4ffa119 100644 --- a/variants/keepteen_lt1/platformio.ini +++ b/variants/keepteen_lt1/platformio.ini @@ -1,5 +1,6 @@ [KeepteenLT1] extends = nrf52_base +board_build.ldscript = boards/nrf52840_s140_v6.ld board = keepteen_lt1 build_flags = ${nrf52_base.build_flags} -I variants/keepteen_lt1 diff --git a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp index e041995546d..f394b19463c 100644 --- a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp +++ b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp @@ -82,7 +82,8 @@ const char* TBeam1WBoard::getManufacturerName() const { return "LilyGo T-Beam 1W"; } -void TBeam1WBoard::powerOff() { +void TBeam1WBoard::shutdownPeripherals() { + ESP32Board::shutdownPeripherals(); portENTER_CRITICAL(&_fan_mux); _stopped = true; _tx_active = false; @@ -99,7 +100,6 @@ void TBeam1WBoard::powerOff() { digitalWrite(LED_PIN, LOW); - ESP32Board::powerOff(); } void TBeam1WBoard::setFanEnabled(bool enabled) { diff --git a/variants/lilygo_tbeam_1w/TBeam1WBoard.h b/variants/lilygo_tbeam_1w/TBeam1WBoard.h index 429b7bd1cfb..33af410a8f8 100644 --- a/variants/lilygo_tbeam_1w/TBeam1WBoard.h +++ b/variants/lilygo_tbeam_1w/TBeam1WBoard.h @@ -70,7 +70,7 @@ class TBeam1WBoard : public ESP32Board { void onAfterTransmit() override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override; - void powerOff() override; + void shutdownPeripherals() override; void setFanEnabled(bool enabled); bool isFanEnabled() const; diff --git a/variants/lilygo_techo_lite/platformio.ini b/variants/lilygo_techo_lite/platformio.ini index 2a92eb9be2d..e8a9b5f2358 100644 --- a/variants/lilygo_techo_lite/platformio.ini +++ b/variants/lilygo_techo_lite/platformio.ini @@ -83,6 +83,7 @@ build_flags = -D MAX_GROUP_CHANNELS=40 ; -D QSPIFLASH=1 -D BLE_PIN_CODE=123456 + -D DISABLE_BLE_LED ; keep the blue LED off instead of blinking while advertising ; -D BLE_DEBUG_LOGGING=1 -D OFFLINE_QUEUE_SIZE=256 -D UI_RECENT_LIST_SIZE=9 @@ -154,6 +155,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D BLE_PIN_CODE=123456 + -D DISABLE_BLE_LED ; keep the blue LED off instead of blinking while advertising -D OFFLINE_QUEUE_SIZE=256 -D UI_RECENT_LIST_SIZE=9 -D AUTO_SHUTDOWN_MILLIVOLTS=3300 diff --git a/variants/lilygo_tlora_v2_1/platformio.ini b/variants/lilygo_tlora_v2_1/platformio.ini index dc60b6f75e4..6e8e08168fb 100644 --- a/variants/lilygo_tlora_v2_1/platformio.ini +++ b/variants/lilygo_tlora_v2_1/platformio.ini @@ -44,6 +44,7 @@ build_src_filter = ${LilyGo_TLora_V2_1_1_6.build_src_filter} +<../examples/simple_repeater> build_flags = ${LilyGo_TLora_V2_1_1_6.build_flags} + -D PIN_USER_BTN=-1 ; after the base flags: the last -D wins. GPIO0 wake kept this repeater from booting (v17) -D ADVERT_NAME='"TLora-V2.1-1.6 Repeater"' -D ADVERT_LAT=0.0 -D ADVERT_LON=0.0 diff --git a/variants/meshtiny/MeshtinyBoard.h b/variants/meshtiny/MeshtinyBoard.h index 21673d0aac8..c3a849037f3 100644 --- a/variants/meshtiny/MeshtinyBoard.h +++ b/variants/meshtiny/MeshtinyBoard.h @@ -34,7 +34,8 @@ class MeshtinyBoard : public NRF52BoardDCDC { void reboot() override { NVIC_SystemReset(); } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); #ifdef PIN_USER_BTN while (digitalRead(PIN_USER_BTN) == LOW) { @@ -60,7 +61,6 @@ class MeshtinyBoard : public NRF52BoardDCDC { nrf_gpio_cfg_sense_input(g_ADigitalPinMap[PIN_USER_BTN], NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); #endif - NRF52Board::powerOff(); } }; diff --git a/variants/meshtracker_x1/MeshTrackerX1Board.h b/variants/meshtracker_x1/MeshTrackerX1Board.h index 51f022e0b85..ddc738c253d 100644 --- a/variants/meshtracker_x1/MeshTrackerX1Board.h +++ b/variants/meshtracker_x1/MeshTrackerX1Board.h @@ -46,7 +46,8 @@ class MeshTrackerX1Board : public NRF52BoardDCDC { return 0; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); #ifdef HAS_GPS digitalWrite(GPS_VRTC_EN, LOW); digitalWrite(GPS_RESET, LOW); @@ -82,6 +83,5 @@ class MeshTrackerX1Board : public NRF52BoardDCDC { nrf_gpio_cfg_sense_input(BUTTON_PIN, NRF_GPIO_PIN_PULLDOWN, NRF_GPIO_PIN_SENSE_HIGH); #endif - sd_power_system_off(); } }; diff --git a/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h b/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h index 450a3d13b02..fa5632a94d0 100644 --- a/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h +++ b/variants/minewsemi_me25ls01/MinewsemiME25LS01Board.h @@ -45,7 +45,8 @@ class MinewsemiME25LS01Board : public NRF52BoardDCDC { return "Minewsemi"; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); #ifdef HAS_GPS digitalWrite(GPS_VRTC_EN, LOW); digitalWrite(GPS_RESET, LOW); @@ -65,7 +66,6 @@ class MinewsemiME25LS01Board : public NRF52BoardDCDC { #ifdef BUTTON_PIN nrf_gpio_cfg_sense_input(digitalPinToInterrupt(BUTTON_PIN), NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); #endif - NRF52Board::powerOff(); } #if defined(P_LORA_TX_LED) diff --git a/variants/mke_s3/MKES3Board.h b/variants/mke_s3/MKES3Board.h new file mode 100644 index 00000000000..a0ccf9e1328 --- /dev/null +++ b/variants/mke_s3/MKES3Board.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +class MKES3Board : public ESP32Board { +public: + MKES3Board() { } + + const char* getManufacturerName() const override { + return "MKE S3"; + } +}; diff --git a/variants/mke_s3/PinChecks.h b/variants/mke_s3/PinChecks.h new file mode 100644 index 00000000000..9df5afd21f8 --- /dev/null +++ b/variants/mke_s3/PinChecks.h @@ -0,0 +1,4 @@ +#pragma once +#if defined(WITH_RS232_BRIDGE) && ((WITH_RS232_BRIDGE_RX == P_LORA_RESET) || (WITH_RS232_BRIDGE_TX == P_LORA_RESET)) +#error "MKE-S3 RS232 pins conflict with LoRa reset; confirm separate RX/TX wiring" +#endif diff --git a/variants/mke_s3/platformio.ini b/variants/mke_s3/platformio.ini new file mode 100644 index 00000000000..4bcf05f9279 --- /dev/null +++ b/variants/mke_s3/platformio.ini @@ -0,0 +1,212 @@ +; https://github.com/makereduvn/MKE-K01-ESP32-S3-DEV-KIT +[MKE_s3] +extends = esp32_base +board = esp32-s3-devkitc-1 +build_flags = + ${esp32_base.build_flags} + ${sensor_base.build_flags} + -I variants/mke_s3 + -D MKE_S3 + -D ESP32_CPU_FREQ=80 + ; First heading + -D P_LORA_NSS=14 + -D P_LORA_MOSI=13 + -D P_LORA_MISO=12 + -D P_LORA_SCLK=11 + -D P_LORA_BUSY=10 + ; Second heading + ; GND + ; 3.3v + -D P_LORA_RESET=5 + -D P_LORA_DIO_1=4 + -D USE_SX1262 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=22 +; -D P_LORA_TX_LED=48 ; RGB LED + -D PIN_BOARD_SDA=8 + -D PIN_BOARD_SCL=9 + -D PIN_USER_BTN=0 +; -D PIN_VEXT_EN=36 + -D SX126X_DIO2_AS_RF_SWITCH=true + -D SX126X_DIO3_TCXO_VOLTAGE=0 ; Ra01SH uses XTAL + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 + -D PIN_GPS_RX=47 + -D PIN_GPS_TX=48 + -D PIN_GPS_EN=26 +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/mke_s3> + + +lib_deps = + ${esp32_base.lib_deps} + ${sensor_base.lib_deps} + adafruit/Adafruit SSD1306 @ ^2.5.13 + +[env:MKE_s3_repeater] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"MKE Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + + + +<../examples/simple_repeater> +lib_deps = + ${MKE_s3.lib_deps} + ${esp32_ota.lib_deps} + bakercp/CRC32 @ ^2.0.0 + +; RS232 environment withheld: v17 assigned GPIO5 to both LoRa reset and RX. +; Publish only after RX/TX wiring is confirmed and conflict checks pass. + +[env:MKE_s3_repeater_bridge_espnow] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"ESPNow Bridge"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_ESPNOW_BRIDGE=1 +; -D BRIDGE_DEBUG=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + + + + + +<../examples/simple_repeater> +lib_deps = + ${MKE_s3.lib_deps} + ${esp32_ota.lib_deps} + +[env:MKE_s3_room_server] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"MKE RoomServer"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + + + +<../examples/simple_room_server> +lib_deps = + ${MKE_s3.lib_deps} + ${esp32_ota.lib_deps} + +[env:MKE_s3_terminal_chat] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + +<../examples/simple_secure_chat/main.cpp> +lib_deps = + ${MKE_s3.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:MKE_s3_companion_radio_usb] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=SSD1306Display + -D ENABLE_USB_INTERFACE +; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 +; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${MKE_s3.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:MKE_s3_companion_radio_ble] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=SSD1306Display + -D BLE_PIN_CODE=123456 ; fixed PIN if display is not detected + -D AUTO_SHUTDOWN_MILLIVOLTS=3400 +; -D BLE_DEBUG_LOGGING=1 + -D OFFLINE_QUEUE_SIZE=256 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${MKE_s3.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:MKE_s3_companion_radio_wifi] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=SSD1306Display +; -D WIFI_DEBUG_LOGGING=1 + -D ENABLE_WIFI_INTERFACE + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' + -D OFFLINE_QUEUE_SIZE=256 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + + + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${MKE_s3.lib_deps} + densaugeo/base64 @ ~1.4.0 + +[env:MKE_s3_sensor] +extends = MKE_s3 +build_flags = + ${MKE_s3.build_flags} + -D ADVERT_NAME='"MKE Sensor"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ENV_PIN_SDA=33 + -D ENV_PIN_SCL=34 + -D DISPLAY_CLASS=SSD1306Display +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${MKE_s3.build_src_filter} + + + +<../examples/simple_sensor> +lib_deps = + ${MKE_s3.lib_deps} + ${esp32_ota.lib_deps} diff --git a/variants/mke_s3/target.cpp b/variants/mke_s3/target.cpp new file mode 100644 index 00000000000..ee3c1e804a1 --- /dev/null +++ b/variants/mke_s3/target.cpp @@ -0,0 +1,46 @@ +#include +#include "target.h" + +MKES3Board board; + +#if defined(P_LORA_SCLK) + static SPIClass spi; + RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); +#else + RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); +#endif + +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); + +#if ENV_INCLUDE_GPS + #include + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); + EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); +#else + EnvironmentSensorManager sensors; +#endif + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; + MomentaryButton user_btn(PIN_USER_BTN, 1000, true); +#endif + +bool radio_init() { + fallback_clock.begin(); + rtc_clock.begin(Wire); + +#if defined(P_LORA_SCLK) + return radio.std_init(&spi); +#else + return radio.std_init(); +#endif +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} + diff --git a/variants/mke_s3/target.h b/variants/mke_s3/target.h new file mode 100644 index 00000000000..8ceeaa978b6 --- /dev/null +++ b/variants/mke_s3/target.h @@ -0,0 +1,29 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#include +#include +#ifdef DISPLAY_CLASS + #include + #include +#endif + +extern MKES3Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; + +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); + +#include "PinChecks.h" diff --git a/variants/muziworks_r1_neo/R1NeoBoard.h b/variants/muziworks_r1_neo/R1NeoBoard.h index c56f1f5c2e9..fa49a18439a 100644 --- a/variants/muziworks_r1_neo/R1NeoBoard.h +++ b/variants/muziworks_r1_neo/R1NeoBoard.h @@ -20,6 +20,7 @@ class R1NeoBoard : public NRF52BoardDCDC { #ifdef NRF52_POWER_MANAGEMENT void powerOff() override { + shutdownPeripherals(); initiateShutdown(SHUTDOWN_REASON_USER); } #endif diff --git a/variants/muziworks_r1_neo/platformio.ini b/variants/muziworks_r1_neo/platformio.ini index fb51c04d94c..02f4c50f559 100644 --- a/variants/muziworks_r1_neo/platformio.ini +++ b/variants/muziworks_r1_neo/platformio.ini @@ -1,5 +1,6 @@ [R1Neo] extends = nrf52_base +board_build.ldscript = boards/nrf52840_s140_v6.ld board = rak4631 board_check = true build_flags = ${nrf52_base.build_flags} diff --git a/variants/nano_g2_ultra/nano-g2.h b/variants/nano_g2_ultra/nano-g2.h index bf9543b039c..9e512adca98 100644 --- a/variants/nano_g2_ultra/nano-g2.h +++ b/variants/nano_g2_ultra/nano-g2.h @@ -43,7 +43,8 @@ class NanoG2Ultra : public NRF52Board { const char *getManufacturerName() const override { return "Nano G2 Ultra"; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); // put GPS chip to sleep digitalWrite(PIN_GPS_STANDBY, LOW); // TODO: unset buzzer to prevent notification circuit activating on hibernate @@ -52,6 +53,5 @@ class NanoG2Ultra : public NRF52Board { nrf_gpio_cfg_sense_input(digitalPinToInterrupt(PIN_USER_BTN), NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_SENSE_LOW); - NRF52Board::powerOff(); } }; diff --git a/variants/promicro/platformio.ini b/variants/promicro/platformio.ini index da27c2fab20..f5b73c70c13 100644 --- a/variants/promicro/platformio.ini +++ b/variants/promicro/platformio.ini @@ -1,5 +1,6 @@ [Promicro] extends = nrf52_base +board_build.ldscript = boards/nrf52840_s140_v6.ld board = promicro_nrf52840 build_flags = ${nrf52_base.build_flags} -I variants/promicro diff --git a/variants/rak3401/fix_bsec_lib.py b/variants/rak3401/fix_bsec_lib.py new file mode 100644 index 00000000000..e21e9e39b6a --- /dev/null +++ b/variants/rak3401/fix_bsec_lib.py @@ -0,0 +1,15 @@ +Import('env') +import os + +# Bosch has a goof in their PlatformIO packaging making linking fail. +# The BSEC library's extra_script.py selects cortex-m4/libalgobsec.a (soft-float ABI). +# nRF52840 compiles with -mfloat-abi=hard, requiring the fpv4-sp-d16-hard blob. +# Workaround to prepend the hard-float path so the linker finds it before the +# soft-float one. +bsec_hard = os.path.join( + env.subst('$PROJECT_DIR'), + '.pio', 'libdeps', env.subst('$PIOENV'), + 'BSEC Software Library', 'src', + 'cortex-m4', 'fpv4-sp-d16-hard' +) +env.Prepend(LIBPATH=[bsec_hard]) diff --git a/variants/rak3401/platformio.ini b/variants/rak3401/platformio.ini index d157e4b1d3f..cfaa8680601 100644 --- a/variants/rak3401/platformio.ini +++ b/variants/rak3401/platformio.ini @@ -1,12 +1,17 @@ [rak3401] extends = nrf52_base +board_build.ldscript = boards/nrf52840_s140_v6.ld board = rak3401 board_check = true +extra_scripts = ${nrf52_base.extra_scripts} + post:variants/rak3401/fix_bsec_lib.py build_flags = ${nrf52_base.build_flags} ${sensor_base.build_flags} -I variants/rak3401 -D RAK_3401 -D RAK_BOARD + -UENV_INCLUDE_BME680 + -D ENV_INCLUDE_BME680_BSEC=1 -D RADIO_CLASS=CustomSX1262 -D WRAPPER_CLASS=CustomSX1262Wrapper -D LORA_TX_POWER=22 @@ -23,6 +28,7 @@ lib_deps = ${sensor_base.lib_deps} adafruit/Adafruit SSD1306 @ ^2.5.13 sparkfun/SparkFun u-blox GNSS Arduino Library@^2.2.27 + boschsensortec/BSEC Software Library @ ^1.8.1492 [env:RAK_3401_repeater] extends = rak3401 @@ -134,3 +140,14 @@ build_src_filter = ${rak3401.build_src_filter} extends = rak3401 build_src_filter = ${rak3401.build_src_filter} +<../examples/kiss_modem/> + +[env:RAK_3401_repeater_bridge_rs232_serial1] +extends = env:RAK_3401_repeater +build_flags = + ${env:RAK_3401_repeater.build_flags} + -D ADVERT_NAME='"RAK3401 1W RS232 Bridge"' + -D WITH_RS232_BRIDGE=Serial1 + -D WITH_RS232_BRIDGE_RX=PIN_SERIAL1_RX + -D WITH_RS232_BRIDGE_TX=PIN_SERIAL1_TX +build_src_filter = ${env:RAK_3401_repeater.build_src_filter} + + diff --git a/variants/rak4631/platformio.ini b/variants/rak4631/platformio.ini index d2781d9fa2b..0bf6a8834e3 100644 --- a/variants/rak4631/platformio.ini +++ b/variants/rak4631/platformio.ini @@ -1,5 +1,6 @@ [rak4631] extends = nrf52_base +board_build.ldscript = boards/nrf52840_s140_v6.ld board = rak4631 board_check = true extra_scripts = ${nrf52_base.extra_scripts} @@ -219,7 +220,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D BLE_PIN_CODE=123456 - -D BLE_DEBUG_LOGGING=1 +; -D BLE_DEBUG_LOGGING=1 -D OFFLINE_QUEUE_SIZE=256 ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 diff --git a/variants/rak_wismesh_tag/RAKWismeshTagBoard.h b/variants/rak_wismesh_tag/RAKWismeshTagBoard.h index daa90d746a1..fafe571dc0f 100644 --- a/variants/rak_wismesh_tag/RAKWismeshTagBoard.h +++ b/variants/rak_wismesh_tag/RAKWismeshTagBoard.h @@ -40,7 +40,8 @@ class RAKWismeshTagBoard : public NRF52BoardDCDC { return "RAK WisMesh Tag"; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); #ifdef BUZZER_EN digitalWrite(BUZZER_EN, LOW); #endif @@ -69,6 +70,5 @@ class RAKWismeshTagBoard : public NRF52BoardDCDC { nrf_gpio_cfg_sense_input(digitalPinToInterrupt(BUTTON_PIN), NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); #endif - NRF52Board::powerOff(); } }; diff --git a/variants/rak_wismesh_tag/platformio.ini b/variants/rak_wismesh_tag/platformio.ini index d6140187170..a2a61f98950 100644 --- a/variants/rak_wismesh_tag/platformio.ini +++ b/variants/rak_wismesh_tag/platformio.ini @@ -1,5 +1,6 @@ [rak_wismesh_tag] extends = nrf52_base +board_build.ldscript = boards/nrf52840_s140_v6.ld board = rak4631 board_check = true build_flags = ${nrf52_base.build_flags} diff --git a/variants/sensecap_solar/SenseCapSolarBoard.h b/variants/sensecap_solar/SenseCapSolarBoard.h index 5a8861c298e..c16d6b0bbd3 100644 --- a/variants/sensecap_solar/SenseCapSolarBoard.h +++ b/variants/sensecap_solar/SenseCapSolarBoard.h @@ -37,7 +37,8 @@ class SenseCapSolarBoard : public NRF52BoardDCDC { return "Seeed SenseCap Solar"; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); digitalWrite(LED_WHITE, LOW); digitalWrite(LED_BLUE, LOW); @@ -51,7 +52,11 @@ class SenseCapSolarBoard : public NRF52BoardDCDC { nrf_gpio_cfg_sense_input(digitalPinToInterrupt(g_ADigitalPinMap[PIN_BUTTON1]), NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); #endif + } + + void powerOff() override { #ifdef NRF52_POWER_MANAGEMENT + shutdownPeripherals(); initiateShutdown(SHUTDOWN_REASON_USER); #else NRF52Board::powerOff(); diff --git a/variants/station_g3_esp32/StationG3Board.cpp b/variants/station_g3_esp32/StationG3Board.cpp index 277c2b8b767..d2c1a57ab51 100644 --- a/variants/station_g3_esp32/StationG3Board.cpp +++ b/variants/station_g3_esp32/StationG3Board.cpp @@ -1,6 +1,7 @@ #include "StationG3Board.h" -void StationG3Board::powerOff() { +void StationG3Board::shutdownPeripherals() { + ESP32Board::shutdownPeripherals(); loRaFEMControl.setSleepModeEnable(); #ifdef P_PA1_EN rtc_gpio_hold_en((gpio_num_t)P_PA1_EN); @@ -10,7 +11,6 @@ void StationG3Board::powerOff() { rtc_gpio_hold_en((gpio_num_t)P_PRIMARY_LNA_EN); #endif - ESP32Board::powerOff(); } bool StationG3Board::setLoRaFemLnaEnabled(bool enable) { diff --git a/variants/station_g3_esp32/StationG3Board.h b/variants/station_g3_esp32/StationG3Board.h index b4c5838c8aa..ca22ff23358 100644 --- a/variants/station_g3_esp32/StationG3Board.h +++ b/variants/station_g3_esp32/StationG3Board.h @@ -53,7 +53,7 @@ class StationG3Board : public ESP32Board { loRaFEMControl.setRxModeEnable(); } - void powerOff() override; + void shutdownPeripherals() override; uint16_t getBattMilliVolts() override { return 0; diff --git a/variants/t1000-e/T1000eBoard.h b/variants/t1000-e/T1000eBoard.h index ba8bf0c1d9d..db364dc1403 100644 --- a/variants/t1000-e/T1000eBoard.h +++ b/variants/t1000-e/T1000eBoard.h @@ -49,7 +49,8 @@ class T1000eBoard : public NRF52BoardDCDC { return 0; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); #ifdef HAS_GPS digitalWrite(GPS_VRTC_EN, LOW); digitalWrite(GPS_RESET, LOW); @@ -84,6 +85,5 @@ class T1000eBoard : public NRF52BoardDCDC { nrf_gpio_cfg_sense_input(BUTTON_PIN, NRF_GPIO_PIN_NOPULL, NRF_GPIO_PIN_SENSE_HIGH); #endif - NRF52Board::powerOff(); } }; diff --git a/variants/t1000-e/platformio.ini b/variants/t1000-e/platformio.ini index 8456dc91513..42dff1aae07 100644 --- a/variants/t1000-e/platformio.ini +++ b/variants/t1000-e/platformio.ini @@ -128,3 +128,20 @@ build_src_filter = ${t1000-e.build_src_filter} +<../examples/kiss_modem/> lib_deps = ${t1000-e.lib_deps} stevemarple/MicroNMEA @ ^2.0.6 + +[env:t1000e_sensor] +extends = t1000-e +build_flags = ${t1000-e.build_flags} + -I examples/companion_radio/ui-orig + -D ADVERT_NAME='"t1000-e Sensor"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +; -D POWERSAVING_DEBUG=1 +build_src_filter = ${t1000-e.build_src_filter} + +<../examples/simple_sensor> +lib_deps = ${t1000-e.lib_deps} + stevemarple/MicroNMEA @ ^2.0.6 diff --git a/variants/thinknode_m1/ThinkNodeM1Board.h b/variants/thinknode_m1/ThinkNodeM1Board.h index 94224d9c2b9..2bf78b709e5 100644 --- a/variants/thinknode_m1/ThinkNodeM1Board.h +++ b/variants/thinknode_m1/ThinkNodeM1Board.h @@ -32,7 +32,8 @@ class ThinkNodeM1Board : public NRF52Board { return "Elecrow ThinkNode-M1"; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); // turn off all leds, sd_power_system_off will not do this for us #ifdef P_LORA_TX_LED @@ -40,6 +41,5 @@ class ThinkNodeM1Board : public NRF52Board { #endif // power off board - NRF52Board::powerOff(); } }; diff --git a/variants/thinknode_m3/ThinkNodeM3Board.h b/variants/thinknode_m3/ThinkNodeM3Board.h index 9e8f49894d2..1e8dd32a448 100644 --- a/variants/thinknode_m3/ThinkNodeM3Board.h +++ b/variants/thinknode_m3/ThinkNodeM3Board.h @@ -42,13 +42,13 @@ class ThinkNodeM3Board : public NRF52BoardDCDC { return 0; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); // turn off all leds, sd_power_system_off will not do this for us digitalWrite(PIN_LED_BLUE, !LED_STATE_ON); digitalWrite(PIN_LED_GREEN, !LED_STATE_ON); digitalWrite(PIN_LED_RED, !LED_STATE_ON); // power off board - NRF52Board::powerOff(); } }; diff --git a/variants/thinknode_m6/ThinkNodeM6Board.h b/variants/thinknode_m6/ThinkNodeM6Board.h index 934794c9af4..1c8d70f4d20 100644 --- a/variants/thinknode_m6/ThinkNodeM6Board.h +++ b/variants/thinknode_m6/ThinkNodeM6Board.h @@ -65,7 +65,8 @@ class ThinkNodeM6Board : public NRF52BoardDCDC { return isSolarChargerActive() || isUsbPowered(); } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); // turn off all leds, sd_power_system_off will not do this for us #ifdef P_LORA_TX_LED @@ -73,6 +74,5 @@ class ThinkNodeM6Board : public NRF52BoardDCDC { #endif // power off board - NRF52Board::powerOff(); } }; diff --git a/variants/thinknode_m6/platformio.ini b/variants/thinknode_m6/platformio.ini index ad7e6902cf0..4ed0848d1ae 100644 --- a/variants/thinknode_m6/platformio.ini +++ b/variants/thinknode_m6/platformio.ini @@ -82,7 +82,7 @@ build_flags = -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D BLE_PIN_CODE=123456 - -D BLE_DEBUG_LOGGING=1 +; -D BLE_DEBUG_LOGGING=1 -D OFFLINE_QUEUE_SIZE=256 -D AUTO_SHUTDOWN_MILLIVOLTS=3300 ; -D MESH_PACKET_LOGGING=1 diff --git a/variants/wio-tracker-l1-1w/WioTrackerL1Board.h b/variants/wio-tracker-l1-1w/WioTrackerL1Board.h index f6164e47af1..4136d1feaaf 100644 --- a/variants/wio-tracker-l1-1w/WioTrackerL1Board.h +++ b/variants/wio-tracker-l1-1w/WioTrackerL1Board.h @@ -41,8 +41,8 @@ class WioTrackerL1Board : public NRF52BoardDCDC { return "Seeed Wio Tracker L1"; } - void powerOff() override { + void shutdownPeripherals() override { // powerOff() and timed enterDeepSleep() both come here setLoRaFemPaGainEnabled(false); // turn Off PA - NRF52Board::powerOff(); + NRF52Board::shutdownPeripherals(); } }; diff --git a/variants/wio-tracker-l1/WioTrackerL1Board.h b/variants/wio-tracker-l1/WioTrackerL1Board.h index 99e852bb7d2..f41beb6ef08 100644 --- a/variants/wio-tracker-l1/WioTrackerL1Board.h +++ b/variants/wio-tracker-l1/WioTrackerL1Board.h @@ -34,7 +34,4 @@ class WioTrackerL1Board : public NRF52BoardDCDC { return "Seeed Wio Tracker L1"; } - void powerOff() override { - NRF52Board::powerOff(); - } }; diff --git a/variants/wio-tracker-l1/platformio.ini b/variants/wio-tracker-l1/platformio.ini index 19139c289eb..6c39ae6d311 100644 --- a/variants/wio-tracker-l1/platformio.ini +++ b/variants/wio-tracker-l1/platformio.ini @@ -36,6 +36,8 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D DISPLAY_CLASS=SH1106Display + -D ENV_PIN_SDA=PIN_WIRE1_SDA + -D ENV_PIN_SCL=PIN_WIRE1_SCL ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 lib_deps = ${WioTrackerL1.lib_deps} @@ -50,6 +52,8 @@ build_flags = ${WioTrackerL1.build_flags} -D ADMIN_PASSWORD='"password"' -D ROOM_PASSWORD='"hello"' -D DISPLAY_CLASS=SH1106Display + -D ENV_PIN_SDA=PIN_WIRE1_SDA + -D ENV_PIN_SCL=PIN_WIRE1_SCL ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 lib_deps = ${WioTrackerL1.lib_deps} diff --git a/variants/wio-tracker-l2/GpsTap.h b/variants/wio-tracker-l2/GpsTap.h new file mode 100644 index 00000000000..020f5e9b185 --- /dev/null +++ b/variants/wio-tracker-l2/GpsTap.h @@ -0,0 +1,139 @@ +#pragma once + +#include + +// Transparent wrapper around the GNSS UART. MicroNMEA ignores GSV sentences, +// so this watches the byte stream for them and keeps a running count of +// satellites in view, strong satellites and best SNR, which separates RF +// faults from a receiver that only lacks sky view. It also records the +// fix flags straight from GGA/RMC so a parser problem upstream can't hide a +// real fix, and can hand every complete line to a logger (SD card). +class GpsTapStream : public Stream { + Stream& _src; + char _line[120]; + uint8_t _len = 0; + struct Talker { char id[3]; uint8_t in_view; uint8_t strong; uint8_t strong_acc; unsigned long at; }; + Talker _talkers[6] = {}; + int _best_snr = 0; + unsigned long _best_at = 0; + unsigned long _last_gsv = 0; + int _gga_fix = -1; // GGA fix quality (0 = no fix) + int _gga_used = 0; // GGA satellites used + char _rmc_status = '?'; // RMC status: A = valid, V = void + unsigned long _last_fix_sentence = 0; + + static const unsigned long FRESH_MS = 6000; + static const int STRONG_DB = 30; + + // pointer to comma-separated field n (0 = sentence id), NULL if absent + static const char* field(const char* s, int n) { + const char* p = s; + for (int i = 0; i < n; i++) { + p = strchr(p, ','); + if (p == NULL) return NULL; + p++; + } + return p; + } + + void parseGsv(const char* s) { + // $GPGSV,total,msg,in_view,prn,elev,az,snr,...*cs + char talker[3] = {s[1], s[2], 0}; + const char* f; + int total = (f = field(s, 1)) ? atoi(f) : 0; + int msg = (f = field(s, 2)) ? atoi(f) : 0; + int in_view = (f = field(s, 3)) ? atoi(f) : -1; + if (in_view < 0) return; + + int strong_here = 0; + for (int k = 0; k < 4; k++) { + const char* snr = field(s, 7 + 4 * k); + if (snr == NULL || *snr == ',' || *snr == '*' || *snr == 0) continue; + int v = atoi(snr); + if (v >= STRONG_DB) strong_here++; + if (v > _best_snr || millis() - _best_at > FRESH_MS) { _best_snr = v; _best_at = millis(); } + } + + int slot = -1; + for (int i = 0; i < 6; i++) { + if (strcmp(_talkers[i].id, talker) == 0) { slot = i; break; } + if (slot < 0 && _talkers[i].id[0] == 0) slot = i; + } + if (slot < 0) return; + Talker& t = _talkers[slot]; + memcpy(t.id, talker, 3); + t.in_view = (uint8_t) in_view; + if (msg <= 1) t.strong_acc = 0; + t.strong_acc += strong_here; + if (msg >= total) t.strong = t.strong_acc; + t.at = millis(); + _last_gsv = millis(); + } + + void parseFixFlags(const char* s) { + const char* type = s + 3; + if (strncmp(type, "GGA", 3) == 0) { + const char* f; + _gga_fix = (f = field(s, 6)) && *f != ',' ? atoi(f) : 0; + _gga_used = (f = field(s, 7)) && *f != ',' ? atoi(f) : 0; + _last_fix_sentence = millis(); + } else if (strncmp(type, "RMC", 3) == 0) { + const char* f = field(s, 2); + _rmc_status = (f != NULL && *f != ',' && *f != 0) ? *f : '?'; + _last_fix_sentence = millis(); + } + } + + void feed(int c) { + if (echo) Serial.write((uint8_t) c); + if (c == '\n' || c == '\r') { + if (_len > 6 && _line[0] == '$') { + _line[_len] = 0; + if (on_line != NULL) on_line(_line); + if (strncmp(_line + 3, "GSV", 3) == 0) parseGsv(_line); + else parseFixFlags(_line); + } + _len = 0; + } else if (_len < sizeof(_line) - 1) { + _line[_len++] = (char) c; + } + } + +public: + bool echo = false; // mirror raw NMEA to USB Serial + void (*on_line)(const char* line) = NULL; // complete-sentence hook (e.g. SD log) + + explicit GpsTapStream(Stream& src) : _src(src) {} + + int satsInView() const { + int n = 0; + for (int i = 0; i < 6; i++) { + if (_talkers[i].id[0] != 0 && millis() - _talkers[i].at < FRESH_MS) n += _talkers[i].in_view; + } + return n; + } + int strongSats() const { + int n = 0; + for (int i = 0; i < 6; i++) { + if (_talkers[i].id[0] != 0 && millis() - _talkers[i].at < FRESH_MS) n += _talkers[i].strong; + } + return n; + } + int bestSnr() const { return millis() - _best_at < FRESH_MS ? _best_snr : 0; } + bool streaming() const { return millis() - _last_gsv < FRESH_MS; } + int ggaFix() const { return _gga_fix; } + int ggaUsed() const { return _gga_used; } + char rmcStatus() const { return _rmc_status; } + + // Stream interface: pass-through with a tap on read() + int available() override { return _src.available(); } + int peek() override { return _src.peek(); } + int read() override { + int c = _src.read(); + if (c >= 0) feed(c); + return c; + } + void flush() override { _src.flush(); } + size_t write(uint8_t b) override { return _src.write(b); } + size_t write(const uint8_t* buf, size_t n) override { return _src.write(buf, n); } +}; diff --git a/variants/wio-tracker-l2/README.md b/variants/wio-tracker-l2/README.md new file mode 100644 index 00000000000..26d1e4a9c9f --- /dev/null +++ b/variants/wio-tracker-l2/README.md @@ -0,0 +1,65 @@ +# MeshCore for Seeed Wio Tracker L2 Pro + +MeshCore port for the **Seeed Studio Wio Tracker L2 Pro** (pre-release hardware; +pin map may change on production units). + +## Hardware + +| Component | Detail | +|-----------|--------| +| MCU module | Wio-S3: ESP32-S3, 16 MB flash (QIO), 8 MB PSRAM (OPI) | +| LoRa | SX1262, 862-930 MHz (SCK 4, MISO 5, MOSI 6, CS 21, RST 7, BUSY 8, DIO1 9, DIO2 = RF switch, TCXO 1.8 V) | +| GNSS | Quectel L76K (GPS/BeiDou/GLONASS/QZSS), UART: module TX to GPIO 18, module RX from GPIO 17, 9600 baud | +| Display | 3.2" 320x240 NV3031B on quad-SPI (SCLK 42, IO0-3 = 41/40/39/38, CS 46), 75 MHz | +| Touch | GT911 capacitive, I2C 0x5D | +| Backlight | LP5814 LED driver, I2C 0x2C | +| I/O expander | TCA9535, I2C 0x21 - gates power/reset for GNSS, LCD, touch, SD, speaker amp, battery ADC, Grove | +| Battery ADC | ADS1115, I2C 0x48, AIN0 through x2 divider | +| USB-C detect | AW35615 CC controller, I2C 0x22 (charge detection) | +| Audio | ES8311 codec (speaker, alert tones) + ES7243E mic ADC (unused) on I2S | +| I2C bus | SDA 47, SCL 48 | +| Buttons | User/Boot = GPIO 0; Wake/lock button on expander P00 | +| SD card | SDIO 1-bit: CLK 2, CMD 3, D0 1 - offline map tiles, logs | + +## Firmware flavors + +| Env | What it does | +|-----|--------------| +| `Wio_Tracker_L2_companion_radio_ble` | **Phone companion.** Standard display UI, MeshCore app over BLE (pairing PIN `123456`), on-screen Bluetooth toggle page. | +| `Wio_Tracker_L2_companion_radio_usb` | Standard display companion over USB-C serial. | +| `Wio_Tracker_L2_repeater` | Standalone mesh repeater. | +| `Wio_Tracker_L2_room_server` | Standalone room/BBS server. | + +## Build & flash + +```bash +# from the MeshCore repo root +pio run -e Wio_Tracker_L2_companion_radio_ble + +# bootloader mode if needed: hold User/Boot, tap RST, release - then: +pio run -e Wio_Tracker_L2_companion_radio_ble -t upload +``` + +The board enumerates as a native USB-CDC port (auto-reset via 1200-bps touch +is enabled). For the BLE companion build, pair from the MeshCore app with the +PIN shown on the device screen. + +## Port notes + +- **Everything hangs off the TCA9535 expander.** `WioTrackerL2Board::begin()` + replays Seeed's power-up sequence; order and delays matter (especially the + 500 ms LCD reset settle). If the expander probe fails, the firmware still + runs but GPS/display/battery reads are dead. +- **Battery** is read via the ADS1115 at +/-4.096 V FSR and mapped through a + LiPo discharge curve. +- **GNSS**: GPS+BeiDou+GLONASS are enabled at start (`$PCAS04,7`). A watchdog + resets the module if it tracks 4+ strong satellites for minutes without + producing a fix. Raw NMEA can be logged to SD for diagnosis. +- **Power**: CPU drops to 80 MHz while the screen sleeps; the speaker amp and + I2S clocks run only while a tone plays; the GNSS and Grove rails are gated; + idle Bluetooth switches to slow advertising. +- **Display orientation**: `offset_rotation=1` (landscape). If a unit shows + the UI upside-down, use 3 in `WioTrackerL2Display.h`. +- Pin map source: `meshtastic/firmware` [PR #10909](https://github.com/meshtastic/firmware/pull/10909), + since merged as [`variants/esp32s3/seeed_wio_tracker_L2`](https://github.com/meshtastic/firmware/tree/master/variants/esp32s3/seeed_wio_tracker_L2), + and the `meshtastic/device-ui` [`wio-l2` branch](https://github.com/meshtastic/device-ui/tree/wio-l2). diff --git a/variants/wio-tracker-l2/Sound.cpp b/variants/wio-tracker-l2/Sound.cpp new file mode 100644 index 00000000000..b84f4a77195 --- /dev/null +++ b/variants/wio-tracker-l2/Sound.cpp @@ -0,0 +1,157 @@ +#include "Sound.h" + +#include + +#include +#include +#include +#include +#include "AudioBoard.h" // pschatzmann/arduino-audio-driver +#include +#include + +// Wio-S3 audio wiring: ES8311 codec on I2C (0x18), I2S MCLK 10 / BCK 11 / +// WS 12 / DOUT 16; speaker amp power (PA EN) is raised by the board init. +#define SND_I2S_MCLK 10 +#define SND_I2S_BCK 11 +#define SND_I2S_WS 12 +#define SND_I2S_DOUT 16 +#define SND_SAMPLE_RATE 44100 + +static DriverPins snd_pins; +static AudioBoard snd_codec(AudioDriverES8311, snd_pins); +static bool snd_ready = false; +static void (*snd_amp_ctl)(bool) = NULL; // board hook: speaker amp power + +void soundSetAmpControl(void (*fn)(bool on)) { snd_amp_ctl = fn; } + +void soundInit() { + // i2s_driver_install crashes inside IDF's cleanup if its DMA allocation + // fails (LoadProhibited boot loop) - refuse to try without clear headroom + if (heap_caps_get_free_size(MALLOC_CAP_DMA) < 60000) { + MESH_DEBUG_PRINTLN("sound: low DMA heap - sound disabled this boot"); + return; + } + + snd_pins.addI2C(PinFunction::CODEC, Wire); + snd_pins.addI2S(PinFunction::CODEC, SND_I2S_MCLK, SND_I2S_BCK, SND_I2S_WS, SND_I2S_DOUT, -1); + + CodecConfig cfg; + cfg.input_device = ADC_INPUT_NONE; + cfg.output_device = DAC_OUTPUT_ALL; + cfg.i2s.bits = BIT_LENGTH_16BITS; + cfg.i2s.rate = RATE_44K; + if (!snd_codec.begin(cfg)) { + MESH_DEBUG_PRINTLN("sound: ES8311 init failed - sound disabled"); + return; + } + snd_codec.setVolume(70); + + i2s_config_t i2s_cfg = {}; + i2s_cfg.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX); + i2s_cfg.sample_rate = SND_SAMPLE_RATE; + i2s_cfg.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT; + i2s_cfg.channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT; + i2s_cfg.communication_format = I2S_COMM_FORMAT_STAND_I2S; + i2s_cfg.intr_alloc_flags = 0; + i2s_cfg.dma_buf_count = 2; // keep the DMA footprint minimal + i2s_cfg.dma_buf_len = 256; + i2s_cfg.tx_desc_auto_clear = true; + if (i2s_driver_install(I2S_NUM_0, &i2s_cfg, 0, NULL) != ESP_OK) { + MESH_DEBUG_PRINTLN("sound: i2s driver install failed"); + return; + } + i2s_pin_config_t pin_cfg = {}; + pin_cfg.mck_io_num = SND_I2S_MCLK; + pin_cfg.bck_io_num = SND_I2S_BCK; + pin_cfg.ws_io_num = SND_I2S_WS; + pin_cfg.data_out_num = SND_I2S_DOUT; + pin_cfg.data_in_num = I2S_PIN_NO_CHANGE; + if (i2s_set_pin(I2S_NUM_0, &pin_cfg) != ESP_OK) { + MESH_DEBUG_PRINTLN("sound: i2s pin config failed"); + return; + } + i2s_stop(I2S_NUM_0); // clocks off until a tone plays (GNSS-adjacent EMI, battery) + snd_ready = true; + MESH_DEBUG_PRINTLN("sound: ES8311 + I2S ready"); +} + +bool soundReady() { return snd_ready; } + +void soundBeep(int freq_hz, int duration_ms) { + if (!snd_ready) return; + if (snd_amp_ctl) snd_amp_ctl(true); + i2s_start(I2S_NUM_0); + delay(3); // amp settle before the first samples + const int total = SND_SAMPLE_RATE * duration_ms / 1000; + const float amp = 6000.0f; // gentle level; codec volume does the rest + static int16_t buf[256 * 2]; // 256 stereo frames per chunk + + int written_frames = 0; + while (written_frames < total) { + int chunk = total - written_frames; + if (chunk > 256) chunk = 256; + for (int i = 0; i < chunk; i++) { + float t = (float)(written_frames + i) / SND_SAMPLE_RATE; + // short attack/decay envelope so the beep doesn't click + float env = 1.0f; + int pos = written_frames + i; + if (pos < 220) env = pos / 220.0f; + else if (pos > total - 220) env = (total - pos) / 220.0f; + int16_t s = (int16_t)(amp * env * sinf(2.0f * (float)M_PI * freq_hz * t)); + buf[i * 2] = s; + buf[i * 2 + 1] = s; + } + size_t written_bytes = 0; + i2s_write(I2S_NUM_0, buf, chunk * 2 * sizeof(int16_t), &written_bytes, portMAX_DELAY); + written_frames += chunk; + } + i2s_zero_dma_buffer(I2S_NUM_0); + i2s_stop(I2S_NUM_0); + if (snd_amp_ctl) snd_amp_ctl(false); +} + +// selectable alert melodies; {0,0} terminates a style early +struct ToneNote { uint16_t freq; uint16_t ms; }; +static const ToneNote TONE_STYLES[SOUND_TONE_STYLES][5] = { + {{880, 90}, {1175, 120}, {0, 0}}, // Classic + {{1400, 40}, {1800, 40}, {2200, 60}, {0, 0}}, // Chirp + {{1319, 140}, {988, 180}, {0, 0}}, // Ding dong + {{1568, 45}, {1245, 45}, {1568, 45}, {1245, 90}, {0, 0}}, // Trill + {{659, 70}, {880, 70}, {1175, 70}, {1568, 110}, {0, 0}}, // Rise + {{1047, 120}, {523, 120}, {1047, 120}, {0, 0}}, // Alarm +}; +static int tone_style = 0; + +void soundLoadTonePref() { + File f = SPIFFS.open("/tone", "r"); + if (f) { + int v = f.parseInt(); + f.close(); + if (v >= 0 && v < SOUND_TONE_STYLES) tone_style = v; + } +} + +void soundSetToneStyle(int style) { + if (style < 0 || style >= SOUND_TONE_STYLES) return; + tone_style = style; + File f = SPIFFS.open("/tone", "w"); + if (f) { + f.print(style); + f.close(); + } +} + +int soundGetToneStyle() { return tone_style; } + +void soundMessageTone() { + const ToneNote* n = TONE_STYLES[tone_style]; + for (int i = 0; i < 5 && n[i].freq != 0; i++) soundBeep(n[i].freq, n[i].ms); +} + +void soundChannelTone() { + // softer cousin of the chosen alert: first note, dropped a fourth + soundBeep(TONE_STYLES[tone_style][0].freq * 3 / 4, 100); +} + +void soundAckTone() { soundBeep(1568, 50); } diff --git a/variants/wio-tracker-l2/Sound.h b/variants/wio-tracker-l2/Sound.h new file mode 100644 index 00000000000..9e5f633c0bd --- /dev/null +++ b/variants/wio-tracker-l2/Sound.h @@ -0,0 +1,19 @@ +#pragma once + +// Board audio backend for the Wio Tracker L2 Pro (ES8311 codec + speaker). +// UI code includes "Sound.h" and gets whatever the variant provides; all +// functions are safe no-ops if the codec failed to initialize. + +#define SOUND_TONE_STYLES 6 // Classic, Chirp, Ding dong, Trill, Rise, Alarm + +void soundInit(); // call once after Wire is up (board.begin done) +void soundSetAmpControl(void (*fn)(bool on)); // board hook to gate the speaker amp power +bool soundReady(); +void soundBeep(int freq_hz, int duration_ms); // short blocking tone +void soundMessageTone(); // incoming DM (plays the selected alert style) +void soundChannelTone(); // incoming channel message (softer variant) +void soundAckTone(); // delivery ack / confirm + +void soundLoadTonePref(); // read /tone from SPIFFS (safe before soundInit) +void soundSetToneStyle(int style); // select + persist an alert style +int soundGetToneStyle(); diff --git a/variants/wio-tracker-l2/WioTrackerL2Board.cpp b/variants/wio-tracker-l2/WioTrackerL2Board.cpp new file mode 100644 index 00000000000..edeaacce52e --- /dev/null +++ b/variants/wio-tracker-l2/WioTrackerL2Board.cpp @@ -0,0 +1,204 @@ +#include "WioTrackerL2Board.h" + +// TCA9535 register map +static const uint8_t TCA_REG_OUTPUT0 = 0x02; +static const uint8_t TCA_REG_OUTPUT1 = 0x03; +static const uint8_t TCA_REG_CONFIG0 = 0x06; // 1 = input, 0 = output +static const uint8_t TCA_REG_CONFIG1 = 0x07; + +// ADS1115 registers / config bits +static const uint8_t ADS_REG_CONVERSION = 0x00; +static const uint8_t ADS_REG_CONFIG = 0x01; +// OS=1 (start single shot) | MUX=100 (AIN0 vs GND) | PGA=001 (+/-4.096V) +// | MODE=1 (single shot) | DR=100 (128 SPS) | COMP_QUE=11 (disabled) +static const uint16_t ADS_CFG_BATT = 0x8000 | 0x4000 | 0x0200 | 0x0100 | 0x0080 | 0x0003; +// +/-4.096V FSR -> 125uV/LSB; battery is behind a x2 divider -> 0.25 mV/LSB +static const float ADS_MV_PER_LSB = 0.125f * 2.0f; + +bool WioTrackerL2Board::expWriteReg(uint8_t reg, uint8_t val) { + Wire.beginTransmission(TCA9535_ADDR); + Wire.write(reg); + Wire.write(val); + return Wire.endTransmission() == 0; +} + +void WioTrackerL2Board::expSetOutput(uint8_t pin, bool initial_level) { + uint8_t port = pin >> 3, bit = pin & 7; + // set desired level first so the pin doesn't glitch when direction flips + if (initial_level) out_shadow[port] |= (1 << bit); + else out_shadow[port] &= ~(1 << bit); + expWriteReg(port ? TCA_REG_OUTPUT1 : TCA_REG_OUTPUT0, out_shadow[port]); + + cfg_shadow[port] &= ~(1 << bit); // 0 = output + expWriteReg(port ? TCA_REG_CONFIG1 : TCA_REG_CONFIG0, cfg_shadow[port]); +} + +void WioTrackerL2Board::expSetInput(uint8_t pin) { + uint8_t port = pin >> 3, bit = pin & 7; + cfg_shadow[port] |= (1 << bit); + expWriteReg(port ? TCA_REG_CONFIG1 : TCA_REG_CONFIG0, cfg_shadow[port]); +} + +void WioTrackerL2Board::expWritePin(uint8_t pin, bool level) { + uint8_t port = pin >> 3, bit = pin & 7; + if (level) out_shadow[port] |= (1 << bit); + else out_shadow[port] &= ~(1 << bit); + expWriteReg(port ? TCA_REG_OUTPUT1 : TCA_REG_OUTPUT0, out_shadow[port]); +} + +// Power-up sequence mirrors Seeed's reference firmware for this board +// (order and delays matter, especially the LCD reset settle time). +bool WioTrackerL2Board::initExpander() { + Wire.beginTransmission(TCA9535_ADDR); + if (Wire.endTransmission() != 0) { + return false; // expander not responding + } + + expSetInput(EXP_PIN_WAKE_BTN); + expSetInput(EXP_PIN_I2C_IRQ); + expSetInput(EXP_PIN_SD_DETECT); + + expSetOutput(EXP_PIN_OTG_EN, LOW); + delay(10); + expSetOutput(EXP_PIN_PA_EN, LOW); // amp only powered while a tone plays + delay(10); + expSetOutput(EXP_PIN_TF_EN, HIGH); + delay(10); + expSetOutput(EXP_PIN_BAT_ADC_EN, HIGH); + delay(10); + expSetOutput(EXP_PIN_GNSS_EN, HIGH); + delay(10); + // GNSS reset is active HIGH; hold 10ms then release LOW so the L76K runs + expSetOutput(EXP_PIN_GNSS_RST, HIGH); + delay(10); + expWritePin(EXP_PIN_GNSS_RST, LOW); + // user LED is driven active-low through the expander; idle level HIGH = off + expSetOutput(EXP_PIN_USER_LED, HIGH); + delay(10); + expSetOutput(EXP_PIN_GROVE_EN, HIGH); + delay(10); + + expSetOutput(EXP_PIN_LCD_EN, HIGH); + delay(50); + expSetOutput(EXP_PIN_LCD_RST, HIGH); + delay(5); + expWritePin(EXP_PIN_LCD_RST, LOW); + delay(10); + expWritePin(EXP_PIN_LCD_RST, HIGH); + delay(500); // NV3031B needs a long settle after reset before init commands + expSetOutput(EXP_PIN_LCD_CS, HIGH); + delay(10); + + // GT911 touch reset sequence: INT low during reset selects I2C addr 0x5D + expSetOutput(EXP_PIN_TP_RST, LOW); + expSetOutput(EXP_PIN_TP_INT, LOW); + delay(10); + expWritePin(EXP_PIN_TP_RST, HIGH); + delay(60); + + // capture WAKE button idle level (polarity unknown on alpha hardware) + int inputs = expReadInputs(); + wake_btn_baseline = inputs >= 0 ? (uint8_t)(inputs & 1) : 0; + + return true; +} + +void WioTrackerL2Board::begin() { + // GNSS UART: NMEA arrives at ~500 B/s and the main loop can stall for + // hundreds of ms on SD tile decodes; the 256-byte default overflowed + Serial1.setRxBufferSize(1024); + + uint32_t t0 = millis(); + while (!Serial && millis() - t0 < 3000) { delay(50); } + MESH_DEBUG_PRINTLN("WioTrackerL2Board: ESP32Board init"); + + ESP32Board::begin(); // starts Wire on PIN_BOARD_SDA/PIN_BOARD_SCL (47/48) + + pinMode(PIN_USER_BTN, INPUT_PULLUP); + pinMode(P_LORA_MISO, INPUT_PULLUP); + + MESH_DEBUG_PRINTLN("WioTrackerL2Board: TCA9535 expander init"); + expander_ok = initExpander(); + if (!expander_ok) { + Serial.println("ERROR: TCA9535 IO expander not found - peripherals unpowered!"); + } + + Wire.beginTransmission(0x22); + aw_ok = Wire.endTransmission() == 0; + MESH_DEBUG_PRINTLN("WioTrackerL2Board: init done"); + + esp_reset_reason_t reason = esp_reset_reason(); + if (reason == ESP_RST_DEEPSLEEP) { + long wakeup_source = esp_sleep_get_ext1_wakeup_status(); + if (wakeup_source & (1 << P_LORA_DIO_1)) { + startup_reason = BD_STARTUP_RX_PACKET; // LoRa packet woke us from deep sleep + } + rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); + rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + } +} + +void WioTrackerL2Board::setLed(bool on) { + // P10 doubles as GNSS wakeup and idles HIGH; blink = brief LOW pulses so + // the GPS never sees a sustained low level + if (expander_ok) { + expWritePin(EXP_PIN_USER_LED, !on); + } +} + +int WioTrackerL2Board::expReadInputs() { + Wire.beginTransmission(TCA9535_ADDR); + Wire.write((uint8_t)0x00); // input port 0 register + if (Wire.endTransmission() != 0) return -1; + if (Wire.requestFrom((int)TCA9535_ADDR, 2) != 2) return -1; + int lo = Wire.read(); + int hi = Wire.read(); + return lo | (hi << 8); +} + +bool WioTrackerL2Board::isExternalPowered() { + if (!aw_ok) return false; + Wire.beginTransmission(0x22); + Wire.write((uint8_t)0x40); // STATUS0 + if (Wire.endTransmission() != 0) return false; + if (Wire.requestFrom(0x22, 1) != 1) return false; + return (Wire.read() & 0x80) != 0; // VBUSOK bit +} + +bool WioTrackerL2Board::readWakeButton() { + if (!expander_ok) return false; + int v = expReadInputs(); + if (v < 0) return false; + return ((uint8_t)(v & 1)) != wake_btn_baseline; +} + +int16_t WioTrackerL2Board::adsReadRaw() { + Wire.beginTransmission(ADS1115_ADDR); + Wire.write(ADS_REG_CONFIG); + Wire.write((uint8_t)(ADS_CFG_BATT >> 8)); + Wire.write((uint8_t)(ADS_CFG_BATT & 0xFF)); + if (Wire.endTransmission() != 0) return -1; + + delay(10); // 128 SPS -> ~8ms conversion time + + Wire.beginTransmission(ADS1115_ADDR); + Wire.write(ADS_REG_CONVERSION); + if (Wire.endTransmission() != 0) return -1; + if (Wire.requestFrom((int)ADS1115_ADDR, 2) != 2) return -1; + + // sequence the two reads explicitly: operand evaluation order of <<| + // is unspecified and each read dequeues a byte + uint8_t hi = Wire.read(); + uint8_t lo = Wire.read(); + int16_t raw = ((int16_t)hi << 8) | lo; + return raw < 0 ? 0 : raw; +} + +uint16_t WioTrackerL2Board::getBattMilliVolts() { + if (!expander_ok) return 0; // BAT_ADC_EN rail never came up + + int16_t raw = adsReadRaw(); + if (raw < 0) return 0; + + return (uint16_t)(raw * ADS_MV_PER_LSB); +} diff --git a/variants/wio-tracker-l2/WioTrackerL2Board.h b/variants/wio-tracker-l2/WioTrackerL2Board.h new file mode 100644 index 00000000000..c5644e2f204 --- /dev/null +++ b/variants/wio-tracker-l2/WioTrackerL2Board.h @@ -0,0 +1,106 @@ +#pragma once + +#include +#include +#include "helpers/ESP32Board.h" + +// --------------------------------------------------------------------------- +// Seeed Wio Tracker L2 Pro (Wio-S3 module: ESP32-S3 + SX1262) +// +// Nearly all peripheral power rails and reset lines are behind a TCA9535 +// 16-bit I2C IO expander at address 0x21. Nothing (GNSS, LCD, touch, SD, +// battery ADC, speaker amp) works until the expander is configured, so +// begin() must run before any display / GPS / sensor init. +// +// Battery voltage is read through an ADS1115 16-bit I2C ADC at 0x48, +// channel AIN0, behind a x2 resistive divider (rail gated by expander). +// --------------------------------------------------------------------------- + +#define TCA9535_ADDR 0x21 +#define ADS1115_ADDR 0x48 + +// TCA9535 pin numbering: 0..7 = port 0 (P00..P07), 8..15 = port 1 (P10..P17) +#define EXP_PIN_WAKE_BTN 0 // input - side WAKE button +#define EXP_PIN_I2C_IRQ 1 // input - shared I2C IRQ +#define EXP_PIN_SD_DETECT 2 // input - microSD card detect +#define EXP_PIN_TP_INT 3 // output - touch panel interrupt (driven for reset seq) +#define EXP_PIN_LCD_CS 4 // output - LCD chip select (idle high) +#define EXP_PIN_LCD_EN 5 // output - LCD power enable +#define EXP_PIN_LCD_RST 6 // output - LCD reset +#define EXP_PIN_GROVE_EN 7 // output - Grove port power +#define EXP_PIN_TP_RST 8 // output - touch panel reset +#define EXP_PIN_GNSS_RST 9 // output - GNSS reset (active HIGH) +#define EXP_PIN_USER_LED 10 // output - mesh/user LED (also GNSS wakeup) +#define EXP_PIN_OTG_EN 11 // output - USB OTG power +#define EXP_PIN_PA_EN 12 // output - speaker amp power +#define EXP_PIN_GNSS_EN 13 // output - GNSS power +#define EXP_PIN_TF_EN 14 // output - microSD power +#define EXP_PIN_BAT_ADC_EN 15 // output - battery ADC divider enable + +class WioTrackerL2Board : public ESP32Board { +public: + void begin(); + + uint16_t getBattMilliVolts() override; + + const char* getManufacturerName() const override { + return "Seeed Wio Tracker L2 Pro"; + } + + void onBeforeTransmit() override { setLed(true); } + void onAfterTransmit() override { setLed(false); } + + // Mesh/user LED lives on the IO expander, not a GPIO + void setLed(bool on); + + // speaker amplifier power (expander P12); off when idle to keep the GNSS + // antenna away from class-D switching noise and to save battery + void setSpeakerAmp(bool on) { expWritePin(EXP_PIN_PA_EN, on); } + + // hardware reset pulse to the L76K GNSS (active HIGH on this board); the + // module keeps almanac/ephemeris across it, so this is a warm restart + // GNSS rail: off saves the whole receiver when GPS is disabled; on re-runs + // the power-up reset so the module comes back cleanly + void setGnssPower(bool on) { + if (on) { + expWritePin(EXP_PIN_GNSS_EN, HIGH); + delay(10); + gnssReset(); + } else { + expWritePin(EXP_PIN_GNSS_EN, LOW); + } + } + // Grove expansion port rail (nothing on-board depends on it) + void setGrovePower(bool on) { expWritePin(EXP_PIN_GROVE_EN, on); } + + void gnssReset() { + expWritePin(EXP_PIN_GNSS_RST, HIGH); + delay(10); + expWritePin(EXP_PIN_GNSS_RST, LOW); + } + + // WAKE button on expander P00: pressed = level differs from boot baseline + bool readWakeButton(); + + // VBUS presence via the AW35615 USB-C controller (I2C 0x22) + bool isExternalPowered() override; + + bool expanderOK() const { return expander_ok; } + +private: + uint8_t out_shadow[2] = { 0xFF, 0xFF }; // TCA9535 output regs default high + uint8_t cfg_shadow[2] = { 0xFF, 0xFF }; // 1 = input (power-on default) + bool expander_ok = false; + bool aw_ok = false; // AW35615 USB-C controller responded at probe + uint8_t wake_btn_baseline = 0; // idle level of P00, captured at init + + int expReadInputs(); // 16-bit input register pair, -1 on error + + bool expWriteReg(uint8_t reg, uint8_t val); + void expSetOutput(uint8_t pin, bool initial_level); + void expSetInput(uint8_t pin); + void expWritePin(uint8_t pin, bool level); + bool initExpander(); + + int16_t adsReadRaw(); +}; diff --git a/variants/wio-tracker-l2/WioTrackerL2Display.h b/variants/wio-tracker-l2/WioTrackerL2Display.h new file mode 100644 index 00000000000..f48332b4695 --- /dev/null +++ b/variants/wio-tracker-l2/WioTrackerL2Display.h @@ -0,0 +1,214 @@ +#pragma once + +#include +#include + +#define LGFX_USE_V1 +#include + +// --------------------------------------------------------------------------- +// Wio Tracker L2 Pro display stack: +// - NV3031B 320x240 panel on quad-SPI (SCLK 42, IO0-3 = 41/40/39/38, CS 46) +// - GT911 capacitive touch on I2C 0x5D (SDA 47 / SCL 48) +// - LP5814 4-channel LED driver on I2C 0x2C used as backlight +// Panel power/reset lines are on the TCA9535 expander and are already +// sequenced by WioTrackerL2Board::begin() before this driver initializes. +// --------------------------------------------------------------------------- + +#ifndef L2_SPI_FREQUENCY + #define L2_SPI_FREQUENCY 75000000 +#endif + +#define LP5814_I2C_ADDR 0x2C + +// LP5814 used as backlight controller (all 4 channels in parallel) +class WioTrackerL2Backlight : public lgfx::v1::ILight { + static constexpr uint8_t REG_DEVICE_CONFIG0 = 0x00; + static constexpr uint8_t REG_MAX_CURRENT = 0x01; + static constexpr uint8_t REG_ENABLE_CONTROL = 0x02; + static constexpr uint8_t REG_DIM_MODE = 0x04; + static constexpr uint8_t REG_ENGINE_MODE = 0x05; + static constexpr uint8_t REG_UPDATE = 0x0F; + static constexpr uint8_t REG_LED0_DC = 0x14; + static constexpr uint8_t REG_LED0_PWM = 0x18; + + uint8_t _brightness = 153; // 60% + + void writeReg(uint8_t reg, uint8_t value) { + Wire.beginTransmission(LP5814_I2C_ADDR); + Wire.write(reg); + Wire.write(value); + Wire.endTransmission(); + } + +public: + bool init(uint8_t brightness) override { + Wire.beginTransmission(LP5814_I2C_ADDR); + if (Wire.endTransmission() != 0) { + return false; // LP5814 not found + } + + writeReg(REG_DEVICE_CONFIG0, 0x01); // chip enable + writeReg(REG_MAX_CURRENT, 0x01); // 51 mA max current + writeReg(REG_ENABLE_CONTROL, 0x00); // outputs off while configuring + writeReg(REG_DIM_MODE, 0x4E); + writeReg(REG_ENGINE_MODE, 0xF0); + for (uint8_t i = 0; i < 4; i++) { + writeReg(REG_LED0_DC + i, 200); + } + writeReg(REG_ENABLE_CONTROL, 0x0F); // enable all 4 channels + writeReg(REG_UPDATE, 0x55); // latch (LP5814 requires 0x55) + delay(5); + + setBrightness(brightness); + return true; + } + + void setBrightness(uint8_t brightness) override { + for (uint8_t i = 0; i < 4; i++) { + writeReg(REG_LED0_PWM + i, brightness); + } + _brightness = brightness; + } + + uint8_t getBrightness() const { return _brightness; } + + virtual ~WioTrackerL2Backlight() = default; +}; + +class LGFX_WioTrackerL2 : public lgfx::LGFX_Device { + lgfx::Panel_NV3031B _panel_instance; + lgfx::Bus_SPI _bus_instance; + lgfx::Touch_GT911 _touch_instance; + WioTrackerL2Backlight _light_instance; + +public: + bool init_impl(bool use_reset, bool use_clear) override { + // bring up backlight controller while the I2C bus is still clean + _light_instance.init(_light_instance.getBrightness()); + + bool result = LGFX_Device::init_impl(use_reset, use_clear); + + // GT911 probe can leave the ESP32 I2C peripheral with a stuck BUSY flag; + // cycling Wire resets it so later LP5814/sensor traffic doesn't time out + Wire.end(); + Wire.begin(47, 48); + + return result; + } + + LGFX_WioTrackerL2(void) { + { + auto cfg = _bus_instance.config(); + cfg.spi_host = SPI3_HOST; + cfg.spi_mode = 3; + cfg.freq_write = L2_SPI_FREQUENCY; + cfg.freq_read = 16000000; + cfg.pin_sclk = 42; + // quad SPI data pins + cfg.pin_io0 = 41; + cfg.pin_io1 = 40; + cfg.pin_io2 = 39; + cfg.pin_io3 = 38; + _bus_instance.config(cfg); + _panel_instance.setBus(&_bus_instance); + } + + { + auto cfg = _panel_instance.config(); + cfg.pin_cs = 46; + cfg.pin_rst = -1; // reset is on the IO expander, done in board init + cfg.pin_busy = -1; + cfg.panel_width = 240; // native portrait orientation + cfg.panel_height = 320; + cfg.memory_width = 240; + cfg.memory_height = 320; + cfg.offset_x = 0; + cfg.offset_y = 0; + cfg.offset_rotation = 1; // panel mounted landscape (320x240) + cfg.invert = true; + cfg.rgb_order = true; + cfg.dlen_16bit = false; + cfg.bus_shared = false; + _panel_instance.config(cfg); + } + + { + auto cfg = _touch_instance.config(); + cfg.pin_cs = -1; + cfg.x_min = 0; + cfg.x_max = 239; + cfg.y_min = 0; + cfg.y_max = 319; + cfg.pin_int = -1; // INT is on the IO expander + cfg.offset_rotation = 2; + cfg.i2c_port = 0; + cfg.i2c_addr = 0x5D; + cfg.pin_sda = 47; + cfg.pin_scl = 48; + cfg.bus_shared = false; + cfg.freq = 400000; + _touch_instance.config(cfg); + _panel_instance.setTouch(&_touch_instance); + } + + _panel_instance.setLight(&_light_instance); + setPanel(&_panel_instance); + } +}; + +class WioTrackerL2Display : public LGFXDisplay { + LGFX_WioTrackerL2 disp; +public: + WioTrackerL2Display() : LGFXDisplay(320, 240, disp) {} + + // direct access to the LGFX device (LVGL flush/touch glue) + lgfx::LGFX_Device* lgfxDevice() { return &disp; } + + // reliable touch read in logical (UI_ZOOM-scaled) coords; returns false when + // not touched (LGFXDisplay::getTouch reads an uninitialized point on release) + bool readTouch(int& x, int& y) { + lgfx::touch_point_t tp; + if (disp.getTouch(&tp, 1) == 0) return false; + x = tp.x / UI_ZOOM; + y = tp.y / UI_ZOOM; + return true; + } + + bool getTouch(int* x, int* y) override { + return readTouch(*x, *y); + } + + // shadows LGFXDisplay::begin() (non-virtual, called on the concrete type in + // main.cpp) - identical except rotation: our panel config already carries + // offset_rotation=1 for the landscape mounting, so no extra rotation here + bool begin() { + // dark theme: deep navy ground, MeshCore blue accents (RGB565) + UIColor::window_bkg = 0x0885; // dark navy + UIColor::title_bkg = 0x1299; // meshcore blue + UIColor::title_txt = 0xFFFF; + UIColor::primary_txt = 0xE73C; // near-white + UIColor::secondary_txt = 0x8D59; // muted blue-gray + UIColor::warning_txt = 0xFD20; // orange + UIColor::popup_bkg = 0x1299; + UIColor::popup_txt = 0xFFFF; + UIColor::corp_blue = 0x5DBF; // sky blue (own messages, icons) + + turnOn(); + display->init(); + display->setRotation(0); + display->setColorDepth(8); + // The zoomed sprite blit stops a pixel short of the panel edge, so those + // pixels keep whatever the panel powered up with (they showed as coloured + // lines down the right side and along the bottom). Clear the panel once. + display->fillScreen(UIColor::window_bkg); + display->setBrightness(153); + display->setTextColor(TFT_WHITE); + + buffer.setColorDepth(8); + buffer.setPsram(true); + buffer.createSprite(width(), height()); + + return true; + } +}; diff --git a/variants/wio-tracker-l2/platformio.ini b/variants/wio-tracker-l2/platformio.ini new file mode 100644 index 00000000000..9434d081871 --- /dev/null +++ b/variants/wio-tracker-l2/platformio.ini @@ -0,0 +1,134 @@ +; Seeed Wio Tracker L2 Pro (alpha) - Wio-S3 module: ESP32-S3R8 + SX1262 +; Pin map derived from Seeed's Meshtastic support branch (meshtastic/firmware +; PR #10909 + device-ui `wio-l2` branch). +[Wio_Tracker_L2] +extends = esp32_base +board = seeed_wio_tracker_l2 +build_flags = ${esp32_base.build_flags} + ${sensor_base.build_flags} + -I variants/wio-tracker-l2 + -D SEEED_WIO_TRACKER_L2 + -D PIN_BOARD_SDA=47 + -D PIN_BOARD_SCL=48 + -D PIN_USER_BTN=0 + -D USE_SX1262 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=22 + -D P_LORA_NSS=21 + -D P_LORA_RESET=7 + -D P_LORA_BUSY=8 + -D P_LORA_DIO_1=9 + -D P_LORA_SCLK=4 + -D P_LORA_MISO=5 + -D P_LORA_MOSI=6 + -D SX126X_DIO2_AS_RF_SWITCH=true + -D SX126X_DIO3_TCXO_VOLTAGE=1.8 + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 + -D ENV_INCLUDE_GPS=1 + -D PIN_GPS_TX=18 ; L76K TX -> ESP32 RX + -D PIN_GPS_RX=17 ; L76K RX <- ESP32 TX + -D GPS_BAUD_RATE=9600 + -D ENV_SKIP_GPS_DETECT=1 ; GPS is always present, powered via IO expander + -D DISPLAY_CLASS=WioTrackerL2Display + -D UI_ZOOM=2 + ; disable ALL env sensor I2C probes: this bus carries six onboard chips + ; (TCA9535 0x21, AW35615 0x22, LP5814 0x2C, ADS1115 0x48, GT911 0x5D, + ; ES8311 0x18) and stray probe matches can hang sensors.begin() + -D ENV_INCLUDE_AHTX0=0 + -D ENV_INCLUDE_BME280=0 + -D ENV_INCLUDE_BMP280=0 + -D ENV_INCLUDE_SHTC3=0 + -D ENV_INCLUDE_SHT4X=0 + -D ENV_INCLUDE_INA3221=0 + -D ENV_INCLUDE_INA219=0 + -D ENV_INCLUDE_INA226=0 + -D ENV_INCLUDE_INA260=0 + -D ENV_INCLUDE_MLX90614=0 + -D ENV_INCLUDE_VL53L0X=0 + -D ENV_INCLUDE_BME680=0 + -D ENV_INCLUDE_BMP085=0 + -D ENV_INCLUDE_LPS22HB=0 +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/wio-tracker-l2> + + + + + + +lib_deps = + ${esp32_base.lib_deps} + ${sensor_base.lib_deps} + lovyan03/LovyanGFX @ 1.2.30 ; pinned: first release that links NV3031B under gnu++14 + https://github.com/pschatzmann/arduino-audio-driver/archive/v0.2.1.zip + +; -------- Companion: BLE phone app, standard display UI -------------------- +[env:Wio_Tracker_L2_companion_radio_ble] +extends = Wio_Tracker_L2 +build_flags = + ${Wio_Tracker_L2.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D BLE_PIN_CODE=123456 + -D OFFLINE_QUEUE_SIZE=256 + -D HAS_TOUCH=1 + -D AUTO_OFF_MILLIS=60000 ; LCD, no burn-in worry; 1 min screen timeout +build_src_filter = ${Wio_Tracker_L2.build_src_filter} + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Wio_Tracker_L2.lib_deps} + densaugeo/base64 @ ~1.4.0 + +; -------- Companion: USB serial link, standard display UI ------------------ +[env:Wio_Tracker_L2_companion_radio_usb] +extends = Wio_Tracker_L2 +build_flags = + ${Wio_Tracker_L2.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE + -D HAS_TOUCH=1 + -D AUTO_OFF_MILLIS=60000 +build_src_filter = ${Wio_Tracker_L2.build_src_filter} + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Wio_Tracker_L2.lib_deps} + densaugeo/base64 @ ~1.4.0 + +; -------- Standalone repeater: extends mesh coverage, no phone needed ------- +[env:Wio_Tracker_L2_repeater] +extends = Wio_Tracker_L2 +build_flags = + ${Wio_Tracker_L2.build_flags} + -D ADVERT_NAME='"WioL2 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +build_src_filter = ${Wio_Tracker_L2.build_src_filter} + +<../examples/simple_repeater/*.cpp> +lib_deps = + ${Wio_Tracker_L2.lib_deps} + ${esp32_ota.lib_deps} + +; -------- Standalone room server: shared message board / BBS ---------------- +[env:Wio_Tracker_L2_room_server] +extends = Wio_Tracker_L2 +build_flags = + ${Wio_Tracker_L2.build_flags} + -D ADVERT_NAME='"WioL2 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +build_src_filter = ${Wio_Tracker_L2.build_src_filter} + +<../examples/simple_room_server> +lib_deps = + ${Wio_Tracker_L2.lib_deps} + ${esp32_ota.lib_deps} diff --git a/variants/wio-tracker-l2/target.cpp b/variants/wio-tracker-l2/target.cpp new file mode 100644 index 00000000000..a87da392495 --- /dev/null +++ b/variants/wio-tracker-l2/target.cpp @@ -0,0 +1,35 @@ +#include +#include "target.h" + +WioTrackerL2Board board; + +static SPIClass spi; +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); + +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); +GpsTapStream gps_tap(Serial1); // GSV sats-in-view / SNR watcher +MicroNMEALocationProvider gps(gps_tap, &rtc_clock); +EnvironmentSensorManager sensors(gps); + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; + MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true); +#endif + +bool radio_init() { + MESH_DEBUG_PRINTLN("radio_init: rtc + sx1262 init"); + fallback_clock.begin(); + rtc_clock.begin(Wire); // Wire already running on 47/48 from board.begin() + + bool ok = radio.std_init(&spi); + MESH_DEBUG_PRINTLN("radio_init: SX1262 %s", ok ? "OK" : "FAILED"); + return ok; +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} diff --git a/variants/wio-tracker-l2/target.h b/variants/wio-tracker-l2/target.h new file mode 100644 index 00000000000..11a66e4ec12 --- /dev/null +++ b/variants/wio-tracker-l2/target.h @@ -0,0 +1,30 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include "WioTrackerL2Board.h" +#include +#include +#include +#include +#include "GpsTap.h" +#ifdef DISPLAY_CLASS + #include "WioTrackerL2Display.h" + #include +#endif + +extern WioTrackerL2Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; +extern GpsTapStream gps_tap; + +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); diff --git a/variants/wio_wm1110/platformio.ini b/variants/wio_wm1110/platformio.ini index a7eac916b08..18c052296cf 100644 --- a/variants/wio_wm1110/platformio.ini +++ b/variants/wio_wm1110/platformio.ini @@ -30,7 +30,7 @@ build_src_filter = ${nrf52_base.build_src_filter} + +<../variants/wio_wm1110> debug_tool = jlink -upload_protocol = jlink +upload_protocol = nrfutil lib_deps = ${nrf52_base.lib_deps} ${sensor_base.lib_deps} adafruit/Adafruit LIS3DH @ ^1.2.4 diff --git a/variants/xiao_nrf52/XiaoNrf52Board.h b/variants/xiao_nrf52/XiaoNrf52Board.h index e71e7f65177..91a0d5961f7 100644 --- a/variants/xiao_nrf52/XiaoNrf52Board.h +++ b/variants/xiao_nrf52/XiaoNrf52Board.h @@ -35,7 +35,8 @@ class XiaoNrf52Board : public NRF52BoardDCDC { return "Seeed Xiao-nrf52"; } - void powerOff() override { + void shutdownPeripherals() override { + NRF52Board::shutdownPeripherals(); // set led on and wait for button release before poweroff digitalWrite(PIN_LED, LOW); #ifdef PIN_USER_BTN @@ -50,7 +51,6 @@ class XiaoNrf52Board : public NRF52BoardDCDC { nrf_gpio_cfg_sense_input(digitalPinToInterrupt(g_ADigitalPinMap[PIN_USER_BTN]), NRF_GPIO_PIN_PULLUP, NRF_GPIO_PIN_SENSE_LOW); #endif - NRF52Board::powerOff(); } }; diff --git a/variants/xiao_nrf52/platformio.ini b/variants/xiao_nrf52/platformio.ini index b40ebbc3600..781304d28fb 100644 --- a/variants/xiao_nrf52/platformio.ini +++ b/variants/xiao_nrf52/platformio.ini @@ -118,3 +118,21 @@ build_src_filter = ${Xiao_nrf52.build_src_filter} +<../examples/kiss_modem/*.cpp> lib_deps = ${Xiao_nrf52.lib_deps} + +[env:Xiao_nrf52_repeater_bridge_rs232] +extends = Xiao_nrf52 +build_flags = + ${Xiao_nrf52.build_flags} + -D ADVERT_NAME='"RS232 Bridge"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_RS232_BRIDGE=Serial1 + -D WITH_RS232_BRIDGE_RX=PIN_SERIAL1_RX + -D WITH_RS232_BRIDGE_TX=PIN_SERIAL1_TX +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${Xiao_nrf52.build_src_filter} + + + +<../examples/simple_repeater> diff --git a/variants/xiao_s3_wio/platformio.ini b/variants/xiao_s3_wio/platformio.ini index 5a2ef106173..6c5e5bf8572 100644 --- a/variants/xiao_s3_wio/platformio.ini +++ b/variants/xiao_s3_wio/platformio.ini @@ -8,6 +8,7 @@ build_flags = ${esp32_base.build_flags} -I variants/xiao_s3_wio -UENV_INCLUDE_GPS -D SEEED_XIAO_S3 + -D PIN_VBAT_READ=1 ; D0, as in PowerSaving-v17 -D P_LORA_DIO_1=39 -D P_LORA_NSS=41 -D P_LORA_RESET=42 ; RADIOLIB_NC