From 5ac31569431ab0bb8155b96758b990f4d46f3e7f Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:23:33 +0200 Subject: [PATCH] observer lightsleep + companion opt-in --- docs/ARCHITECTURE.md | 21 +++- docs/CLI_commands.md | 4 +- docs/DESIGN.md | 4 + docs/adr/0009-wifi-companion-boards.md | 2 +- releasenotes/RELEASE_NOTES_1.17.7-zephcore.md | 95 ++++++++++++++++++- zephcore/CMakeLists.txt | 39 ++++---- zephcore/adapters/ble/ZephyrBLE.cpp | 20 ++++ zephcore/adapters/mqtt/ZephyrMQTTPublisher.c | 29 ++++-- zephcore/adapters/usb/ZephyrCompanionUSB.cpp | 6 ++ zephcore/adapters/usb/ZephyrUSBCDC.cpp | 28 ++++++ zephcore/adapters/wifi/ZephyrWiFiStation.c | 80 ++++++++++++---- zephcore/adapters/wifi/ZephyrWiFiStation.h | 15 ++- zephcore/app/CompanionWifi.cpp | 14 ++- zephcore/app/RepeaterMesh.cpp | 3 +- zephcore/app/main_observer.cpp | 3 +- .../boards/common/esp32_cpu_80mhz.overlay | 9 +- zephcore/boards/common/pm_esp32.conf | 31 +++--- .../esp32/heltec_wifi_lora32_v3/board.conf | 12 ++- .../esp32/heltec_wifi_lora32_v3/zephcore.yml | 1 + .../esp32/heltec_wifi_lora32_v4/zephcore.yml | 1 + .../esp32/heltec_wifi_lora32_v43/zephcore.yml | 1 + .../esp32/heltec_wireless_tracker/board.conf | 12 ++- .../heltec_wireless_tracker/zephcore.yml | 1 + .../heltec_wireless_tracker_v2/zephcore.yml | 1 + zephcore/boards/esp32/station_g2/zephcore.yml | 1 + .../boards/esp32/xiao_esp32s3/zephcore.yml | 1 + zephcore/boards/example_board/README.md | 5 +- zephcore/boards/example_board/zephcore.yml | 4 + zephcore/cmake/zephcore_board.cmake | 6 ++ zephcore/helpers/NodePrefs.h | 10 +- zephcore/helpers/pm_esp32_wake.c | 43 ++++++++- zephcore/helpers/pm_sleep_guard.h | 5 + zephcore/helpers/ui/display.c | 26 ++++- zephcore/scripts/board_manifest.py | 6 +- 34 files changed, 439 insertions(+), 100 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2d9ec19..2368f15 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -838,6 +838,8 @@ Repeaters and room servers default to `CONFIG_ZEPHCORE_REPEATER_GPS_INTERVAL_SEC went away is reported as a DTR drop in three cases: DTR low, VBUS removed, and a bus reset. The reset case exists because the CDC class keeps the old line state across a reset, so nothing else says that the port the host had open is gone (a hub resetting upstream leaves VBUS up). Bus suspend, resume and reset also reach a second callback. + While a host is on the bus (from reset, configuration or resume until suspend or VBUS loss) the module blocks + ESP32 light sleep: sleep stops the USB clock and the host loses the device without being told. - **`ZephyrCompanionUSB`** (companion): the wired companion transport, over the CDC port or a plain UART (`zephcore,companion-uart`). Framing: `<` (to the node) or `>` (from it), a little-endian 16-bit length, the payload; a first byte that is printable and not `<` starts the text CLI instead. It has its own receive queue and an @@ -879,8 +881,19 @@ driver. Persisted in `prefs.json` as `zc.leds_brightness`; it is not part of the ### 7.6 WiFi / MQTT / TCP Transports -- **`adapters/wifi/ZephyrWiFiStation.c`**: WiFi STA client (ESP32) used by the observer, the repeater uplink and the WiFi companion -- **`adapters/mqtt/ZephyrMQTTPublisher.c`**: MQTT publisher for observed/uplinked packets +- **`adapters/wifi/ZephyrWiFiStation.c`**: WiFi STA client (ESP32) used by the observer, the repeater uplink and the WiFi companion. + The observer and the uplink run with the driver's WiFi power save (minimum modem sleep: the WiFi radio is off + between the access point's DTIM beacons); the WiFi companion requests power save off, as before. On ESP32 + light-sleep builds the station blocks SoC light sleep until the link is ready (DHCP, SNTP) and its client has + reported a session with `zc_wifi_station_session_up()`, and again whenever either is lost. With no such client + (the WiFi companion) the block stays for the life of the boot. While the SoC sleeps the WiFi MAC wakes it for + each beacon it listens to, and `helpers/pm_esp32_wake.c` refuses a sleep while the MAC's beacon timer is active. + On light-sleep companion builds the WiFi companion runs with power save too and reports a session at once, so + the station blocks sleep only while the link is not ready. +- **`adapters/mqtt/ZephyrMQTTPublisher.c`**: MQTT publisher for observed/uplinked packets. It reports its session to + the station: up at CONNACK, down when the session ends for any reason. Keepalive: PINGREQ once a quarter of + the 60 s keepalive is left, so a broker that enforces the keepalive without grace does not close an idle + session. - **`adapters/ota/wifi_ota.c`**: WiFi SoftAP + HTTP firmware upload to MCUboot slot1 (ESP32, requires `--sysbuild`) Companion transports, as upstream's `companion_radio`: each is a `BaseSerialInterface` @@ -888,7 +901,9 @@ Companion transports, as upstream's `companion_radio`: each is a `BaseSerialInte BLE, USB/UART and TCP can all be connected at once; every connected client gets every frame (a reply to one app also reaches the other). -- **`adapters/ble/ZephyrBLE.cpp`** (+ `ble_gatt_layout.cpp`, `ble_dfu.cpp`): BLE NUS +- **`adapters/ble/ZephyrBLE.cpp`** (+ `ble_gatt_layout.cpp`, `ble_dfu.cpp`): BLE NUS. Blocks ESP32 light sleep from + connect until the link is encrypted (pairing does not survive sleep); an encrypted link and advertising do not + block it. - **`adapters/usb/ZephyrCompanionUSB.cpp`**: USB CDC-ACM, or a plain UART (`zephcore,companion-uart` chosen node; the only link on the Bluetooth-less LoRa-E5), with the text CLI - **`adapters/transport/TcpCompanionTransport.c`**: TCP (port 5000, MeshCore `SerialWifiInterface` framing) on native Linux and on WiFi companions (`app/CompanionWifi.cpp`, `capabilities: wifi: true`) - **`adapters/transport/frame_txq.c`**: the TX queue BLE and TCP share (congestion, overflow slot, lossless replies) diff --git a/docs/CLI_commands.md b/docs/CLI_commands.md index 9a8ff20..8ea9808 100644 --- a/docs/CLI_commands.md +++ b/docs/CLI_commands.md @@ -45,7 +45,7 @@ The text CLI of every ZephCore role. Commands are sent over USB serial (CDC-ACM) | `start ota` | ESP32: start WiFi AP + HTTP OTA server. nRF52: reboot into BLE OTA DFU mode | | `stop ota` | *(ZephCore only)* Stop WiFi OTA server (ESP32 only) | | `clkreboot` | Set clock to a fixed reference time (15 May 2024 8:50pm UTC) then reboot. On a board with a hardware RTC the reference time is written to the chip too, as upstream, so the reset survives the reboot | -| `powersaving` / `powersaving on` / `powersaving off` | Upstream's command and replies. On ESP32 light-sleep repeaters (boards with `light_sleep: true`) it gates light sleep: `off` (persisted) keeps the SoC awake, which also keeps a USB Serial/JTAG console attached; `on` allows sleep again, replying `on - After N s (console window)` while the console window is open, else `on - Immediate effect`. Default **on** on those builds, and prefs from firmware that stored the field without acting on it are read as on once. nRF52/nRF54L: stored, replies `on - Immediate effect` (they always idle in System ON; `off` changes nothing). Other boards: `Board not supported` | +| `powersaving` / `powersaving on` / `powersaving off` | Upstream's command and replies. On ESP32 light-sleep builds (repeaters of boards with `light_sleep: true`, companions of boards with `light_sleep_companion: true`) it gates light sleep: `off` (persisted) keeps the SoC awake, which also keeps a USB Serial/JTAG console attached; `on` allows sleep again, replying `on - After N s (console window)` while the console window is open, else `on - Immediate effect`. Default **on** on those repeaters, and prefs from firmware that stored the field without acting on it are read as on once. Default **off** on companions: a companion sleeps only after `powersaving on`, and then not while a USB host is attached, the display is on, or a Bluetooth link is not yet encrypted. nRF52/nRF54L: stored, replies `on - Immediate effect` (they always idle in System ON; `off` changes nothing). Other boards: `Board not supported` | --- @@ -388,7 +388,7 @@ The observer role (ESP32, `boards/common/observer.conf`) has its own CLI and non | `get extra.sf` | LR2021 side detectors: the extra spreading factors currently received alongside `sf`, comma-separated (bare, no `> ` prefix), or `No extra SF configured`. Reflects the saved prefs, not what the chip accepted — if the set became invalid after an `sf`/`bw` change it is reported here but was refused at boot (a `WRN` line says so). | | `get adc.multiplier` | Battery voltage ADC calibration multiplier | | `get bootloader.ver` | Bootloader version string | -| `get pm` | *(ZephCore only)* ESP32 light-sleep builds: `> on asleep P% sleeps S/E wake tT gG oO radioR btnB [win Ns]` (`off` when powersaving is off): powersaving state, share of uptime asleep, sleeps taken out of light-sleep entries, wake causes (RTC timer, GPIO, other), sleeps that ended with the radio IRQ / user button active, and the console window left. Radio wakes should track the packets the node hears. Other builds: `Error: no light sleep on this build` | +| `get pm` | *(ZephCore only)* ESP32 light-sleep builds: `> on asleep P% sleeps S/E wake tT gG wW oO radioR btnB [win Ns]` (`off` when powersaving is off): powersaving state, share of uptime asleep, sleeps taken out of light-sleep entries, wake causes (RTC timer, GPIO, WiFi, other), sleeps that ended with the radio IRQ / user button active, and the console window left. Radio wakes should track the packets the node hears. WiFi wakes are the beacons an associated station (the uplink) wakes for: about ten a second on most access points, none without the uplink. Other builds: `Error: no light sleep on this build` | | `get pwrmgt.support` | `> supported` on nRF52 (VBUS detection, boot voltage), else `> unsupported` — upstream's reply | | `get pwrmgt.source` | nRF52: `> external` (VBUS present) or `> battery`; elsewhere `ERROR: Power management not supported` | | `get pwrmgt.bootreason` | `> Reset: ; Shutdown: `. The cause is this boot's hardware reset cause as labels (`PIN`, `SOFTWARE`, `BROWNOUT`, `POR`, `WATCHDOG`, `LOWPOWER` for a wake from System OFF, ...), followed by `(crash in , pc 0x...)` when the previous run ended in a fatal error and rebooted — resolve the pc with `addr2line` against the same build. The shutdown reason is `User Request`, `Low Voltage` or `None` | diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f7b6017..650a17c 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -302,6 +302,10 @@ it polls external state. Repeater and room server share one loop (`server_main_c - Low-battery policy for every companion (`PowerPolicy`): three low readings 30 s apart, never on external power, never on a reading under 2000 mV (no battery). - GPS: duty-cycled; holds the SoC light-sleep lock only while acquiring. +- ESP32 companions on boards that declare it can light-sleep after `powersaving on` (off by default): never + while a USB host is attached, the display is on or a BLE link is not yet encrypted. +- WiFi uplink and observer: the station uses WiFi modem sleep. On ESP32 light-sleep repeaters it holds the SoC + light-sleep lock only until the MQTT session is up, and again whenever the link or the session is lost. ### 8.4 Security - On-air: Ed25519 identities, X25519 ECDH, AES-128 + truncated HMAC (upstream scheme). diff --git a/docs/adr/0009-wifi-companion-boards.md b/docs/adr/0009-wifi-companion-boards.md index 648b337..e739ae1 100644 --- a/docs/adr/0009-wifi-companion-boards.md +++ b/docs/adr/0009-wifi-companion-boards.md @@ -7,7 +7,7 @@ Status: accepted (recorded 2026-09-25) **WiFi companion** (`CONFIG_ZEPHCORE_COMPANION_WIFI`, `boards/common/wifi_companion.conf`): auto-added to companion builds of boards whose `zephcore.yml` declares `capabilities: wifi: true`. On the PSRAM ESP32-S3 boards the WiFi heap, network buffers and the `CompanionMesh` object go to PSRAM. Four no-PSRAM boards also -declare it (Heltec V3 and Wireless Tracker at 140/130 contacts, both C6 boards at 280; the offline queue stays +declare it (Heltec V3 and Wireless Tracker at 140/130 contacts, 120/110 since 2026-10-09 when their companions gained light sleep, both C6 boards at 280; the offline queue stays 256): each sets `ZEPHCORE_MAX_CONTACTS` in `board.conf` to the largest count that links, and an S3 without PSRAM also gets bounded WiFi buffers and a measured 72 KB heap (Kconfig, `HEAP_MEM_POOL_IGNORE_MIN`). Tracker V2, C3 and the classic ESP32 boards do not fit WiFi + BLE (DRAM). Upstream's diff --git a/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md b/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md index 88beb98..813bbf8 100644 --- a/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md +++ b/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md @@ -4,10 +4,14 @@ Faster GPS fixes on three trackers, a clock that survives a reboot, fixes for US could stop responding right after boot or when the port closed during a contact sync, a contacts import that no longer stalls every few seconds, more reliable detection of clock chips, repeaters that power down an external flash chip they do not use, a formatter that erases that chip on every -board that has one, and a few fixes ported from upstream MeshCore. +board that has one, ESP32 repeaters with the WiFi uplink and observers that run much cooler and keep +their broker connection, optional light sleep for ESP32-S3 companions, and a few fixes ported from +upstream MeshCore. > [!NOTE] -> A normal upgrade keeps your identity, settings, contacts and phone pairing. +> A normal upgrade keeps your identity, settings, contacts and phone pairing. One exception: +> companions on the Heltec V3 and the Heltec Wireless Tracker now hold 120 and 110 contacts +> instead of 140 and 130; see "Companions can light-sleep" below. --- @@ -142,6 +146,87 @@ This was found while looking into a report of a XIAO nRF52840 repeater drawing a 1.17.6 than on 1.17.4 (thanks to **Kimotu**). Whether it accounts for that difference is not confirmed yet. +## ESP32 repeaters with the WiFi uplink, and observers, run cooler + +**svenlange2** reported in [#107](https://github.com/liquidraver/ZephCore/issues/107) that a Heltec +V4.3 repeater ran about 20 C cooler than stock firmware, and was back at 45 C as soon as the WiFi + +MQTT uplink was built in and connected. Thank you for the report and for the measurements: this +whole section came out of them. + +The report was right. A repeater with the uplink kept its WiFi radio fully on all the time, and on +the boards that light-sleep as repeaters it never slept again once the uplink was in the build. +Observers kept the WiFi radio on in the same way. Neither was necessary. This release changes both, +which resolves #107: + +- **WiFi power save is on** for the uplink and for observers: the WiFi radio sleeps between the + access point's beacons. The connection to the access point and to the broker is kept. +- **Light sleep works with the uplink connected**, on the boards that light-sleep as repeaters + (Heltec V3, V4, V4.3, Wireless Tracker, Wireless Tracker V2). The node sleeps between beacons once + it is connected to the broker. It stays awake while it is connecting, and for as long as the WiFi + network or the broker cannot be reached. +- **An idle connection to the broker is no longer dropped every two minutes.** Some brokers, and TLS + front ends placed before one, close a connection that has been silent for exactly the keepalive + time (60 seconds). The keepalive message was sent only when that time was already up, so on a + quiet mesh the node lost the connection every two minutes and reconnected. It is now sent 15 + seconds earlier. +- **Observers run the CPU at 80 MHz**, like every other role (240 MHz before, 160 MHz on C3 and C6 + boards). Connecting to the broker takes a few tenths of a second longer. + +On a XIAO ESP32-S3 the chip temperature went from 47 C to about 35 C with WiFi power save alone, and +to about 26 C with light sleep as well, with the uplink connected the whole time. The Heltec V4.3 +from the report is one of the boards that now sleeps with the uplink connected. A received LoRa +packet wakes the node as before, and `powersaving off` still keeps it awake. + +The MCU temperature that made the comparison possible is also from #107: see "Also in this +release". + +`get pm` now counts the wakes for WiFi beacons on their own: its reply has a new `w` field between +`g` and `o`. + +A light-sleep repeater with a display now stays awake while the display is on. The display turns +itself off 10 seconds after boot or after the last button press, as before. + +## Companions can light-sleep on seven ESP32-S3 boards (off by default) + +Companions on these boards can now put the processor into light sleep between events: + +- Heltec V3, V4, V4.3, Wireless Tracker and Wireless Tracker V2, +- Seeed XIAO ESP32-S3, +- UnitEng Station G2. + +It is **off by default**. Turn it on with `powersaving on` from the app's command line, and off +again with `powersaving off`; the setting is kept across reboots. + +This is new, and it is offered for testing: reports of how it behaves on your board are welcome, +the Station G2 most of all. Repeaters on the XIAO ESP32-S3 and the Station G2 do not light-sleep +yet. + +With it on, the node keeps advertising, stays connected to the app over Bluetooth or WiFi and +receives LoRa packets as before. It does not sleep: + +- while a computer is attached over USB, +- while the display is on, +- while a phone is pairing, +- for 10 minutes after boot and after each press of the user button. + +> [!NOTE] +> **USB on a sleeping companion:** a companion that is asleep is not seen by a computer it is +> plugged into. Press the user button after plugging in, or send `powersaving off` from the app +> first. + +WiFi companions on these boards now use WiFi power save, with powersaving on or off: the WiFi +radio sleeps between the access point's beacons and the connection is kept. + +> [!NOTE] +> **Heltec V3 and Wireless Tracker: fewer contacts.** These two run WiFi and Bluetooth without +> extra memory, and light sleep needs some of it. The contact limit goes from 140 to 120 on the +> Heltec V3 and from 130 to 110 on the Wireless Tracker, whether or not you turn powersaving on. +> A node holding more keeps the first 120 (or 110) after the update; export your contacts from +> the app before updating if you are above that. The offline message queue stays at 256. + +On the Heltec V3 a computer talking to the companion over USB also keeps it awake, from its +first message until the next reboot. + ## Formatter: the external flash chip is erased on every board that has one The nRF52 formatters (`SoftDevice_v6_formatter` and `SoftDevice_v7_formatter`, as `.uf2` and as the @@ -200,9 +285,9 @@ matches the SoftDevice version of your bootloader. It also brings a fix for boards on WiFi: the starting sequence number of a TCP connection (the companion port and the update page) was predictable, and is now derived from a secret as intended. - **Heltec V3 and Wireless Tracker companions** (WiFi and Bluetooth together, no PSRAM) had under - 1 KB of memory left; they now have about 2.5 KB. The Bluetooth controller's stack on ESP32-S3 and - ESP32-C3 companions was 4 KB and is now 2 KB; it uses about 1.1 KB with WiFi connected. Nothing - changes in use. + 1 KB of memory left; they now have about 2.5 KB, with light sleep included. The Bluetooth + controller's stack on ESP32-S3 and ESP32-C3 companions was 4 KB and is now 2 KB; it uses about + 1.1 KB with WiFi connected. - **Documentation**: the README was reorganised, build and flashing instructions moved to `docs/BUILDING.md`, and the example board and porting guide were rewritten for the current board layout. diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index 1f4d06f..8bb9c67 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -172,14 +172,11 @@ if(ZEPHCORE_PLATFORM STREQUAL "esp32") # first-order term in a battery node's draw — unlike nRF52, which already # idles at microamps. Every ESP32 build runs at 80 MHz, matching Arduino # MeshCore; hal_espressif derives ESP_DEFAULT_CPU_FREQ_MHZ from cpu@0, so no - # module patch is needed. Observers keep the SoC maximum (240 MHz S3/classic, - # 160 MHz C3/C6): full WiFi + TLS + MQTT on mains or big-panel solar, so - # throughput matters more than current. "Don't apply the overlay" rather than - # an override, so the per-SoC maximum is inherited, not hardcoded. - if(NOT ZEPHCORE_BUILD_ROLE STREQUAL "observer") - zephcore_add_overlay(${_zc}/esp32_cpu_80mhz.overlay) - message(STATUS " ESP32 CPU: 80 MHz (observers keep the SoC maximum)") - endif() + # module patch is needed. Every role, observers included: 80 MHz is the + # lowest clock WiFi runs at and carries WiFi + TLS + MQTT + # (devdocs/lld/13-power-management.md, section 12). + zephcore_add_overlay(${_zc}/esp32_cpu_80mhz.overlay) + message(STATUS " ESP32 CPU: 80 MHz") # SoC temperature sensor (MCU temperature in telemetry, image-calibration # drift on LR11xx/LR2021): every role, every SoC that has the node. The @@ -188,18 +185,24 @@ if(ZEPHCORE_PLATFORM STREQUAL "esp32") zephcore_add_overlay(${_zc}/esp32_coretemp.overlay) endif() - # Light sleep: REPEATERS on boards that declare it. 80 MHz cuts the idle draw; - # light sleep is what removes it (mechanism and requirements: - # boards/common/pm_esp32.conf). Repeaters only: the Espressif HCI driver takes - # no pm_policy locks, so a companion would light-sleep mid-advertising or - # mid-connection; repeater.conf sets CONFIG_BT=n. Which boards qualify is a - # hardware fact (DIO1 on an RTC-wake-capable GPIO, and validated on hardware), - # so the board declares it: `capabilities: light_sleep: true` in its - # zephcore.yml. pm_esp32.conf lists which boards fail the wiring test and why; - # a board that passes it but was never validated (v4_r8) stays undeclared. + # Light sleep: on boards that declare it. 80 MHz cuts the idle draw; light + # sleep is what removes it (mechanism and requirements: + # boards/common/pm_esp32.conf). Which boards qualify is a hardware fact + # (the radio IRQ and button wake the SoC, pins are held, validated on + # hardware), so the board declares it in its zephcore.yml: + # `light_sleep: true` for the repeater, `light_sleep_companion: true` for the + # companion. Two lines because they differ: a repeater sleeps by default, so + # its line waits for validation on hardware (v4_r8, which only passes the + # wiring test, stays undeclared); a companion sleeps only after + # `powersaving on`, and its build costs about 4 KB of DRAM, which a WiFi + + # BLE companion without PSRAM gives up in contacts + # (devdocs/lld/13-power-management.md, section 13). if(ZEPHCORE_BUILD_ROLE STREQUAL "repeater" AND ZEPHCORE_BOARD_LIGHT_SLEEP) zephcore_add_conf(${_zc}/pm_esp32.conf) - message(STATUS " ESP32 light sleep: auto-enabled (DIO1 wake-capable repeater board)") + message(STATUS " ESP32 light sleep: auto-enabled (repeater, board declares it)") + elseif(ZEPHCORE_BUILD_ROLE STREQUAL "companion" AND ZEPHCORE_BOARD_LIGHT_SLEEP_COMPANION) + zephcore_add_conf(${_zc}/pm_esp32.conf) + message(STATUS " ESP32 light sleep: auto-enabled (companion, board declares it; opt-in at run time)") endif() # WiFi OTA for S3/C-series repeaters: WiFi AP + HTTP server + MCUboot image diff --git a/zephcore/adapters/ble/ZephyrBLE.cpp b/zephcore/adapters/ble/ZephyrBLE.cpp index 49fc0c9..2052b3d 100644 --- a/zephcore/adapters/ble/ZephyrBLE.cpp +++ b/zephcore/adapters/ble/ZephyrBLE.cpp @@ -34,6 +34,7 @@ LOG_MODULE_REGISTER(zephcore_ble, CONFIG_ZEPHCORE_BLE_LOG_LEVEL); #include "ZephyrBLE.h" #include "ble_internal.h" #include "frame_txq.h" +#include /* ========== Constants ========== */ @@ -293,6 +294,22 @@ static void build_device_name_and_adv(const char *name_from_prefs) /* ========== Connection callbacks ========== */ +/* No SoC light sleep from connect until the link is encrypted (or gone): + * pairing does not survive it (devdocs/lld/13-power-management.md, section + * 13). An encrypted link may sleep between connection events. */ +static atomic_t unencrypted_hold; + +static void unencrypted_hold_set(bool hold) +{ + if (hold) { + if (atomic_cas(&unencrypted_hold, 0, 1)) { + zc_pm_block_sleep(); + } + } else if (atomic_cas(&unencrypted_hold, 1, 0)) { + zc_pm_unblock_sleep(); + } +} + static void connected(struct bt_conn *conn, uint8_t err) { char addr[BT_ADDR_LE_STR_LEN]; @@ -304,6 +321,7 @@ static void connected(struct bt_conn *conn, uint8_t err) LOG_INF("connected: %s", addr); current_conn = bt_conn_ref(conn); + unencrypted_hold_set(true); /* Zephyr stops advertising internally when the conn slot is consumed * (BT_MAX_CONN=1 — there's no slot left to advertise from). Sync our @@ -340,6 +358,7 @@ static void disconnected(struct bt_conn *conn, uint8_t reason) LOG_INF("disconnected: %s reason 0x%02x", addr, reason); k_work_cancel_delayable(&sec_timeout_work); + unencrypted_hold_set(false); if (conn == current_conn) { bt_conn_unref(current_conn); @@ -393,6 +412,7 @@ static void security_changed(struct bt_conn *conn, bt_security_t level, enum bt_ * so stand the unpaired-connection timeout down. */ if (level >= BT_SECURITY_L2) { k_work_cancel_delayable(&sec_timeout_work); + unencrypted_hold_set(false); } /* Enable TX when we have sufficient security (level 2+ = encrypted). diff --git a/zephcore/adapters/mqtt/ZephyrMQTTPublisher.c b/zephcore/adapters/mqtt/ZephyrMQTTPublisher.c index 79721c8..077cb62 100644 --- a/zephcore/adapters/mqtt/ZephyrMQTTPublisher.c +++ b/zephcore/adapters/mqtt/ZephyrMQTTPublisher.c @@ -9,6 +9,9 @@ * 4. Publish retained "online" status (LWT handles "offline" on disconnect) * 5. Poll loop: drain publish queue + mqtt_input() keepalive * 6. On error/disconnect: publish "offline", back off 5s, goto 1 + * + * The session (CONNACK until the poll loop ends) is reported to the WiFi + * station, which blocks SoC light sleep outside it. */ #include "ZephyrMQTTPublisher.h" @@ -320,12 +323,20 @@ static void run_poll_loop(void) } } - /* Send keepalive ping if needed */ - int rc = mqtt_live(&s_client); - if (rc < 0 && rc != -EAGAIN) { - LOG_WRN("mqtt_live error: %d", rc); - s_connected = false; - return; + /* Keepalive ping, a quarter of the keepalive early. mqtt_live() + * pings only once the keepalive has fully elapsed, and a broker + * that enforces it without grace closes the session first + * (devdocs/lld/13-power-management.md, section 12). */ + int left_ms = mqtt_keepalive_time_left(&s_client); + + if (left_ms >= 0 && left_ms <= (int)s_client.keepalive * 250) { + int rc = mqtt_ping(&s_client); + + if (rc < 0 && rc != -EAGAIN) { + LOG_WRN("mqtt_ping error: %d", rc); + s_connected = false; + return; + } } if (!s_connected) { @@ -435,12 +446,18 @@ static void mqtt_thread_fn(void *p1, void *p2, void *p3) /* Connected — reset the failure backoff */ s_retry_backoff_s = RETRY_BACKOFF_MIN_S; + /* Session up: the station may let the SoC light-sleep until the + * poll loop ends. */ + zc_wifi_station_session_up(true); + /* Publish retained "online" status */ publish_status(&s_client, true); /* Run poll loop until disconnect or reconnect request */ run_poll_loop(); + zc_wifi_station_session_up(false); + /* Publish "offline" before closing (best-effort) */ if (s_connected) { publish_status(&s_client, false); diff --git a/zephcore/adapters/usb/ZephyrCompanionUSB.cpp b/zephcore/adapters/usb/ZephyrCompanionUSB.cpp index adb5887..3c2972b 100644 --- a/zephcore/adapters/usb/ZephyrCompanionUSB.cpp +++ b/zephcore/adapters/usb/ZephyrCompanionUSB.cpp @@ -22,6 +22,7 @@ LOG_MODULE_REGISTER(zephcore_usb, CONFIG_ZEPHCORE_USB_LOG_LEVEL); #include "ZephyrCompanionUSB.h" #include "ZephyrUSBCDC.h" +#include #include "companion_framing.h" #define USB_RING_BUF_SIZE 512 /* USB RX ring buffer size */ @@ -223,6 +224,11 @@ static void usb_session_begin(uint8_t log_tag, bool is_text) } usb_session_active = true; usb_session_is_text = is_text; +#if !COMPANION_HAS_DTR + /* A plain UART has no DTR: the session never ends, and UART RX cannot + * wake a sleeping SoC. No light sleep from here on. */ + zc_pm_block_sleep(); +#endif LOG_INF("usb_rx: first traffic 0x%02x, session started (%s)", log_tag, is_text ? "text" : "binary"); usb_link_update(); diff --git a/zephcore/adapters/usb/ZephyrUSBCDC.cpp b/zephcore/adapters/usb/ZephyrUSBCDC.cpp index 81805b7..9dc36ce 100644 --- a/zephcore/adapters/usb/ZephyrUSBCDC.cpp +++ b/zephcore/adapters/usb/ZephyrUSBCDC.cpp @@ -30,6 +30,7 @@ LOG_MODULE_REGISTER(zephcore_usbd, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL); #include +#include /* ===== Per-role descriptor strings ====== */ #define ZEPHCORE_USB_VID 0x2fe3 /* Zephyr Project VID */ @@ -88,6 +89,23 @@ static void enter_bootloader(void) CODE_UNREACHABLE; } +/* A host is on the bus (it reset, configured or resumed us and has not + * suspended it since). No SoC light sleep while one is: sleep stops the USB + * clock, and the host loses the device without being told + * (devdocs/lld/13-power-management.md, section 13). */ +static atomic_t s_host_present; + +static void host_present(bool present) +{ + if (present) { + if (atomic_cas(&s_host_present, 0, 1)) { + zc_pm_block_sleep(); + } + } else if (atomic_cas(&s_host_present, 1, 0)) { + zc_pm_unblock_sleep(); + } +} + /* The host went away without a DTR=0: report it as a DTR drop. */ static void host_gone(void) { @@ -105,6 +123,15 @@ static void usbd_msg_callback(struct usbd_context *const ctx, { ARG_UNUSED(ctx); + if (msg->type == USBD_MSG_SUSPEND || msg->type == USBD_MSG_VBUS_REMOVED) { + host_present(false); + } else if (msg->type == USBD_MSG_RESET || msg->type == USBD_MSG_RESUME || + msg->type == USBD_MSG_CONFIGURATION || + msg->type == USBD_MSG_CDC_ACM_LINE_CODING || + msg->type == USBD_MSG_CDC_ACM_CONTROL_LINE_STATE) { + host_present(true); + } + if (msg->type == USBD_MSG_CDC_ACM_LINE_CODING) { uint32_t baudrate = 0; if (uart_line_ctrl_get(msg->dev, UART_LINE_CTRL_BAUD_RATE, @@ -309,4 +336,5 @@ extern "C" void zephcore_usbd_detach(void) (void)usbd_disable(&zephcore_usbd); s_initialized = false; s_dtr_active = false; + host_present(false); } diff --git a/zephcore/adapters/wifi/ZephyrWiFiStation.c b/zephcore/adapters/wifi/ZephyrWiFiStation.c index b6fae1c..e8ddf2f 100644 --- a/zephcore/adapters/wifi/ZephyrWiFiStation.c +++ b/zephcore/adapters/wifi/ZephyrWiFiStation.c @@ -37,11 +37,42 @@ K_EVENT_DEFINE(g_wifi_events); static const char *s_ssid; static const char *s_psk; static void (*s_time_sync_cb)(uint32_t unix_ts); +static bool s_power_save; +static bool s_started; /* Protect link-up vs. SNTP state */ static volatile bool s_wifi_link_up; /* WiFi associate event received */ static volatile bool s_wifi_ready; /* DHCP + SNTP done */ +/* ========== Light-sleep block ========== */ + +/* Held unless the link is ready and the client's session is up + * (devdocs/lld/13-power-management.md, section 12). Called from the net_mgmt + * thread and from the client's thread. */ +static K_MUTEX_DEFINE(s_sleep_mutex); +static volatile bool s_session_up; +static bool s_sleep_blocked; + +static void sleep_block_update(void) +{ + if (!s_started) { + return; + } + + k_mutex_lock(&s_sleep_mutex, K_FOREVER); + + bool block = !(s_wifi_ready && s_session_up); + + if (block && !s_sleep_blocked) { + zc_pm_block_sleep(); + } else if (!block && s_sleep_blocked) { + zc_pm_unblock_sleep(); + } + s_sleep_blocked = block; + + k_mutex_unlock(&s_sleep_mutex); +} + /* ========== Reconnect work ========== */ static void connect_work_fn(struct k_work *work); @@ -68,6 +99,7 @@ static void do_sntp_and_signal(void) #endif s_wifi_ready = true; + sleep_block_update(); k_event_post(&g_wifi_events, WIFI_READY_BIT); LOG_INF("WiFi ready (DHCP+SNTP done)"); } @@ -104,18 +136,19 @@ static void wifi_event_handler(struct net_mgmt_event_callback *cb, if (success) { LOG_INF("WiFi link up (SSID: %s)", s_ssid ? s_ssid : "?"); s_wifi_link_up = true; - /* Disable power save — WIFI_PS_MIN_MODEM (default) sleeps between - * DTIM beacons, which delays ACKs and causes the MQTT broker's TCP - * stack to retransmit and eventually RST the connection (-ECONNRESET - * in the poll loop). WIFI_PS_DISABLED keeps the radio always awake. */ - struct wifi_ps_params ps = { - .enabled = WIFI_PS_DISABLED, - .type = WIFI_PS_PARAM_STATE, - }; - if (net_mgmt(NET_REQUEST_WIFI_PS, iface, &ps, sizeof(ps)) < 0) { - LOG_WRN("Failed to disable WiFi power save"); - } else { - LOG_INF("WiFi power save disabled"); + if (!s_power_save) { + /* Power save off: the WiFi radio stays on between + * beacons. Left alone, the driver runs minimum modem + * sleep. */ + struct wifi_ps_params ps = { + .enabled = WIFI_PS_DISABLED, + .type = WIFI_PS_PARAM_STATE, + }; + if (net_mgmt(NET_REQUEST_WIFI_PS, iface, &ps, sizeof(ps)) < 0) { + LOG_WRN("Failed to disable WiFi power save"); + } else { + LOG_INF("WiFi power save disabled"); + } } /* DHCP will fire ipv4_event_handler when lease is obtained */ } else { @@ -128,6 +161,7 @@ static void wifi_event_handler(struct net_mgmt_event_callback *cb, LOG_INF("WiFi disconnected"); s_wifi_link_up = false; s_wifi_ready = false; + sleep_block_update(); k_event_clear(&g_wifi_events, WIFI_READY_BIT); /* Reconnect after 5s backoff */ k_work_reschedule(&connect_work, K_SECONDS(5)); @@ -181,20 +215,21 @@ static void connect_work_fn(struct k_work *work) /* ========== Public API ========== */ void zc_wifi_station_start(const char *ssid, const char *psk, - void (*time_sync_cb)(uint32_t unix_ts)) + void (*time_sync_cb)(uint32_t unix_ts), bool power_save) { s_ssid = ssid; s_psk = psk; s_time_sync_cb = time_sync_cb; + s_power_save = power_save; 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(); + /* No light sleep until the link is ready and the client's session is up. + * There is no zc_wifi_station_stop(): a station stays for the life of the + * boot. */ + s_started = true; + sleep_block_update(); + zc_pm_wifi_station_started(); /* Register WiFi event callback */ net_mgmt_init_event_callback(&wifi_cb, wifi_event_handler, @@ -222,10 +257,17 @@ void zc_wifi_station_reconnect(void) /* Clear state and immediately reschedule connect */ s_wifi_link_up = false; s_wifi_ready = false; + sleep_block_update(); k_event_clear(&g_wifi_events, WIFI_READY_BIT); k_work_reschedule(&connect_work, K_MSEC(200)); } +void zc_wifi_station_session_up(bool up) +{ + s_session_up = up; + sleep_block_update(); +} + bool zc_wifi_station_is_connected(void) { return s_wifi_ready; diff --git a/zephcore/adapters/wifi/ZephyrWiFiStation.h b/zephcore/adapters/wifi/ZephyrWiFiStation.h index f3d6cbc..0230cf9 100644 --- a/zephcore/adapters/wifi/ZephyrWiFiStation.h +++ b/zephcore/adapters/wifi/ZephyrWiFiStation.h @@ -33,11 +33,24 @@ extern struct k_event g_wifi_events; * ssid, psk — the network; pointers kept (read at every connect), so they * must remain valid. psk "" = open network. * time_sync_cb — called once after SNTP with Unix timestamp; may be NULL. + * power_save — true: run with the driver's WiFi power save (minimum modem + * sleep: the WiFi radio is off between the access point's DTIM + * beacons). false: request power save off at every link-up. * * Must be called once from main() before any other wifi_station_* calls. + * + * ESP32 light-sleep builds: the station blocks SoC light sleep until the link + * is ready and a client has reported its session up, and again whenever either + * is lost. With no such client the block stays for the life of the boot. */ void zc_wifi_station_start(const char *ssid, const char *psk, - void (*time_sync_cb)(uint32_t unix_ts)); + void (*time_sync_cb)(uint32_t unix_ts), bool power_save); + +/* + * The client's session over the link (the MQTT publisher's broker session) is + * up, or has ended. Safe to call from any thread. + */ +void zc_wifi_station_session_up(bool up); /* * Trigger an immediate reconnect (e.g. after credential change via CLI). diff --git a/zephcore/app/CompanionWifi.cpp b/zephcore/app/CompanionWifi.cpp index cbbbe08..e2b1831 100644 --- a/zephcore/app/CompanionWifi.cpp +++ b/zephcore/app/CompanionWifi.cpp @@ -13,6 +13,7 @@ #include #include #include +#include LOG_MODULE_REGISTER(zephcore_wifi_companion, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); @@ -35,8 +36,17 @@ void companion_wifi_start(const NodePrefs &prefs) memcpy(s_pwd, prefs.wifi_pwd, sizeof(s_pwd)); LOG_INF("WiFi companion: joining %s", s_ssid); /* No SNTP time: the app sets the companion's clock, and time only moves - * forward (see time_sync.h). */ - zc_wifi_station_start(s_ssid, s_pwd, nullptr); + * forward (see time_sync.h). + * + * Light-sleep builds: WiFi power save on (the WiFi library gives its sleep + * lock up only in power save), and no client session to wait for, so the + * station blocks sleep only while the link is not ready. Other builds: + * power save off, as before (devdocs/lld/13-power-management.md, + * sections 12 and 13). */ + zc_wifi_station_start(s_ssid, s_pwd, nullptr, ZC_PM_LIGHT_SLEEP != 0); + if (ZC_PM_LIGHT_SLEEP) { + zc_wifi_station_session_up(true); + } } bool companion_wifi_cli(const char *command, NodePrefs &prefs, void (*save)(void), char *reply) diff --git a/zephcore/app/RepeaterMesh.cpp b/zephcore/app/RepeaterMesh.cpp index 68d5416..2787868 100644 --- a/zephcore/app/RepeaterMesh.cpp +++ b/zephcore/app/RepeaterMesh.cpp @@ -1102,7 +1102,8 @@ void RepeaterMesh::begin(RepeaterDataStore* store) { if (isUplinkEnabled() && _uplink_creds.wifi_ssid[0] && _uplink_creds.mqtt_host[0]) { s_uplink_mesh = this; - zc_wifi_station_start(_uplink_creds.wifi_ssid, _uplink_creds.wifi_psk, uplink_time_sync_cb); + zc_wifi_station_start(_uplink_creds.wifi_ssid, _uplink_creds.wifi_psk, uplink_time_sync_cb, + true /* WiFi power save */); mqtt_publisher_start(&_uplink_creds, _prefs.node_name, _uplink_status_topic, _uplink_packets_topic); mqtt_publisher_set_connect_cb([]() { diff --git a/zephcore/app/main_observer.cpp b/zephcore/app/main_observer.cpp index 337eefe..0435487 100644 --- a/zephcore/app/main_observer.cpp +++ b/zephcore/app/main_observer.cpp @@ -403,7 +403,8 @@ int main(void) print_banner(); /* Start WiFi (non-blocking — MQTT thread waits for WIFI_READY_BIT) */ - zc_wifi_station_start(s_creds.wifi_ssid, s_creds.wifi_psk, time_sync_cb); + zc_wifi_station_start(s_creds.wifi_ssid, s_creds.wifi_psk, time_sync_cb, + true /* WiFi power save */); /* Start MQTT publisher thread */ char client_id[64]; diff --git a/zephcore/boards/common/esp32_cpu_80mhz.overlay b/zephcore/boards/common/esp32_cpu_80mhz.overlay index cf1bf75..a2c061d 100644 --- a/zephcore/boards/common/esp32_cpu_80mhz.overlay +++ b/zephcore/boards/common/esp32_cpu_80mhz.overlay @@ -26,11 +26,10 @@ * classic) and the single-core RISC-V parts (C3, C6) alike. Referencing * &cpu1 here would be a dtc error on the latter. * - * Applied automatically to non-observer ESP32 builds — see the ESP32 CPU - * clock block in CMakeLists.txt. Kept out of the per-board DTS files on - * purpose: one place to change, and it makes the observer exception a simple - * "don't include this" rather than an override that would have to know each - * SoC's maximum (240 MHz on S3/classic, 160 MHz on C3/C6). + * Applied automatically to every ESP32 build — see the ESP32 CPU clock block + * in CMakeLists.txt. Kept out of the per-board DTS files on purpose: one place + * to change. Without it a build runs at its SoC's maximum (240 MHz on + * S3/classic, 160 MHz on C3/C6). */ &cpu0 { diff --git a/zephcore/boards/common/pm_esp32.conf b/zephcore/boards/common/pm_esp32.conf index 0ba6318..e0dd69c 100644 --- a/zephcore/boards/common/pm_esp32.conf +++ b/zephcore/boards/common/pm_esp32.conf @@ -1,8 +1,9 @@ # ESP32 system power management — light sleep between events. # # AUTO-ENABLED for repeater builds on boards whose zephcore.yml declares -# `light_sleep: true` (cmake/zephcore_board.cmake, CMakeLists.txt). Passing it -# by hand is supported for testing another board: +# `light_sleep: true`, and for companion builds on boards that declare +# `light_sleep_companion: true` (cmake/zephcore_board.cmake, CMakeLists.txt). +# Passing it by hand is supported for testing another board: # # west build -b xiao_esp32s3/esp32s3/procpu zephcore --pristine --sysbuild -- # -DEXTRA_CONF_FILE="boards/common/repeater.conf;boards/common/pm_esp32.conf" # @@ -37,16 +38,24 @@ # button to reopen the window, or send `powersaving off` (persisted) over LoRa; # `powersaving on` allows sleep again. Light-sleep builds default to on. # -# WiFi: the ESP32 WiFi driver starts WiFi at boot (NULL mode), and a started -# WiFi library holds its modem lock, so NO S3 repeater (they all carry WiFi OTA) -# ever light-slept before 2026-09-26. helpers/pm_esp32_wake.c stops WiFi at -# boot; `start ota` (AP) and a station connect start it again. While WiFi is in -# use a pm_policy lock is held — the OTA session for its duration -# (adapters/ota/wifi_ota.c), an uplink or observer station permanently -# (adapters/wifi/ZephyrWiFiStation.c). +# WiFi: the ESP32 WiFi driver starts WiFi at boot (NULL mode), and the WiFi +# library holds its modem lock for as long as the WiFi radio is on, so NO S3 +# repeater (they all carry WiFi OTA) ever light-slept before 2026-09-26. +# helpers/pm_esp32_wake.c stops WiFi at boot; `start ota` (AP) and a station +# connect start it again. An OTA session holds a pm_policy lock for its +# duration (adapters/ota/wifi_ota.c). A station (the MQTT uplink) runs in WiFi +# power save: the library gives its lock up between beacons and the WiFi MAC +# wakes the SoC for each one. The station holds a lock of its own until its +# MQTT session is up, and again whenever the link or the session is lost +# (adapters/wifi/ZephyrWiFiStation.c); a node whose access point is away stays +# awake. devdocs/lld/13-power-management.md, section 12. # -# REPEATERS ONLY for now. Companions keep BLE up; making the controller's -# modem sleep coexist with system light sleep is the open question in LLD 13. +# COMPANIONS sleep only after `powersaving on` (their default is off), and then +# not while a USB host is on the bus, the display is on, or a BLE link is not +# yet encrypted. The BLE controller gives its lock up between events and its +# wake timer bounds each sleep (CONFIG_PM_CUSTOM_TICKS_HOOK below). The build +# costs about 4 KB of DRAM, which is why the companion has its own capability +# line. devdocs/lld/13-power-management.md, section 13. # # CHECKING A BOARD: `get pm` shows the share of time asleep, the sleep count and # wake causes (t = RTC timer, g = GPIO, o = other) and per-pin wake counts. diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.conf b/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.conf index e75c74c..058f1f3 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.conf +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.conf @@ -49,8 +49,10 @@ CONFIG_POWER_DOMAIN=n CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS=3000 # WiFi + BLE companion without PSRAM (`capabilities: wifi` in zephcore.yml): -# contacts are what gives. 140 is the largest count that links with ~2 KB DRAM -# to spare (188 B per contact; measured 2026-09-24, see -# devdocs/lld/08-connectivity.md §14). The offline queue stays at 256. An -# existing contacts file holding more is truncated to 140 on first load. -CONFIG_ZEPHCORE_MAX_CONTACTS=140 +# contacts are what gives (188 B each; devdocs/lld/08-connectivity.md §14). +# 120 links with ~2 KB DRAM to spare with light sleep in the build +# (`light_sleep_companion`, about 4 KB; 140 before it, 2026-10-09, +# devdocs/lld/13-power-management.md section 13). The offline queue stays +# at 256. An existing contacts file holding more is truncated to 120 on +# first load. +CONFIG_ZEPHCORE_MAX_CONTACTS=120 diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v3/zephcore.yml b/zephcore/boards/esp32/heltec_wifi_lora32_v3/zephcore.yml index 5471959..46f79d1 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v3/zephcore.yml +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v3/zephcore.yml @@ -3,6 +3,7 @@ target: heltec_wifi_lora32_v3/esp32s3/procpu capabilities: wifi: true light_sleep: true + light_sleep_companion: true release: roles: [companion, repeater] catalog: diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v4/zephcore.yml b/zephcore/boards/esp32/heltec_wifi_lora32_v4/zephcore.yml index 4e55c95..c25e47a 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v4/zephcore.yml +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v4/zephcore.yml @@ -3,6 +3,7 @@ target: heltec_wifi_lora32_v4/esp32s3/procpu capabilities: wifi: true light_sleep: true + light_sleep_companion: true release: roles: [companion, repeater] catalog: diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v43/zephcore.yml b/zephcore/boards/esp32/heltec_wifi_lora32_v43/zephcore.yml index 9fd6950..a3bc966 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v43/zephcore.yml +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v43/zephcore.yml @@ -3,6 +3,7 @@ target: heltec_wifi_lora32_v43/esp32s3/procpu capabilities: wifi: true light_sleep: true + light_sleep_companion: true release: roles: [companion, repeater] catalog: diff --git a/zephcore/boards/esp32/heltec_wireless_tracker/board.conf b/zephcore/boards/esp32/heltec_wireless_tracker/board.conf index 0341046..8249214 100644 --- a/zephcore/boards/esp32/heltec_wireless_tracker/board.conf +++ b/zephcore/boards/esp32/heltec_wireless_tracker/board.conf @@ -32,8 +32,10 @@ CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS=3000 # WiFi + BLE companion without PSRAM (`capabilities: wifi` in zephcore.yml): -# contacts are what gives. 130 is the largest count that links with ~2 KB DRAM -# to spare (188 B per contact; measured 2026-09-24, see -# devdocs/lld/08-connectivity.md §14). The offline queue stays at 256. An -# existing contacts file holding more is truncated to 130 on first load. -CONFIG_ZEPHCORE_MAX_CONTACTS=130 +# contacts are what gives (188 B each; devdocs/lld/08-connectivity.md §14). +# 110 links with ~2 KB DRAM to spare with light sleep in the build +# (`light_sleep_companion`, about 4 KB; 130 before it, 2026-10-09, +# devdocs/lld/13-power-management.md section 13). The offline queue stays +# at 256. An existing contacts file holding more is truncated to 110 on +# first load. +CONFIG_ZEPHCORE_MAX_CONTACTS=110 diff --git a/zephcore/boards/esp32/heltec_wireless_tracker/zephcore.yml b/zephcore/boards/esp32/heltec_wireless_tracker/zephcore.yml index e108f00..3ce3b19 100644 --- a/zephcore/boards/esp32/heltec_wireless_tracker/zephcore.yml +++ b/zephcore/boards/esp32/heltec_wireless_tracker/zephcore.yml @@ -3,6 +3,7 @@ target: heltec_wireless_tracker/esp32s3/procpu capabilities: wifi: true light_sleep: true + light_sleep_companion: true release: roles: [companion, repeater] catalog: diff --git a/zephcore/boards/esp32/heltec_wireless_tracker_v2/zephcore.yml b/zephcore/boards/esp32/heltec_wireless_tracker_v2/zephcore.yml index ffe28fb..7850570 100644 --- a/zephcore/boards/esp32/heltec_wireless_tracker_v2/zephcore.yml +++ b/zephcore/boards/esp32/heltec_wireless_tracker_v2/zephcore.yml @@ -2,6 +2,7 @@ target: heltec_wireless_tracker_v2/esp32s3/procpu capabilities: light_sleep: true + light_sleep_companion: true release: roles: [companion, repeater] catalog: diff --git a/zephcore/boards/esp32/station_g2/zephcore.yml b/zephcore/boards/esp32/station_g2/zephcore.yml index de8eafa..5e1b3a2 100644 --- a/zephcore/boards/esp32/station_g2/zephcore.yml +++ b/zephcore/boards/esp32/station_g2/zephcore.yml @@ -2,6 +2,7 @@ target: station_g2/esp32s3/procpu capabilities: wifi: true + light_sleep_companion: true release: roles: [companion, repeater] catalog: diff --git a/zephcore/boards/esp32/xiao_esp32s3/zephcore.yml b/zephcore/boards/esp32/xiao_esp32s3/zephcore.yml index 0391df8..f198f49 100644 --- a/zephcore/boards/esp32/xiao_esp32s3/zephcore.yml +++ b/zephcore/boards/esp32/xiao_esp32s3/zephcore.yml @@ -2,6 +2,7 @@ target: xiao_esp32s3/esp32s3/procpu capabilities: wifi: true + light_sleep_companion: true release: roles: [companion, repeater] catalog: diff --git a/zephcore/boards/example_board/README.md b/zephcore/boards/example_board/README.md index 61760ca..6b368cf 100644 --- a/zephcore/boards/example_board/README.md +++ b/zephcore/boards/example_board/README.md @@ -207,7 +207,10 @@ Only `lora0` is required. Everything else enables its feature by being present. - **Manifest capabilities:** `wifi: true` gives the companion a WiFi transport and belongs only on boards with the RAM for WiFi and BLE together ([ADR 0009](../../../docs/adr/0009-wifi-companion-boards.md)). `light_sleep: true` makes repeaters light-sleep; the requirements are in `boards/common/pm_esp32.conf`, and - it is declared only after it was validated on the hardware. Either way, give NSS, RESET and every MCU-driven + it is declared only after it was validated on the hardware. `light_sleep_companion: true` gives the companion + build light sleep (off until the user sends `powersaving on`); it needs the same pin holds and wake pins and + about 4 KB of free DRAM, which a WiFi + BLE companion without PSRAM pays for in contacts. Being opt-in, it may + be declared before `light_sleep`. Either way, give NSS, RESET and every MCU-driven RF switch or FEM line `ESP32_GPIO_SLEEP_HOLD_EN` from the start. - **RAM, not flash, limits the companion.** Boards without PSRAM lower `MAX_CONTACTS`; the classic ESP32 also trims channels and the offline queue. diff --git a/zephcore/boards/example_board/zephcore.yml b/zephcore/boards/example_board/zephcore.yml index 166a934..6623ed2 100644 --- a/zephcore/boards/example_board/zephcore.yml +++ b/zephcore/boards/example_board/zephcore.yml @@ -27,6 +27,10 @@ target: my_board # light_sleep: true # repeater light-sleeps between events. Declare it only # # after boards/common/pm_esp32.conf's checks pass on # # hardware; qualifying wiring alone is not enough +# light_sleep_companion: true # the companion can light-sleep (off until +# # `powersaving on`). Needs the same pin holds and +# # wake pins, and about 4 KB of DRAM. It does not need +# # light_sleep: opt-in, so it may come first # Leave `release:` out while the board is in bring-up: it then builds locally # and is listed as "source-only", but no release carries firmware for it. diff --git a/zephcore/cmake/zephcore_board.cmake b/zephcore/cmake/zephcore_board.cmake index 141bd95..d314158 100644 --- a/zephcore/cmake/zephcore_board.cmake +++ b/zephcore/cmake/zephcore_board.cmake @@ -10,6 +10,7 @@ # ZEPHCORE_BOARD_DIR boards//, or "" if none # ZEPHCORE_PLATFORM nrf52 | nrf54l | esp32 | mg24 | stm32wl | linux | "" # ZEPHCORE_BOARD_LIGHT_SLEEP TRUE if the board manifest declares it +# ZEPHCORE_BOARD_LIGHT_SLEEP_COMPANION TRUE if the board manifest declares it # ZEPHCORE_BOARD_WIFI TRUE if the board manifest declares it # zephcore_board_file( ) # absolute path of inside the board dir, or "". @@ -83,6 +84,7 @@ endif() # this runs before Zephyr has set up Python; `board_manifest.py check` enforces # the exact two-space-indented ` : true|false` form this relies on. set(ZEPHCORE_BOARD_LIGHT_SLEEP FALSE) +set(ZEPHCORE_BOARD_LIGHT_SLEEP_COMPANION FALSE) set(ZEPHCORE_BOARD_WIFI FALSE) zephcore_board_file(_zb_manifest zephcore.yml) if(_zb_manifest) @@ -90,6 +92,10 @@ if(_zb_manifest) if(_zb_ls) set(ZEPHCORE_BOARD_LIGHT_SLEEP TRUE) endif() + file(STRINGS "${_zb_manifest}" _zb_lsc REGEX "^ light_sleep_companion: true[ \t]*$") + if(_zb_lsc) + set(ZEPHCORE_BOARD_LIGHT_SLEEP_COMPANION TRUE) + endif() file(STRINGS "${_zb_manifest}" _zb_wifi REGEX "^ wifi: true[ \t]*$") if(_zb_wifi) set(ZEPHCORE_BOARD_WIFI TRUE) diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index 89139f9..a9816f1 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -238,10 +238,12 @@ static inline void auto_shutdown_upgrade(NodePrefs *p) { } /* `powersaving` gates ESP32 light sleep (helpers/pm_esp32_wake.c). On a - * light-sleep build the default is on: those builds slept unconditionally - * before the switch existed, and the stored 0 carried no intent. Elsewhere - * it is upstream's default, off. */ -#if defined(CONFIG_PM) && defined(CONFIG_SOC_FAMILY_ESPRESSIF_ESP32) + * light-sleep repeater or room server the default is on: those builds slept + * unconditionally before the switch existed, and the stored 0 carried no + * intent. Elsewhere, light-sleep companions included, it is upstream's + * default, off. */ +#if defined(CONFIG_PM) && defined(CONFIG_SOC_FAMILY_ESPRESSIF_ESP32) && \ + !defined(CONFIG_ZEPHCORE_ROLE_COMPANION) #define POWERSAVING_DEFAULT 1 #else #define POWERSAVING_DEFAULT 0 diff --git a/zephcore/helpers/pm_esp32_wake.c b/zephcore/helpers/pm_esp32_wake.c index a186ec8..88d8736 100644 --- a/zephcore/helpers/pm_esp32_wake.c +++ b/zephcore/helpers/pm_esp32_wake.c @@ -46,8 +46,15 @@ #include #include #include +#include #if defined(CONFIG_WIFI_ESP32) #include +#include +#endif + +/* The WiFi MAC keeps its own beacon timer (TSF) and wakes the SoC with it. */ +#if defined(CONFIG_WIFI_ESP32) && SOC_WIFI_HW_TSF +#define ZC_PM_WIFI_TSF 1 #endif #include @@ -96,6 +103,7 @@ static struct { uint32_t slept; /* ... and the SoC actually slept */ uint32_t wake_timer; uint32_t wake_gpio; + uint32_t wake_wifi; /* the WiFi MAC, for a beacon */ uint32_t wake_other; uint64_t asleep_us; } stats; @@ -146,6 +154,8 @@ static void wake_state_exit(enum pm_state state) stats.wake_gpio++; } else if (causes & BIT(ESP_SLEEP_WAKEUP_TIMER)) { stats.wake_timer++; + } else if (causes & BIT(ESP_SLEEP_WAKEUP_WIFI)) { + stats.wake_wifi++; } else { stats.wake_other++; } @@ -223,11 +233,11 @@ int zc_pm_format_stats(char *buf, size_t len) uint32_t pct10 = up_ms ? (uint32_t)(stats.asleep_us / up_ms) : 0; /* 0.1 % units */ uint32_t win_s = zc_pm_console_window_remaining_ms() / 1000U; int n = snprintf(buf, len, - "> %s asleep %u.%u%% sleeps %u/%u wake t%u g%u o%u", + "> %s asleep %u.%u%% sleeps %u/%u wake t%u g%u w%u o%u", atomic_get(&powersave_blocked) ? "off" : "on", pct10 / 10U, pct10 % 10U, stats.slept, stats.entries, - stats.wake_timer, stats.wake_gpio, stats.wake_other); + stats.wake_timer, stats.wake_gpio, stats.wake_wifi, stats.wake_other); ARRAY_FOR_EACH_PTR(wake_pins, p) { if (n > 0 && (size_t)n < len) { @@ -240,6 +250,19 @@ int zc_pm_format_stats(char *buf, size_t len) return (n > 0 && (size_t)n < len) ? n : (int)strlen(buf); } +/* ========== WiFi station ========== */ + +#if defined(ZC_PM_WIFI_TSF) +static atomic_t wifi_station_started; +#endif + +void zc_pm_wifi_station_started(void) +{ +#if defined(ZC_PM_WIFI_TSF) + atomic_set(&wifi_station_started, 1); +#endif +} + /* ========== esp_timer deadlines bound the sleep ========== */ #if defined(CONFIG_PM_CUSTOM_TICKS_HOOK) @@ -248,9 +271,20 @@ int zc_pm_format_stats(char *buf, size_t len) * would otherwise be slept through — the BLE controller's modem-sleep wake * timer ("btSlp") is the one that matters: miss it and the controller misses * its connection event. ESP-IDF's own tickless idle bounds the sleep the same - * way (esp_pm/pm_impl.c). */ + * way (esp_pm/pm_impl.c). + * + * A WiFi station in power save gives up the WiFi library's sleep lock between + * beacons a moment before the MAC's beacon timer (TSF) goes idle. No sleep in + * that moment, as in ESP-IDF: 0 ticks means "an event is due now" + * (devdocs/lld/13-power-management.md, section 12). */ int64_t pm_policy_next_custom_ticks(void) { +#if defined(ZC_PM_WIFI_TSF) + if (atomic_get(&wifi_station_started) && esp_wifi_internal_is_tsf_active()) { + return 0; + } +#endif + int64_t next = esp_timer_get_next_alarm_for_wake_up(); if (next == INT64_MAX) { @@ -287,7 +321,8 @@ static int pm_wake_init(void) #if defined(CONFIG_WIFI_ESP32) /* The ESP32 WiFi driver starts WiFi at boot, in NULL mode, "to enable - * coexistence". Started, the WiFi library holds its modem PM lock + * coexistence". In NULL mode the WiFi radio stays on, the WiFi library + * holds its modem PM lock for as long as it is * (pm_policy_state_all_lock_get via esp_pm) and the SoC never * light-sleeps: measured on the XIAO repeater, zero sleep entries with * WiFi compiled in, sleeps without it. Stop it here. Nothing is lost: diff --git a/zephcore/helpers/pm_sleep_guard.h b/zephcore/helpers/pm_sleep_guard.h index dcce2c1..8575890 100644 --- a/zephcore/helpers/pm_sleep_guard.h +++ b/zephcore/helpers/pm_sleep_guard.h @@ -60,6 +60,10 @@ uint32_t zc_pm_console_window_remaining_ms(void); /* One `get pm` line into buf; returns its length. */ int zc_pm_format_stats(char *buf, size_t len); +/* A WiFi station is in use from now on: no light sleep while the WiFi MAC's + * beacon timer (TSF) is active. */ +void zc_pm_wifi_station_started(void); + #ifdef __cplusplus } #endif @@ -69,6 +73,7 @@ int zc_pm_format_stats(char *buf, size_t len); #else static inline void zc_pm_set_powersaving(bool on) { (void)on; } +static inline void zc_pm_wifi_station_started(void) { } static inline void zc_pm_console_window_open(void) { } static inline uint32_t zc_pm_console_window_remaining_ms(void) { return 0; } static inline int zc_pm_format_stats(char *buf, size_t len) { (void)buf; (void)len; return 0; } diff --git a/zephcore/helpers/ui/display.c b/zephcore/helpers/ui/display.c index 84eefe6..c043d2f 100644 --- a/zephcore/helpers/ui/display.c +++ b/zephcore/helpers/ui/display.c @@ -16,6 +16,7 @@ #include "display.h" #include "doom_game.h" +#include "pm_sleep_guard.h" #include #include @@ -47,6 +48,21 @@ LOG_MODULE_REGISTER(zephcore_display, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL); static const struct device *disp_dev; static bool disp_on; + +/* The display goes on or off. No SoC light sleep while it is on: someone is + * using the UI (devdocs/lld/13-power-management.md, section 13). */ +static void disp_set_on(bool on) +{ + if (on == disp_on) { + return; + } + disp_on = on; + if (on) { + zc_pm_block_sleep(); + } else { + zc_pm_unblock_sleep(); + } +} static bool disp_initialized; static bool disp_rotated; /* panel flipped 180 degrees (upside-down mount) */ @@ -375,7 +391,7 @@ static void auto_off_handler(struct k_work *work) * mc_display_on() → backlight restore. */ if (is_epd) { backlight_set(false); - disp_on = false; + disp_set_on(false); return; } if (disp_on) { @@ -546,7 +562,7 @@ int mc_display_init(void) } display_blanking_off(disp_dev); backlight_set(true); - disp_on = true; + disp_set_on(true); disp_initialized = true; /* Set up auto-off timer and schedule initial timeout */ @@ -606,7 +622,7 @@ void mc_display_on(void) if (!is_epd) { display_blanking_off(disp_dev); } - disp_on = true; + disp_set_on(true); } backlight_set(true); @@ -622,7 +638,7 @@ void mc_display_off(void) if (disp_on) { display_blanking_on(disp_dev); backlight_set(false); - disp_on = false; + disp_set_on(false); } } @@ -908,7 +924,7 @@ void mc_display_epd_full_reset(void) /* After splash handoff, keep frontlight off; next user interaction * wakes it via mc_display_on(). */ backlight_set(false); - disp_on = false; + disp_set_on(false); } const struct device *mc_display_get_device(void) diff --git a/zephcore/scripts/board_manifest.py b/zephcore/scripts/board_manifest.py index 87cd339..eb53b40 100644 --- a/zephcore/scripts/board_manifest.py +++ b/zephcore/scripts/board_manifest.py @@ -11,6 +11,10 @@ native-Linux preset boards/linux_native/.conf carries a sibling wifi: true # WiFi companion: RAM for WiFi + BLE together light_sleep: true # DIO1 on an RTC-wake-capable GPIO AND # validated on hardware (ESP32 repeaters) + light_sleep_companion: true # the companion build has light sleep + # (opt-in with `powersaving on`); needs + # the wake pins and pin holds, and the + # RAM. Independent of light_sleep release: # absent = not published (bring-up) roles: [companion, repeater] # REQUIRED in release variants: # extra published builds of this board @@ -51,7 +55,7 @@ PLATFORM_DIRS = {"nrf52840": "nrf52", "nrf54l": "nrf54l", "esp32": "esp32", "mg24": "mg24", "stm32wl": "stm32wl"} ROLES = ("companion", "repeater", "room_server", "observer") KEYS = {"target", "capabilities", "release", "catalog", "linux"} -CAPS = {"light_sleep", "wifi"} +CAPS = {"light_sleep", "light_sleep_companion", "wifi"} CATALOG_KEYS = {"device", "maker", "new", "img", "own_img", "subtitle"} CAP_LINE = re.compile(r"^ (\w+): (true|false)\s*$", re.M)