diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6f31c10..770307d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,6 +9,24 @@ env: python_version: 3.12 toolchains: arm-zephyr-eabi:xtensa-espressif_esp32s3_zephyr-elf:xtensa-espressif_esp32_zephyr-elf:riscv64-zephyr-elf + # Mark the GitHub release as a pre-release AND skip the Mesh America catalog + # publish. Set to "true" for a release that should not reach ordinary users + # through apps.meshamerica.com's browser flasher; set back to "false" for a + # normal release. + # + # Two separate effects, both driven from here so they cannot drift apart: + # - the release is created with prerelease: true, so GitHub does not mark it + # "Latest" and it is excluded from the /releases/latest API. + # - publish-catalog is skipped entirely, so the firmware-dist branch and its + # catalog.json are left ALONE — the configurator keeps offering whatever + # the last non-prerelease build published. Nothing is unpublished or + # rolled back; that branch is simply not force-pushed this time. + # Release artifacts are still built and attached, so a manual download and + # flash works exactly as usual. + # + # 1.16.8: pre-release while ESP32 light sleep is unvalidated on hardware. + prerelease: "true" + jobs: setup-zephyr: runs-on: ubuntu-latest @@ -228,6 +246,10 @@ jobs: contents: write outputs: release_tag: ${{ steps.tag.outputs.release_tag }} + # Re-exported as a job output because the `env` context is NOT available + # in a job-level `if:` — publish-catalog cannot read env.prerelease + # directly and has to gate on this instead. + prerelease: ${{ env.prerelease }} steps: - uses: actions/checkout@v6 @@ -326,6 +348,7 @@ jobs: body_path: ${{ steps.notes.outputs.notes_path }} target_commitish: ${{ github.sha }} draft: false + prerelease: ${{ env.prerelease }} files: firmware/* # Publish firmware + provider catalog for the Mesh America configurator @@ -335,8 +358,14 @@ jobs: # CORS-clean hosts (firmware via jsDelivr, catalog via raw.githubusercontent). # The stable catalog URL registered with Mesh America is: # https://raw.githubusercontent.com///firmware-dist/catalog.json + # + # Skipped entirely on a pre-release (see the `prerelease` env at the top). + # This branch is force-pushed, so skipping is the whole mechanism: the + # previous release's firmware and catalog.json stay exactly as they are and + # the configurator keeps offering that version. publish-catalog: needs: create-release + if: needs.create-release.outputs.prerelease != 'true' runs-on: ubuntu-latest permissions: contents: write diff --git a/releasenotes/RELEASE_NOTES_1.16.8-zephcore.md b/releasenotes/RELEASE_NOTES_1.16.8-zephcore.md new file mode 100644 index 0000000..3d5bb1f --- /dev/null +++ b/releasenotes/RELEASE_NOTES_1.16.8-zephcore.md @@ -0,0 +1,222 @@ +# ZephCore 1.16.8-zephcore + +> [!CAUTION] +> **This is a pre-release.** ESP32 light sleep is enabled by default on Heltec repeaters and has not +> been validated on hardware yet — see the light-sleep section for what to check and how to build it +> out. It is deliberately **not published to the apps.meshamerica.com configurator**, which keeps +> offering v1.16.7; flash this one manually from the assets below if you want to help test it. +> The nRF based boards should be fine. + +> [!IMPORTANT] +> ## Before you upgrade +> +> **From v1.16.5 / v1.16.6 / v1.16.7** — clean flash, no re-bond, bonds and data survive. +> +> **From v1.16.2 / v1.16.3 / v1.16.4** — clean flash, bonds and data survive. If you are on an +> **ESP32-S3 / ESP32-C board** and have *not* yet taken the v1.16.5 update, you still owe the one-time +> serial reflash: the app moved to flash offset `0x10000` in v1.16.5, and a board on the old layout +> cannot cross that update over WiFi-OTA or the browser flasher — **flash the `-merged.bin` once over +> USB/serial.** Affected boards: *Heltec V3 / V4 / V4.3, Station G2, Wireless Tracker / V2, XIAO +> ESP32-S3 / C3 / C6, LilyGo T-Lora C6, ThinkNode M9.* Identity, contacts, channels, prefs, and BLE +> bonds are preserved. nRF52, classic ESP32 (T-Beam / PICO-D4 / TTGO LoRa32), STM32WL, and native +> Linux are unaffected. +> +> **From v1.16.1 or older** — flash it; on first boot it clears BLE bonds automatically (identity, +> contacts, channels, prefs preserved). Re-bond your phone/desktop once. +> +> **Coming from Arduino MeshCore** — flash it; auto-formats on first boot (new identity, clean storage). +> +> **Heltec ESP32 repeaters now light-sleep**, and their USB CLI stops answering 10 minutes after boot +> as a result. Reconnecting your terminal normally resets the node and gives you another 10 minutes; +> if it doesn't, power-cycle. Details in the light-sleep section below. +> +> **One CLI rename:** `set/get cad.probe.interval` is now `set/get probe.interval` — the same setting, +> renamed because one measurement now feeds both the noise floor and the CAD probe. Your stored value +> carries over; only the command name changed. `set/get agc.reset.interval` is gone (see below). +> +> Take this with a grain of salt — try the formatters if anything anomalous happens with your node. + +--- + +A **power and radio-measurement release**. Repeaters no longer wake on a fixed 5 s tick — they sleep +until their next real deadline. Every ESP32 build drops to 80 MHz, and Heltec repeaters now light-sleep +between packets. The noise-floor sampler and CAD probe were unified into one measurement, the +SX126x RSSI/AGC calibration is now band-correct, and the observer role got a batch of fixes for settings +that silently never reached the hardware. + +## Highlights + +### Repeaters are now deadline-driven — the periodic housekeeping tick is gone + +Every repeater woke five times a second-and-a-bit forever: a fixed 5 s "housekeeping" timer that ran the +noise-floor sample, the CAD probe, advert timers and the tempradio revert. None of that actually needs a +poll — each item is either a deadline or a sampler with its own interval. + +The new `mesh/Maintenance.h` contract lets the event loop ask *"when does maintenance next have work?"* +and arm **one one-shot wake for exactly that moment**. An idle repeater now sleeps until its soonest real +deadline instead of on a fixed cadence. A hard ceiling (`CONFIG_ZEPHCORE_MAINTENANCE_BACKSTOP_MS`, +default **60 s**) clamps the wake as a safety net against a missed deadline — it should never bind in +normal operation. + +Companions and room servers keep their periodic tick deliberately: the companion block contains genuine +pollers (contact-dump progress, BLE advertising watchdog) with no deadline to arm, and the room server is +paced by its own 500 ms push timer regardless. + +### ESP32 power: 80 MHz everywhere, and light sleep on Heltec repeaters + +Espressif parts have no equivalent of the nRF52's "idle at microamps in WFI" — `WAITI` gates the core +clock but leaves PLL, peripherals and RAM powered, so an idle ESP32 sits in the **tens of mA**. CPU +frequency is therefore a first-order term in a battery node's draw, and light sleep (sub-mA) is what +removes the rest. + +- **Every ESP32 build is now pulled to 80 MHz**, matching what Arduino MeshCore ships (new + hal-espressif patch `0002` — upstream omits 80 from its DT map). **Observers are the exception** and + keep the SoC maximum (240 MHz on S3/classic, 160 MHz on C3/C6): they run a full WiFi + TLS + MQTT + stack and are mains- or big-panel powered, so throughput matters more than current. +- **Light sleep is now enabled automatically** for **repeater** builds on **Heltec V3, V4, V4.3, + Wireless Tracker and Wireless Tracker V2**. Patch `0012` marks the LoRa DIO1 line as a wakeup source, + so a received packet wakes the SoC — the node sleeps between packets and deadlines and wakes for real + work. Combined with the deadline-driven change above, an idle repeater now spends most of its life + asleep instead of waking five times a second at 240 MHz. + +> [!IMPORTANT] +> **The USB CLI stops answering 10 minutes after boot on these boards.** Nothing arms a UART wake +> source on ESP32, so characters typed at a sleeping node are dropped. To make configuration reliable, +> light sleep is **blocked for the first 10 minutes after every boot** +> (`CONFIG_ZEPHCORE_PM_BOOT_AWAKE_MS`) — and because these boards' USB bridge drives EN from DTR, +> **connecting a terminal normally resets the node and re-arms that window.** If your terminal doesn't +> toggle DTR, power-cycle the node to get a console. Remote admin over LoRa is unaffected. + +Console output itself is safe: a new `pm_notifier` flushes the console UART to idle before every sleep, +which Zephyr's PM path does not do — without it, lines would be cut mid-character. Packet-logging and +debug builds are therefore fine to run with sleep enabled. + +Anything that can't tolerate sleeping holds a `pm_policy` lock instead of being excluded by hand: a +**WiFi OTA session** holds one for its duration, an **uplink/observer WiFi station** holds one +permanently (the association would drop), and **GPS holds one while the module is powered**, since the +GNSS UART is not a wake source and inbound NMEA would be lost. Under a GPS duty cycle the lock is only +held during the awake phase. + +> [!WARNING] +> **Not yet validated on hardware**, and now on by default for the boards above — so this is the first +> thing to suspect if an allowlisted board misbehaves. The failure mode to rule out is a **deaf +> repeater**: check the boot log for `Pin N is not wakeup capable`, confirm the node still receives +> packets, and only then measure current. Rebuild with `-DCONFIG_PM=n` to take it back out. +> +> Boards **deliberately excluded** because DIO1 can't wake the SoC: XIAO ESP32-S3 (GPIO39), Station G2 +> (GPIO48), ThinkNode M9 (GPIO42), LilyGo T-Lora C6 (GPIO23, outside the C6's LP range), and +> TTGO LoRa32 (SX127x — patch 0012 only arms the sx126x driver). **Companion builds never get it** on +> any board: the Espressif HCI driver takes no `pm_policy` locks, so the SoC could sleep mid-advertising +> or mid-connection. Passing `pm_esp32.conf` by hand still works for testing another board. + +### One measurement now serves both the noise floor and CAD — `set probe.interval` + +The noise-floor sampler and the CAD calibration probe used to be two separate things riding the same +5 s tick. They are now a **single periodic radio measurement**: one median-of-8 RSSI reading that the +CAD probe consumes rather than measuring again. + +- `set cad.probe.interval ` → **`set probe.interval `** (default still **15**, `0` still + disables CAD probing). It is now also the noise-floor sampling rate, and it sets how often an idle + repeater wakes. +- New `CONFIG_ZEPHCORE_NOISE_FLOOR_INTERVAL_MS` is the build-time fallback used when probing is off — + 15 s on repeaters, 5 s on companions/room servers (which wake on their tick anyway). +- A sample blocked by TX, mid-RX or a duty-cycle sleep window retries on a 5 s deadline (max 2 retries) + instead of losing a full interval — deliberately reproducing the old retry grid, which matters under + RX duty cycle where blocked attempts are the norm. + +**RSSI reads are now correctly spaced.** A median only rejects outliers if the reads are independent, +and the chip refreshes RSSI once per averaging window (~16 µs at BW 62.5, ~134 µs at BW 7.8) — reads +issued faster return the *same* underlying sample and the median collapses to a single read. The spacing +and the RX-entry settle delay are now derived from the SX1261/2 datasheet (rev 2.2, Table 13-82). + +`get cad` reports the result so the assumption stays falsifiable on real hardware: the old `iv:` field is +replaced by **`sp:/()`**. A **non-zero mean proves the reads are +independent**, however high the zero-spread share climbs — only mean `0.0` with a high share indicts the +sampler. Measured on air at BW 62.5: `0.6/90%` quiet, `0.9/84%` with the floor at −103 dBm. + +### Fixed: SX126x RSSI/AGC calibration used the wrong band on sub-GHz builds + +Datasheet rev 2.2 (§6.1.6, new in Dec 2024) documents that the SX126x RSSI/AGC calibration registers are +**band-specific**. ZephCore applied the 868–915 MHz set unconditionally, so every 433 MHz build was +running the wrong AGC gain selection — that is **packet loss, not just wrong telemetry**. New patch +`0011` adds the per-band table plus the shared-register `0x08AC` / `AgcSensiAdjust` fix. + +### RAK3401 1-watt: external-PA RX register patch enabled + +The undocumented register `0x8B5` RX improvement (`CONFIG_ZEPHCORE_SX126X_HELTEC_REG_PATCH`) is not +Heltec-specific despite its name — upstream MeshCore also enables it for the RAK3401's SKY66122 FEM. It +is now on for `rak3401_1watt`, and the Kconfig help was corrected to say "boards with an external PA/FEM +in the RX path." + +### The AGC-reset workaround is gone + +`set/get agc.reset.interval` has been **removed** — it now replies `use rxduty instead`, and `set` is +accepted-and-ignored for prefs compatibility. It was a periodic blind AGC kick that only existed because +of the fixed tick; the in-driver RX restore and duty-cycle re-arm paths handle the real cases. + +### Observer role fixes + +Several observer settings looked accepted and then never reached the hardware: + +- **`set freq/sf/bw/cr` never reached the radio.** The radio was bound to a static placeholder + `NodePrefs` that is never loaded from flash, so it stayed on compiled-in defaults forever — the value + was written to flash and echoed back by the CLI and MQTT, then "reverted" on reboot. The radio is now + bound to the mesh's live prefs before `begin()`, matching repeater and room server. +- **First boot discarded the observer defaults.** The store is shared with the repeater, and its no-file + branch re-applies *repeater* defaults over whatever the caller passed in. The observer now detects + first boot itself and re-applies (and persists) its own `cr` / `tx_power` once. +- **`set name Observer` reverted on every reboot** — "Observer" was in the regenerate-if-unset list. It + is a name a user can legitimately set, so it is no longer treated as unset. +- **`set freq` / `set bw` now refuse values the loader will reject** (300–1000 MHz, 7–500 kHz). Out-of- + range values used to save fine, then get silently reset to defaults on the next boot — taking + `bw`/`sf`/`cr`/`tx_power` with them. +- **USB-console routing fixed** on Station G2 and XIAO ESP32-S3, so non-companion roles keep their + console and serial CLI. +- The observer `help` banner was reorganised (network / node / radio / query) and now lists the query + commands that already existed. + +## Upstream sync + +Synced with Arduino MeshCore `dev`: + +- **Room server: `room.post `** — post to the shared room as the server itself. Pushed to + clients like any other post. +- **Fixed: an empty guest password granted read+write to anyone.** An unset `guest.password` (the + default) now *disables* guest login instead of matching an empty submitted password. To run an open + room server use `set allow.read.only on`, which grants read-only. Both password compares still run + unconditionally, so timing behaviour is unchanged. +- **Companion telemetry: MCU die temperature is now reported under base permission**, matching Arduino + MeshCore and ZephCore's own repeater/room-server telemetry. It is still suppressed when an external + sensor already supplied a temperature, so a node never emits two. +- **Advert names are truncated on a UTF-8 code-point boundary** — a name whose multi-byte character + straddled the advert size limit used to go out mangled. +- **ThinkNode M3: GPS reset pin left floating.** Driving the REINIT pin (P0.25) stops the module + producing NMEA on this hardware; upstream fixed it by never driving it. The `gps-reset` alias is gone, + which compiles out every reset path — `GPS_EN` alone handles wake/standby. +- **New patch `0013` bounds the SX126x RX-busy latch with a payload deadline.** Continuous RX has no + symbol timer, so a `HEADER_VALID` whose packet never completes would pin the TX gate open forever and + silently mute the node. The bound is max-length airtime at the current SF/BW +25% +100 ms — + deliberately generous, a stuck-state safety net rather than a timing mechanism. + +## Other changes + +- **west manifest bumped** (routine pinned-tree advance). +- `ARCHITECTURE.md`, `ADAPTIVE_CAD.md` and `Repeater_CLI_commands.md` updated for the probe unification + and the removed commands. + +## Recommended upgrade checklist + +1. **Heltec V3 / V4 / V4.3 / Wireless Tracker repeater?** You get 80 MHz *and* light sleep + automatically. Do your configuration in the first 10 minutes after boot, or reconnect your terminal + (which resets the node) to get the window back. +2. **Running a 433 MHz SX126x node?** Update — the RSSI/AGC calibration band fix affects reception, not + just readings. +3. **Running an observer?** Update, then re-apply your radio settings once — they may never have reached + the hardware before. +4. **Scripts or notes using `cad.probe.interval` / `agc.reset.interval`?** Update them to + `probe.interval`; the AGC command is gone. +5. **ESP32-S3 / ESP32-C boards that skipped v1.16.5:** flash `-merged.bin` once over USB/serial. Data survives. +6. **Everything else, from v1.16.2 onward:** just flash — bonds and data survive. +7. **From v1.16.1 or older:** just flash — self-migrates on first boot; re-bond once. +8. **From Official MeshCore:** just flash — auto-formats on first boot. +9. **Anything odd?** Format first. diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index 415ec73..3eec222 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -369,6 +369,40 @@ if(ZEPHCORE_PLATFORM_CONF MATCHES "esp32_common" AND NOT EXTRA_CONF_FILE MATCHES endif() endif() +# ========== ESP32 light sleep (allowlisted repeater boards) ========== +# 80 MHz above cuts the idle draw; light sleep is what removes it. See +# boards/common/pm_esp32.conf for the mechanism and its requirements — this +# block is only the policy that decides who gets it automatically. +# +# REPEATERS ONLY, and the gate is deliberately the same one WiFi OTA uses +# below: the Espressif HCI driver takes no pm_policy locks, so a companion +# build would light-sleep mid-advertising or mid-connection. repeater.conf +# sets CONFIG_BT=n, which removes the problem instead of racing it. +# +# BOARD ALLOWLIST, not a platform check. Light sleep is only safe where DIO1 +# can wake the SoC, i.e. an RTC-capable pin (GPIO 0-21 on the S3). Every board +# below wires DIO1 to GPIO14 and consoles on uart0 through a USB-serial bridge, +# so both the packet wake and the console flush land correctly. Deliberately +# ABSENT and must stay absent unless their wiring changes: +# xiao_esp32s3 (DIO1=GPIO39), station_g2 (GPIO48), thinknode_m9 (GPIO42), +# lilygo_tlora_c6 (GPIO23, outside the C6's LP range 0-7) +# -> cannot wake on a received packet; the node would go deaf while +# looking excellent on a current meter. +# ttgo_lora32 -> SX127x; patches/zephyr/0012 arms DIO1 in the sx126x driver +# only, so nothing marks the pin as a wake source. +# Boards with a native USB Serial/JTAG console are also unsuitable: the USB +# peripheral drops off the bus in light sleep and there is no USJ wake source +# in the HAL (only UART0/1/2 and LP-UART). +if(ZEPHCORE_PLATFORM_CONF MATCHES "esp32_common" AND EXTRA_CONF_FILE MATCHES "repeater" + AND BOARD MATCHES "heltec_wifi_lora32_v3|heltec_wifi_lora32_v4|heltec_wireless_tracker") + set(ZEPHCORE_PM_CONF "${CMAKE_CURRENT_SOURCE_DIR}/boards/common/pm_esp32.conf") + if(EXISTS ${ZEPHCORE_PM_CONF}) + list(APPEND ZEPHCORE_CONF_FILES ${ZEPHCORE_PM_CONF}) + zephcore_auto_pair_overlay("${ZEPHCORE_PM_CONF}") + message(STATUS " ESP32 light sleep: auto-enabled (DIO1 wake-capable repeater board)") + endif() +endif() + if(ZEPHCORE_PLATFORM_CONF MATCHES "esp32_common" AND EXTRA_CONF_FILE MATCHES "repeater" AND NOT BOARD MATCHES "/esp32/") set(ZEPHCORE_WIFI_OTA_CONF "${CMAKE_CURRENT_SOURCE_DIR}/boards/common/wifi_ota.conf") @@ -491,7 +525,7 @@ add_definitions(-DFIRMWARE_BUILD_EPOCH=${ZEPHCORE_BUILD_EPOCH}u) # release is tagged/named, and what the Mesh America catalog uses as its version # key — so the configurator can match a running device against the catalog. Keep # all four identical; the release workflow reads this value directly. -set(ZEPHCORE_FIRMWARE_VERSION "1.16.7-zephcore") +set(ZEPHCORE_FIRMWARE_VERSION "1.16.8-zephcore") add_definitions(-DFIRMWARE_VERSION="${ZEPHCORE_FIRMWARE_VERSION}") add_subdirectory(lib/monocypher) @@ -607,6 +641,12 @@ if(CONFIG_SOC_FAMILY_ESPRESSIF_ESP32) endif() endif() +# ESP32 light-sleep console guards — TX flush before sleep + post-boot awake +# window. Only meaningful when CONFIG_PM is on (boards/common/pm_esp32.conf). +if(CONFIG_SOC_FAMILY_ESPRESSIF_ESP32 AND CONFIG_PM) + target_sources(app PRIVATE helpers/pm_esp32_console.c) +endif() + # Native-Linux (native_sim) runtime setup: force real-time clock mode so the # simulated clock tracks wall time (required for the real SX126x radio's # BUSY/DIO1 timing). Bakes in the equivalent of the --rt command-line flag. diff --git a/zephcore/Kconfig b/zephcore/Kconfig index 695d0a0..0d4686f 100644 --- a/zephcore/Kconfig +++ b/zephcore/Kconfig @@ -436,6 +436,45 @@ config ZEPHCORE_NOISE_FLOOR_INTERVAL_MS 30000, ~4 min and ~8 min. Past 15000 the CAD probe becomes the binding deadline instead, so raise both or neither. +config ZEPHCORE_PM_BOOT_AWAKE_MS + int "Block light sleep for this long after boot (ms)" + depends on PM && SOC_FAMILY_ESPRESSIF_ESP32 + default 600000 + range 0 3600000 + help + How long after boot the SoC refuses to light-sleep, so the USB console + is guaranteed reachable for a configuration session. + + This exists because nothing arms a UART wake source on ESP32 — see + helpers/pm_esp32_console.c. Once the window closes, characters typed at + a sleeping node are DROPPED and the CLI stops answering over USB until + the board resets. In practice connecting a terminal usually does reset + it (the USB bridge drives EN from DTR), which re-arms the window. + + 0 disables the window entirely — the node may sleep immediately after + boot, and the USB console becomes unusable except in the instants it + happens to be awake. Do not set 0 on a board you still need to + configure. + + Raising it costs only the extra awake time at the start of each boot. + +config ZEPHCORE_PM_CONSOLE_UART_NUM + int "Console UART index flushed before light sleep" + depends on PM && SOC_FAMILY_ESPRESSIF_ESP32 + default 0 + range 0 2 + help + Hardware UART index (not a DT node) passed to + esp_rom_uart_tx_wait_idle() before entering light sleep, so a line in + the TX FIFO is not cut off mid-character by the clock stopping. + + 0 is correct for every board this is currently enabled on: their + console is uart0, whether via a USB-serial bridge (Heltec V3/V4/V4.3) + or the ROM-default pads. Change it only if a board's console is moved + to another hardware UART — a wrong index does not fail the build, it + just silently flushes the wrong peripheral and lets the console + truncate again. + menu "Radio Configuration" choice ZEPHCORE_RADIO_TYPE diff --git a/zephcore/adapters/gps/ZephyrGPSManager.cpp b/zephcore/adapters/gps/ZephyrGPSManager.cpp index fe03636..a996341 100644 --- a/zephcore/adapters/gps/ZephyrGPSManager.cpp +++ b/zephcore/adapters/gps/ZephyrGPSManager.cpp @@ -17,6 +17,7 @@ */ #include "ZephyrGPSManager.h" +#include "../../helpers/pm_sleep_guard.h" #include #include @@ -1513,6 +1514,15 @@ void gps_enable(bool enable) if (enable) { LOG_INF("GPS enabled - starting acquisition"); + /* Block SoC light sleep for as long as the module is powered. + * The GNSS UART is not a wake source, so a sleeping SoC drops + * inbound NMEA outright — sentences would be lost mid-stream and + * a fix would never converge. Balanced by the put in the disable + * branch; the early return above keeps the pair 1:1, and under a + * GPS duty cycle the lock is only held during the awake phase. + * Compiles to nothing without CONFIG_PM. */ + zc_pm_block_sleep(); + /* Start acquiring immediately (no delay for first wake) */ gps_current_state = GPS_STATE_ACQUIRING; consecutive_good_fixes = 0; @@ -1543,6 +1553,9 @@ void gps_enable(bool enable) } else { LOG_INF("GPS disabled - canceling timers and powering off"); + /* Matches the block taken in the enable branch. */ + zc_pm_unblock_sleep(); + /* Cancel any pending work */ k_work_cancel_delayable(&gps_wake_work); k_work_cancel_delayable(&gps_timeout_work); diff --git a/zephcore/adapters/ota/wifi_ota.c b/zephcore/adapters/ota/wifi_ota.c index 5f21807..ad69944 100644 --- a/zephcore/adapters/ota/wifi_ota.c +++ b/zephcore/adapters/ota/wifi_ota.c @@ -13,6 +13,7 @@ #include "wifi_ota.h" #include "ota_page.h" +#include "../../helpers/pm_sleep_guard.h" #include #include @@ -459,10 +460,18 @@ int wifi_ota_start(const char *node_name, const char *board_name) node_name ? node_name : "Unknown", board_name ? board_name : "Unknown"); + /* Hold off light sleep for the whole session. The WiFi stack and the + * HTTP upload have no PM coordination here, so a node that slept + * mid-transfer would drop the client's connection. Released in + * wifi_ota_stop(); the post-upload reboot path also passes through + * there, and a reboot would clear the lock regardless. */ + zc_pm_block_sleep(); + /* Start WiFi AP */ int ret = wifi_ap_start(); if (ret) { + zc_pm_unblock_sleep(); return ret; } @@ -472,6 +481,7 @@ int wifi_ota_start(const char *node_name, const char *board_name) if (ret) { LOG_ERR("HTTP server start failed: %d", ret); wifi_ap_stop(); + zc_pm_unblock_sleep(); return ret; } @@ -505,6 +515,11 @@ int wifi_ota_stop(void) flash_ctx_initialized = false; total_bytes_received = 0; + /* Matches the lock taken in wifi_ota_start(). The early return above + * (!ota_active) is why this sits after the flag check — an unbalanced + * put would drop someone else's lock. */ + zc_pm_unblock_sleep(); + LOG_INF("OTA server stopped"); return 0; } diff --git a/zephcore/adapters/wifi/ZephyrWiFiStation.c b/zephcore/adapters/wifi/ZephyrWiFiStation.c index 6170696..2fc878b 100644 --- a/zephcore/adapters/wifi/ZephyrWiFiStation.c +++ b/zephcore/adapters/wifi/ZephyrWiFiStation.c @@ -11,6 +11,7 @@ #include "ZephyrWiFiStation.h" #include "observer_creds.h" +#include "../../helpers/pm_sleep_guard.h" #include #include @@ -182,6 +183,13 @@ void zc_wifi_station_start(const struct ObserverCreds *creds, s_wifi_link_up = false; s_wifi_ready = false; + /* Permanent, and intentionally never released: there is no + * zc_wifi_station_stop() — once an uplink or observer node associates it + * stays associated for the life of the boot. Light sleep would drop the + * association (nothing here coordinates with the WiFi modem's own sleep), + * so a node built with WiFi simply does not light-sleep. */ + zc_pm_block_sleep(); + /* Register WiFi event callback */ net_mgmt_init_event_callback(&wifi_cb, wifi_event_handler, NET_EVENT_WIFI_CONNECT_RESULT | diff --git a/zephcore/boards/common/pm_esp32.conf b/zephcore/boards/common/pm_esp32.conf index 2428fdd..aac1980 100644 --- a/zephcore/boards/common/pm_esp32.conf +++ b/zephcore/boards/common/pm_esp32.conf @@ -1,9 +1,25 @@ # ESP32 system power management — light sleep between events. # +# AUTO-ENABLED for repeater builds on the DIO1-wake-capable Heltec boards; see +# the allowlist block in CMakeLists.txt for who qualifies and why the others +# must not. Passing it by hand is still supported for testing another board: +# # west build -b heltec_wifi_lora32_v3/esp32s3/procpu zephcore --pristine -- \ # -DEXTRA_CONF_FILE="boards/common/repeater.conf;boards/common/pm_esp32.conf" # -# OPT-IN, and deliberately not enabled by default. Read this before shipping it. +# CONSOLE BEHAVIOUR — helpers/pm_esp32_console.c does two things without which +# light sleep makes a node unusable: it flushes the console UART before every +# sleep (Zephyr's PM path does not, so lines would be cut mid-character), and +# it blocks sleep for ZEPHCORE_PM_BOOT_AWAKE_MS (default 10 min) after boot so +# there is a guaranteed window to configure the node. AFTER THAT WINDOW THE USB +# CLI STOPS ANSWERING: nothing arms a UART wake source, so typed characters are +# dropped. Connecting a terminal usually resets the board (the USB bridge +# drives EN from DTR) which re-arms the window — but a terminal that does not +# toggle DTR needs a manual power cycle. Remote admin over LoRa is unaffected. +# +# WiFi builds hold a pm_policy lock and never light-sleep at all — the OTA +# session takes one for its duration (adapters/ota/wifi_ota.c) and an uplink or +# observer station takes one permanently (adapters/wifi/ZephyrWiFiStation.c). # # What it buys: Espressif parts have no equivalent of the nRF52's "idle at # microamps in WFI" — WAITI gates the core clock but leaves PLL, peripherals @@ -23,7 +39,10 @@ # # OK heltec_wifi_lora32_v3 / v4 / v43, heltec_wireless_tracker(_v2) # -> DIO1 = GPIO14 -# NOT OK xiao_esp32s3 (GPIO39), station_g2 (GPIO48), thinknode_m9 (GPIO42) +# NOT OK xiao_esp32s3 (GPIO39), station_g2 (GPIO48), thinknode_m9 (GPIO42), +# lilygo_tlora_c6 (GPIO23, outside the C6's LP range 0-7) +# N/A ttgo_lora32 -> SX127x; patch 0012 arms DIO1 in the sx126x driver +# only, so nothing marks the pin as a wake source at all # # On a NOT-OK board the driver logs "Pin N is not wakeup capable" at boot and # the node will light-sleep straight through inbound packets while looking @@ -43,9 +62,12 @@ # and announce elapsed ticks, so k_uptime_get() and every maintenance deadline # stay correct. # -# UNVALIDATED ON HARDWARE. Verify in this order: (1) no "not wakeup capable" -# line at boot, (2) the node still receives packets, (3) only then measure -# current. A deaf repeater is the expected failure and it is not obvious. +# NOT YET VALIDATED ON HARDWARE — and it is now auto-enabled, so this is the +# first thing to check if an allowlisted board misbehaves. Verify in this +# order: (1) no "not wakeup capable" line at boot, (2) the node still receives +# packets, (3) only then measure current. A deaf repeater is the expected +# failure and it is not obvious from the outside. To rule it out on a suspect +# unit, rebuild with -DCONFIG_PM=n. CONFIG_PM=y diff --git a/zephcore/boards/common/zephcore_common.conf b/zephcore/boards/common/zephcore_common.conf index 8de6d2b..64540cc 100644 --- a/zephcore/boards/common/zephcore_common.conf +++ b/zephcore/boards/common/zephcore_common.conf @@ -156,7 +156,7 @@ CONFIG_BT_DIS_FW_REV=y # of truth for the C side, injected as -DFIRMWARE_VERSION). This Kconfig value can't # read a C macro, so it must be bumped here too. Phones that read DIS and phones # that query CMD_DEVICE_QUERY should see the same version. -CONFIG_BT_DIS_FW_REV_STR="1.16.7-zephcore" +CONFIG_BT_DIS_FW_REV_STR="1.16.8-zephcore" CONFIG_BT_DIS_SW_REV=y CONFIG_BT_DIS_SW_REV_STR="Zephyr" CONFIG_BT_DIS_PNP=n diff --git a/zephcore/helpers/pm_esp32_console.c b/zephcore/helpers/pm_esp32_console.c new file mode 100644 index 0000000..826ba3a --- /dev/null +++ b/zephcore/helpers/pm_esp32_console.c @@ -0,0 +1,106 @@ +/* + * SPDX-License-Identifier: MIT + * ESP32 light-sleep console guards. + * + * Two independent problems sit between ESP32 light sleep and a usable serial + * console. This file solves both; neither fix substitutes for the other. + * + * 1. TRUNCATED OUTPUT. soc/espressif/common/power.c calls + * esp_light_sleep_start() with no wait for the console UART to drain — the + * TX-idle flush that ESP-IDF performs lives behind its own console Kconfig, + * which is not part of a Zephyr build. Sleeping with bytes still in the + * FIFO cuts the line mid-character. A pm_notifier's state_entry callback + * runs immediately before pm_state_set() (subsys/pm/pm.c), which is exactly + * the right moment to poll the UART to idle. + * + * 2. UNREACHABLE INPUT. Nothing arms a UART wake source — Zephyr's PM path + * never calls esp_sleep_enable_uart_wakeup(), and on the boards this runs on + * the console RX pad (GPIO44 on an S3 uart0) is outside the RTC range that + * could carry a GPIO wake instead. Characters typed at a sleeping node are + * therefore dropped, and no amount of TX handling changes that. + * + * The answer here is a boot window rather than a wake source: sleep is + * blocked outright for ZEPHCORE_PM_BOOT_AWAKE_MS after boot, so a node is + * always reachable for that long. This works better in practice than it + * sounds, because the USB bridge on these boards drives EN from DTR — a + * terminal that asserts DTR on open resets the board, which re-arms the + * window at the moment someone connects. A terminal that does not toggle + * DTR gets no window, and the node has to be power-cycled to answer over + * USB; that is the known limitation of this approach. Arming + * esp_sleep_enable_uart_wakeup() and re-taking the lock on console activity + * would remove it, at the cost of the first keystroke (the hardware + * consumes it as the wake trigger). + * + * Remote admin over LoRa is unaffected by any of this: DIO1 is armed as a wake + * source by patches/zephyr/0012 and wakes the SoC on a received packet. + */ + +#include +#include +#include + +#include + +#include "pm_sleep_guard.h" + +#include +LOG_MODULE_REGISTER(zephcore_pm, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); + +/* ========== 1. Flush the console before every sleep ========== */ + +static void pm_console_flush(enum pm_state state) +{ + if (state != PM_STATE_STANDBY) { + return; + } + + /* Polls a status register until the shifter empties — no locking, so it + * is safe in this context (called with the scheduler locked). Bounded + * by the FIFO depth at the configured baud: ~11 ms worst case for a full + * 128-byte FIFO at 115200, and normally microseconds. */ + esp_rom_uart_tx_wait_idle(CONFIG_ZEPHCORE_PM_CONSOLE_UART_NUM); +} + +static struct pm_notifier console_notifier = { + .state_entry = pm_console_flush, +}; + +/* ========== 2. Keep the node awake for a window after boot ========== */ + +#if CONFIG_ZEPHCORE_PM_BOOT_AWAKE_MS > 0 + +static void boot_window_expired(struct k_work *work) +{ + ARG_UNUSED(work); + + zc_pm_unblock_sleep(); + LOG_INF("boot window elapsed — light sleep enabled " + "(USB console answers again after a reset)"); +} + +static K_WORK_DELAYABLE_DEFINE(boot_window_work, boot_window_expired); + +#endif /* CONFIG_ZEPHCORE_PM_BOOT_AWAKE_MS > 0 */ + +static int pm_console_init(void) +{ + pm_notifier_register(&console_notifier); + +#if CONFIG_ZEPHCORE_PM_BOOT_AWAKE_MS > 0 + /* Taken here rather than released here: the lock is held from init and + * dropped by the work item, so there is no window at startup in which + * the node could sleep before the guard is in place. */ + zc_pm_block_sleep(); + k_work_schedule(&boot_window_work, + K_MSEC(CONFIG_ZEPHCORE_PM_BOOT_AWAKE_MS)); + + LOG_INF("light sleep deferred %d ms (console configuration window)", + CONFIG_ZEPHCORE_PM_BOOT_AWAKE_MS); +#endif + + return 0; +} + +/* POST_KERNEL: needs the kernel work queue, and must be in place before the + * application can idle long enough to sleep. */ +SYS_INIT(pm_console_init, POST_KERNEL, CONFIG_KERNEL_INIT_PRIORITY_DEFAULT); diff --git a/zephcore/helpers/pm_sleep_guard.h b/zephcore/helpers/pm_sleep_guard.h new file mode 100644 index 0000000..ac4d68e --- /dev/null +++ b/zephcore/helpers/pm_sleep_guard.h @@ -0,0 +1,35 @@ +/* + * SPDX-License-Identifier: MIT + * Sleep guard — block SoC light sleep across a critical stretch. + * + * Zephyr's policy locks are reference-counted, so every zc_pm_block_sleep() + * needs exactly one matching zc_pm_unblock_sleep(); nested holders compose. + * While at least one lock is held the idle path skips PM_STATE_STANDBY and the + * SoC idles normally instead — device power management is untouched either way. + * + * Both calls compile to nothing without CONFIG_PM, so callers do not need to + * guard their own use sites. + */ + +#pragma once + +#if defined(CONFIG_PM) + +#include + +static inline void zc_pm_block_sleep(void) +{ + pm_policy_state_lock_get(PM_STATE_STANDBY, PM_ALL_SUBSTATES); +} + +static inline void zc_pm_unblock_sleep(void) +{ + pm_policy_state_lock_put(PM_STATE_STANDBY, PM_ALL_SUBSTATES); +} + +#else /* !CONFIG_PM */ + +static inline void zc_pm_block_sleep(void) { } +static inline void zc_pm_unblock_sleep(void) { } + +#endif /* CONFIG_PM */