diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index 4b74732..4a96025 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -32,7 +32,7 @@ All commands are sent over USB serial (CDC-ACM). Commands sent remotely over the | `start ota` | ESP32: start WiFi AP + HTTP OTA server. nRF52: reboot into BLE OTA DFU mode | | `stop ota` | 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` | Not implemented | +| `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` | --- @@ -312,6 +312,7 @@ Companion builds of boards whose `zephcore.yml` declares `capabilities: wifi: tr | `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` | 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 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/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index 15f7f4e..a66ad55 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -466,10 +466,10 @@ 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). +# ESP32 light sleep (boards/common/pm_esp32.conf): console guards (TX flush +# before sleep, console window) and the wake pins, `powersaving` and `get pm`. if(CONFIG_SOC_FAMILY_ESPRESSIF_ESP32 AND CONFIG_PM) - target_sources(app PRIVATE helpers/pm_esp32_console.c) + target_sources(app PRIVATE helpers/pm_esp32_console.c helpers/pm_esp32_wake.c) endif() # Native-Linux (native_sim) runtime setup: force real-time clock mode so the diff --git a/zephcore/Kconfig b/zephcore/Kconfig index 2440c8a..82fbb6d 100644 --- a/zephcore/Kconfig +++ b/zephcore/Kconfig @@ -557,26 +557,27 @@ config ZEPHCORE_NOISE_FLOOR_INTERVAL_MS deadline instead, so raise both or neither. config ZEPHCORE_PM_BOOT_AWAKE_MS - int "Block light sleep for this long after boot (ms)" + int "Console window: no light sleep for this long (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. + How long the SoC refuses to light-sleep after boot, and again after + every press of the user button, so the USB console is 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. + a sleeping node are DROPPED, and on boards that console over the S3's + USB Serial/JTAG the USB device detaches on every sleep. Press the user + button, or send `powersaving off` over LoRa, to get it back. 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. + Raising it costs only the extra awake time after each boot or press. config ZEPHCORE_PM_CONSOLE_UART_NUM int "Console UART index flushed before light sleep" @@ -595,8 +596,8 @@ config ZEPHCORE_PM_CONSOLE_UART_NUM boards/common/esp32s3_usb.overlay is companion-only), so this flush targets a peripheral that is not their console and does nothing. That is harmless but not a fix: a USJ console has no wake source either way, - so on those boards the USB CLI is simply unavailable once - ZEPHCORE_PM_BOOT_AWAKE_MS expires. Change it only if a board's console + so on those boards the USB CLI is unavailable outside the console + window (ZEPHCORE_PM_BOOT_AWAKE_MS). 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. diff --git a/zephcore/adapters/board/ZephyrBoard.cpp b/zephcore/adapters/board/ZephyrBoard.cpp index 4d2cfb9..64ba2b0 100644 --- a/zephcore/adapters/board/ZephyrBoard.cpp +++ b/zephcore/adapters/board/ZephyrBoard.cpp @@ -444,6 +444,7 @@ void ZephyrBoard::onPacketReceived() void ZephyrBoard::reboot() { + zephcore_persist_before_off(); k_msleep(50); /* Let UART/USB flush */ #ifdef ZEPHCORE_USBD_DETACH /* USB device stack (CDC ACM): detach so the host sees an unplug. */ @@ -468,6 +469,7 @@ void ZephyrBoard::powerOff() void ZephyrBoard::rebootToBootloader() { + zephcore_persist_before_off(); #ifdef NRF52_GPREGRET /* Write magic value to GPREGRET0 - enter UF2 bootloader mode. * UF2 supports both drag-and-drop (.uf2) and serial DFU (nrfutil). */ @@ -523,6 +525,7 @@ bool ZephyrBoard::startOTAUpdate(const char *id, char reply[]) { #ifdef NRF52_GPREGRET /* Write magic value to GPREGRET0 - enter BLE OTA DFU mode */ + zephcore_persist_before_off(); nrf_power_gpregret_set(NRF_POWER, 0, BOOTLOADER_DFU_OTA_MAGIC); sprintf(reply, "OK - rebooting to BLE DFU (name: %s)", id ? id : "DfuTarg"); k_msleep(50); /* Let UART/USB flush */ diff --git a/zephcore/adapters/board/zephyr_poweroff.c b/zephcore/adapters/board/zephyr_poweroff.c index 13a31c8..4b58876 100644 --- a/zephcore/adapters/board/zephyr_poweroff.c +++ b/zephcore/adapters/board/zephyr_poweroff.c @@ -94,6 +94,10 @@ __weak void ui_before_power_off(void) { } +__weak void zephcore_persist_before_off(void) +{ +} + /* Sensor and buzzer power-gate regulators. GPS has its own; never BLE (that * corrupts controller state across the wake). */ static void power_regulators_off(void) @@ -118,6 +122,9 @@ void zephcore_power_off(void) { LOG_INF("Powering off"); + /* 0. Deferred flash writes, while every rail is still up. */ + zephcore_persist_before_off(); + /* 1. The UI: heartbeat LED, display (EPD keeps its image). */ ui_before_power_off(); diff --git a/zephcore/adapters/board/zephyr_poweroff.h b/zephcore/adapters/board/zephyr_poweroff.h index c7befc0..c2fd3b3 100644 --- a/zephcore/adapters/board/zephyr_poweroff.h +++ b/zephcore/adapters/board/zephyr_poweroff.h @@ -39,6 +39,14 @@ FUNC_NORETURN void zephcore_power_off(void); * builds provide it. */ void ui_before_power_off(void); +/* Write out whatever the role defers to flash lazily (the companion's contacts + * and channels), before a clean reboot or power-off. Weak no-op here; the + * companion provides it (main_companion.cpp). Callable from any thread: off + * the main thread it hands the work to the main thread and waits, bounded. + * zephcore_power_off() and the ZephyrBoard reboot paths call it; so must any + * new clean-reboot path that calls sys_reboot() directly. */ +void zephcore_persist_before_off(void); + #ifdef __cplusplus } #endif diff --git a/zephcore/adapters/datastore/FsStream.h b/zephcore/adapters/datastore/FsStream.h index 5a195e3..baf9f3e 100644 --- a/zephcore/adapters/datastore/FsStream.h +++ b/zephcore/adapters/datastore/FsStream.h @@ -5,6 +5,8 @@ * The parser calls available() and read() once per character, so the reader * refills a small buffer instead of asking the filesystem each time. The * writer remembers any short write so the atomic replace can refuse to commit. + * The compare stream takes a writer's output and checks it against a file, + * so a save that would not change the file can skip the flash write. */ #pragma once @@ -71,3 +73,35 @@ public: int read() override { return -1; } int peek() override { return -1; } }; + +class FsCompareStream : public Stream { + FsReadStream _in; + bool _same = true; + +public: + explicit FsCompareStream(struct fs_file_t *f) : _in(f) {} + + /* True if everything written matched the file and covered all of it. + * Call once, after the last write. */ + bool same() { return _same && _in.read() < 0; } + + /* Reports every byte written: a short count is a write failure to the + * serializer, and a mismatch is not one. */ + size_t write(uint8_t c) override + { + if (_same && _in.read() != c) { + _same = false; + } + return 1; + } + size_t write(const uint8_t *buf, size_t size) override + { + for (size_t i = 0; i < size && _same; i++) { + write(buf[i]); + } + return size; + } + int available() override { return 0; } + int read() override { return -1; } + int peek() override { return -1; } +}; diff --git a/zephcore/adapters/datastore/PrefsFile.cpp b/zephcore/adapters/datastore/PrefsFile.cpp index d0750c5..0c08ddb 100644 --- a/zephcore/adapters/datastore/PrefsFile.cpp +++ b/zephcore/adapters/datastore/PrefsFile.cpp @@ -54,8 +54,35 @@ static bool json_writer(struct fs_file_t *file, void *arg) return c->to_json(*c->p, out) && out.ok(); } +/* True if serialising p would reproduce the file byte for byte. Most + * savePrefs() callers save whatever the command touched, including a value + * set to what it already was; this keeps those off the flash. */ +static bool prefs_json_unchanged(const char *path, const NodePrefs &p, PrefsToJsonFn to_json) +{ + if (!zephcore_fs_exists(path)) { + return false; + } + + struct fs_file_t file; + + fs_file_t_init(&file); + if (fs_open(&file, path, FS_O_READ) < 0) { + return false; + } + FsCompareStream cmp(&file); + bool same = to_json(p, cmp) && cmp.same(); + + fs_close(&file); + return same; +} + bool zephcore_prefs_json_save(const char *path, const NodePrefs &p, PrefsToJsonFn to_json) { + if (prefs_json_unchanged(path, p, to_json)) { + LOG_DBG("%s unchanged, not rewritten", path); + return true; + } + struct json_writer_ctx c = { &p, to_json }; return zephcore_fs_atomic_write(path, json_writer, &c, "savePrefs"); diff --git a/zephcore/adapters/datastore/PrefsFile.h b/zephcore/adapters/datastore/PrefsFile.h index 80b2907..d3726cf 100644 --- a/zephcore/adapters/datastore/PrefsFile.h +++ b/zephcore/adapters/datastore/PrefsFile.h @@ -13,5 +13,6 @@ typedef bool (*PrefsToJsonFn)(const NodePrefs &p, Stream &out); /* False if the file is absent, empty or does not parse; p is then untouched. */ bool zephcore_prefs_json_load(const char *path, NodePrefs &p, PrefsFromJsonFn from_json); -/* Power-safe replace; false (and the old file kept) on any short write. */ +/* Power-safe replace; false (and the old file kept) on any short write. A + * file that already holds exactly this is left alone (true, no write). */ bool zephcore_prefs_json_save(const char *path, const NodePrefs &p, PrefsToJsonFn to_json); diff --git a/zephcore/adapters/datastore/ZephyrDataStore.cpp b/zephcore/adapters/datastore/ZephyrDataStore.cpp index c45227b..4f766b0 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.cpp +++ b/zephcore/adapters/datastore/ZephyrDataStore.cpp @@ -433,6 +433,8 @@ bool ZephyrDataStore::loadLegacyPrefs(NodePrefs &prefs) /* That firmware's 3300 mV default is 3200 now (NodePrefs.h). */ prefs.auto_shutdown_set = 0; auto_shutdown_upgrade(&prefs); + prefs.powersaving_set = 0; + powersaving_upgrade(&prefs); return true; } diff --git a/zephcore/adapters/ota/wifi_ota.c b/zephcore/adapters/ota/wifi_ota.c index ad69944..7c4ea70 100644 --- a/zephcore/adapters/ota/wifi_ota.c +++ b/zephcore/adapters/ota/wifi_ota.c @@ -14,6 +14,7 @@ #include "wifi_ota.h" #include "ota_page.h" #include "../../helpers/pm_sleep_guard.h" +#include "../board/zephyr_poweroff.h" #include #include @@ -55,6 +56,7 @@ static char home_html[384]; static void ota_reboot_fn(struct k_work *work) { LOG_INF("OTA reboot"); + zephcore_persist_before_off(); sys_reboot(SYS_REBOOT_COLD); } diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index 786485b..88233b6 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -100,6 +100,7 @@ CompanionMesh::CompanionMesh(mesh::Radio &radio, mesh::MillisecondClock &ms, mes _app_target_ver = 0; _dirty_contacts_expiry = 0; _dirty_channels_expiry = 0; + memset(&_advert_prev, 0, sizeof(_advert_prev)); memset(_send_scope.key, 0, sizeof(_send_scope.key)); _send_scope_force_unscoped = false; _cli_exec_cb = nullptr; @@ -711,19 +712,50 @@ bool CompanionMesh::putBlobByKey(const uint8_t key[], int key_len, const uint8_t return _store->putBlobByKey(key, key_len, src_buf, len); } +/* Snapshot the contact record before the base class updates it, for the + * dirty decision in onDiscoveredContact(). */ +void CompanionMesh::onAdvertRecv(mesh::Packet *packet, const mesh::Identity &id, uint32_t timestamp, + const uint8_t *app_data, size_t app_data_len) +{ + const ContactInfo *c = lookupContactByPubKey(id.pub_key, PUB_KEY_SIZE); + + _advert_prev.known = c && c->type != ADV_TYPE_NONE; + if (_advert_prev.known) { + _advert_prev.type = c->type; + _advert_prev.gps_lat = c->gps_lat; + _advert_prev.gps_lon = c->gps_lon; + memcpy(_advert_prev.name, c->name, sizeof(_advert_prev.name)); + } + BaseChatMesh::onAdvertRecv(packet, id, timestamp, app_data, app_data_len); + _advert_prev.known = false; +} + /* BaseChatMesh virtual implementations */ void CompanionMesh::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path_len, const uint8_t *path) { LOG_INF("onDiscoveredContact: '%s' is_new=%d path_len=%d num_contacts=%d", contact.name, is_new, path_len, getNumContacts()); - /* A re-heard advert from a contact we already have is liveness only — - * the base class has just refreshed last_advert_timestamp and lastmod, - * and nothing else typically moved. Persist it lazily rather than - * spending a full-file rewrite per advert; a genuinely new contact, a - * path change (onContactPathUpdated) or a message all still flush on the - * short deadline. */ - markContactsDirty(is_new); + /* is_new is upstream's "NOT in contacts[]": auto-add declined it (type + * filter, hop limit, or a full table), so nothing that is stored changed + * and there is nothing to write -- upstream schedules its lazy write only + * for !is_new. A contact the base class has just added arrives with + * is_new false. + * + * Of those, an addition or a changed name, type or position is + * substantive and flushes on the short deadline; a known contact + * re-advertising (only the timestamps moved) is liveness and waits the + * long one, rather than costing a full-file rewrite per advert. */ + if (!is_new) { + bool substantive = !_advert_prev.known || + _advert_prev.type != contact.type || + _advert_prev.gps_lat != contact.gps_lat || + _advert_prev.gps_lon != contact.gps_lon || + strncmp(_advert_prev.name, contact.name, + sizeof(_advert_prev.name)) != 0; + + markContactsDirty(substantive); + } // Update advert path table if (path && mesh::Packet::isValidPathLen(path_len)) { diff --git a/zephcore/app/CompanionMesh.h b/zephcore/app/CompanionMesh.h index 21b879c..20ac65b 100644 --- a/zephcore/app/CompanionMesh.h +++ b/zephcore/app/CompanionMesh.h @@ -196,10 +196,17 @@ public: /* From the housekeeping event: loop() only runs on packet events. */ void timeSyncTick(); + /* Write out any lazily-deferred contacts/channels now. Main thread only; + * every reboot and power-off path reaches it through + * zephcore_persist_before_off(). */ + void flushPendingWrites() { flushDirtyContacts(); flushDirtyChannels(); } + NodePrefs prefs; protected: /* BaseChatMesh */ + void onAdvertRecv(mesh::Packet *packet, const mesh::Identity &id, uint32_t timestamp, + const uint8_t *app_data, size_t app_data_len) override; void onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path_len, const uint8_t *path) override; ContactInfo *processAck(const uint8_t *data) override; void onContactPathUpdated(const ContactInfo &contact) override; @@ -331,17 +338,34 @@ private: int64_t _dirty_channels_expiry; static constexpr int64_t LAZY_WRITE_DELAY_MS = 5000; /* as upstream */ - /* Deadline for a liveness-only change (a known contact re-advertised). - * saveContacts() rewrites the whole file (~47 KB without external flash); - * on the 5 s deadline a T1000-E did 21 rewrites in 90 minutes, one per - * advert. A substantive change still pulls the deadline in. */ - static constexpr int64_t LAZY_WRITE_LIVENESS_MS = 600000; /* 10 minutes */ + /* Deadline for a liveness-only change (a known contact re-advertised with + * nothing but a newer timestamp): only last_advert_timestamp and lastmod + * moved. saveContacts() rewrites the whole file (~40 KB, ten 4 KB blocks + * on a T1000-E's 128 KB /lfs); on a busy mesh something is always + * re-advertising, so this deadline IS the rewrite rate. 5 s (upstream) + * was a rewrite per advert, 10 min was 144 a day, and one hour is 24, + * below the adv_blobs write per advert. Every clean reboot and power-off + * flushes first (flushPendingWrites()), so only a crash or a pulled + * battery loses the last hour of "last heard" times. A substantive + * change still pulls the deadline in. */ + static constexpr int64_t LAZY_WRITE_LIVENESS_MS = 3600000; /* 1 hour */ void markContactsDirty(bool substantive = true); void markChannelsDirty(); void flushDirtyContacts(); void flushDirtyChannels(); + /* What the contact record held before the advert being processed, so + * onDiscoveredContact() can tell an addition or a real change (name, + * type, position) from a re-advert. Set by onAdvertRecv() around the + * base class call. */ + struct AdvertPrev { + bool known; + uint8_t type; + int32_t gps_lat, gps_lon; + char name[sizeof(ContactInfo::name)]; + } _advert_prev; + /* Forward-only: our clock stamps outgoing DMs, and peers keep per-sender * replay high-water marks. */ MeshTimeSync _timesync{FIRMWARE_BUILD_EPOCH, true}; diff --git a/zephcore/app/RepeaterDataStore.cpp b/zephcore/app/RepeaterDataStore.cpp index dd4dbce..86e476f 100644 --- a/zephcore/app/RepeaterDataStore.cpp +++ b/zephcore/app/RepeaterDataStore.cpp @@ -158,6 +158,9 @@ bool RepeaterDataStore::loadLegacyPrefs(const char* path, NodePrefs& prefs) { /* Likewise gps_enabled: that firmware ran the GPS whatever it said. */ prefs.gps_enabled = 1; prefs.gps_enabled_set = 1; + /* And powersaving, which it stored but never acted on. */ + prefs.powersaving_set = 0; + powersaving_upgrade(&prefs); return true; } diff --git a/zephcore/app/RepeaterMesh.cpp b/zephcore/app/RepeaterMesh.cpp index e9b410a..4f22a8d 100644 --- a/zephcore/app/RepeaterMesh.cpp +++ b/zephcore/app/RepeaterMesh.cpp @@ -251,6 +251,12 @@ uint8_t RepeaterMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t } LOG_INF("Login success"); + /* What save() stores that a login can change: a new entry (permissions + * 0 until set below), the role, the secret. last_timestamp and + * last_activity are RAM-only. */ + uint8_t prev_perms = client->permissions; + bool secret_changed = memcmp(client->shared_secret, secret, PUB_KEY_SIZE) != 0; + client->last_timestamp = sender_timestamp; client->last_activity = getRTCClock()->getCurrentTime(); /* Role assignment only ever escalates. putClient() hands back the @@ -265,7 +271,7 @@ uint8_t RepeaterMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t } memcpy(client->shared_secret, secret, PUB_KEY_SIZE); - if (client->isAdmin()) { + if (client->isAdmin() && (client->permissions != prev_perms || secret_changed)) { /* Flush admin sessions now instead of leaving them on the lazy * timer. The entry carries the shared secret this session's * traffic is encrypted with; if power is lost before the timer @@ -274,7 +280,13 @@ uint8_t RepeaterMesh::handleLoginReq(const mesh::Identity& sender, const uint8_t * a wrong password or an unreachable node. A repeater is the * device most likely to lose power unattended, so the few * milliseconds are worth it. save() writes the whole ACL, so any - * other pending changes go out with it. */ + * other pending changes go out with it. + * + * Only when the stored entry changed, though. The secret is the + * ECDH of the two fixed keys, so a known admin logging in again + * carries the same one: rewriting an identical ACL on every login + * (a monitoring tool polling every few minutes) was pure wear. + * Upstream schedules a lazy write on every non-guest login. */ if (_store) { acl.save(_store->getFS()); dirty_contacts_expiry = 0; diff --git a/zephcore/app/RoomServerMesh.cpp b/zephcore/app/RoomServerMesh.cpp index d4b08cd..9c9443b 100644 --- a/zephcore/app/RoomServerMesh.cpp +++ b/zephcore/app/RoomServerMesh.cpp @@ -441,6 +441,12 @@ void RoomServerMesh::onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, } LOG_INF("Room login success"); + /* What save() stores that a login can change: a new entry (permissions + * 0 until set below), the role, sync_since, the secret. */ + uint8_t prev_perms = client->permissions; + uint32_t prev_sync_since = client->extra.room.sync_since; + bool secret_changed = memcmp(client->shared_secret, secret, PUB_KEY_SIZE) != 0; + client->last_timestamp = sender_timestamp; client->extra.room.sync_since = sender_sync_since; client->extra.room.pending_ack = 0; @@ -450,7 +456,12 @@ void RoomServerMesh::onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, client->permissions |= perms; memcpy(client->shared_secret, secret, PUB_KEY_SIZE); - if (perms != PERM_ACL_GUEST) { + /* Upstream schedules the write on every non-guest login; a member logging + * in again with nothing new (same secret, role and sync point) would + * rewrite an identical ACL. */ + if (perms != PERM_ACL_GUEST && + (client->permissions != prev_perms || client->extra.room.sync_since != prev_sync_since || + secret_changed)) { if (!dirty_contacts_expiry) dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); } } diff --git a/zephcore/boards/common/pm_esp32.conf b/zephcore/boards/common/pm_esp32.conf index afa0fab..0ba6318 100644 --- a/zephcore/boards/common/pm_esp32.conf +++ b/zephcore/boards/common/pm_esp32.conf @@ -1,99 +1,63 @@ # 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: +# 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: # -# west build -b heltec_wifi_lora32_v3/esp32s3/procpu zephcore --pristine -- \ -# -DEXTRA_CONF_FILE="boards/common/repeater.conf;boards/common/pm_esp32.conf" -# -# 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. -# -# Only Heltec V3 actually consoles on uart0. V4, V4.3 and Wireless Tracker V2 -# choose usb_serial in board.overlay, and the uart0 reroute in -# boards/common/esp32s3_usb.overlay is companion-only, so a REPEATER build of -# those boards has a USB Serial/JTAG console. The flush above then targets a -# peripheral that is not their console and is inert, and the USJ peripheral -# drops off the bus in light sleep with no wake source in the HAL. Net effect -# on those three: no USB CLI at all after the boot window, rather than a CLI -# that merely ignores keystrokes. This is accepted so the boards stay in the -# field exercising light sleep — see the allowlist block in CMakeLists.txt. -# -# 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). +# west build -b xiao_esp32s3/esp32s3/procpu zephcore --pristine --sysbuild -- # -DEXTRA_CONF_FILE="boards/common/repeater.conf;boards/common/pm_esp32.conf" # # 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 -# and RAM powered, so an idle ESP32 sits in the tens of mA. Light sleep is -# sub-mA. On a repeater that is the difference between the MCU dominating the +# and RAM powered, so an idle ESP32 sits in the tens of mA. Light sleep is +# sub-mA. On a repeater that is the difference between the MCU dominating the # power budget and the radio dominating it. # -# REPEATERS ONLY. Do not pair this with a companion build. The Espressif HCI -# driver (drivers/bluetooth/hci/hci_esp32.c) takes no pm_policy locks, so -# nothing stops the SoC light-sleeping mid-advertising or mid-connection; the -# NXP driver does take them, Espressif's does not. repeater.conf sets -# CONFIG_BT=n, which removes the problem rather than papering over it. +# THE PIECES +# - pm_esp32.overlay: &rtc_timer as the timed wake source (without it power.c +# logs "Sleep skipped" and CONFIG_PM silently does nothing) and &gpio0 +# wakeup-source, which turns the ESP32 GPIO wake source on. +# - helpers/pm_esp32_wake.c: the LoRa IRQ line and the user button (sw0) +# become level wakes on any pad for the length of each sleep, and the +# interrupt a sleep swallowed is re-raised on wake. Also the `powersaving` +# switch and the `get pm` counters. GPIO_INT_WAKEUP (EXT1) is NOT used: +# RTC pads only, one polarity for all pins on the S3, pads left on the RTC +# mux after a sleep (devdocs/lld/13-power-management.md, F-1/F-2). +# - helpers/pm_esp32_console.c: flushes the console UART before every sleep, +# and holds a console window (ZEPHCORE_PM_BOOT_AWAKE_MS, 10 min) after boot +# and after every button press. +# - Pin holds, in each board's DTS/overlay: light sleep isolates every pad +# that is not held (input and output off, no pulls), RTC pad or not, while +# the radio keeps receiving. NSS, RESET and any MCU-driven RF switch, FEM +# enable or FEM supply carry ESP32_GPIO_SLEEP_HOLD_EN (inert without PM). # -# BOARD REQUIREMENT — DIO1 must be an RTC-capable GPIO. On ESP32-S3 -# esp_sleep_is_valid_wakeup_gpio() reduces to RTC_GPIO_IS_VALID_GPIO(), i.e. -# GPIO 0-21. A board wiring DIO1 above that CANNOT wake on a received packet: +# CONSOLE: outside the console window, characters typed at a sleeping node are +# dropped. On boards that console over the S3's USB Serial/JTAG (V4, V4.3, +# Wireless Tracker V2, XIAO, Station G2) the HAL disables the USJ pad for every +# sleep, so the USB device detaches and reattaches continuously. Press the user +# button to reopen the window, or send `powersaving off` (persisted) over LoRa; +# `powersaving on` allows sleep again. Light-sleep builds default to on. # -# OK heltec_wifi_lora32_v3 / v4 / v43, heltec_wireless_tracker(_v2) -# -> DIO1 = GPIO14 -# 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 0003 arms DIO1 in the sx126x driver -# only, so nothing marks the pin as a wake source at all +# 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). # -# Note the "v4" above means heltec_wifi_lora32_v4 exactly, NOT the whole V4 -# family. heltec_wifi_lora32_v4_r8 is deliberately NOT allowlisted even -# though its wiring qualifies (DIO1 = GPIO14, NSS = GPIO8, RESET = GPIO12 on -# &spi2): CONFIG_PM has never been validated on that board. The allowlist -# regex in CMakeLists.txt is anchored so a name that merely extends one of -# these does not join by substring match. To add it, run the three checks at -# the end of this file on hardware first. +# 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. # -# 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 -# excellent on a current meter. Check that log line before trusting any -# measurement. -# -# PAD HOLD — light sleep powers down RTC_PERIPH, so every RTC-capable pad -# (GPIO 0-21, which is all of them on these boards) loses its level unless -# held. The paired overlay holds the SX1262 NSS and RESET; without that, a -# floating NSS lets the sleeping radio clock garbage into its own retention -# RAM and the node transmits ~30 dB down until the next reboot. The overlay -# hardcodes NSS GPIO8 / RESET GPIO12 / &spi2 — verify a new board matches -# before adding it to the allowlist below. -# -# Also required and supplied by the paired pm_esp32.overlay: -# - &rtc_timer enabled. soc/espressif/common/power.c refuses to sleep without -# it, logging "Sleep skipped. Make sure '&rtc_timer' is enabled as a wakeup -# source." — i.e. CONFIG_PM=y alone silently does nothing. -# - &gpio0 as a wakeup-source, so the driver's PM resume hook calls -# esp_sleep_enable_gpio_wakeup(). -# And patches/zephyr/0003 marks DIO1 itself with GPIO_INT_WAKEUP. +# 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. +# A deaf node is the failure to rule out first: radio wakes should track the +# packets it hears, and its receive counters should track a non-sleeping +# neighbour's. # # Kernel time is safe across sleep: the Xtensa/ESP32 timer drivers implement # the LPM hooks (esp32_lptim_hook_on_lpm_entry/exit) that read the RTC counter # and announce elapsed ticks, so k_uptime_get() and every maintenance deadline # stay correct. -# -# 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 @@ -109,3 +73,7 @@ CONFIG_COUNTER=y # thread — that is the trap that historically killed GPS. Device suspends stay # explicit (pm_device_action_run from the main thread). CONFIG_PM_DEVICE_SYSTEM_MANAGED=n + +# Bound each light sleep by the next esp_timer alarm as well as the kernel's +# next timeout (helpers/pm_esp32_wake.c, pm_policy_next_custom_ticks()). +CONFIG_PM_CUSTOM_TICKS_HOOK=y diff --git a/zephcore/boards/common/pm_esp32.overlay b/zephcore/boards/common/pm_esp32.overlay index 104bdcb..7690f06 100644 --- a/zephcore/boards/common/pm_esp32.overlay +++ b/zephcore/boards/common/pm_esp32.overlay @@ -3,16 +3,16 @@ * Devicetree half of boards/common/pm_esp32.conf — auto-paired by * zephcore_auto_pair_overlay() in CMakeLists.txt, so passing the .conf is * enough; this file does not need to be listed separately. + * + * Only the board-independent part lives here. Pin holds are wiring facts and + * live in each board's DTS/overlay (see "PAD HOLD" below). */ -#include -#include - /* * The RTC timer is the wake source for every timed sleep. Upstream leaves it * status = "disabled" (zephyr/dts/xtensa/espressif/esp32s3/esp32s3_common.dtsi), * and soc/espressif/common/power.c checks for it in rtc_wakeup_enable(): - * without a ready rtc_timer device it logs "Sleep skipped. Make sure + * without a wakeup-capable rtc_timer device it logs "Sleep skipped. Make sure * '&rtc_timer' is enabled as a wakeup source." and returns false — so * CONFIG_PM=y on its own is a silent no-op. This is the single easiest thing * to forget when enabling ESP32 PM. @@ -23,95 +23,41 @@ }; /* - * Arms the GPIO wake path. gpio_esp32_pm_action() calls - * esp_sleep_enable_gpio_wakeup() on PM_DEVICE_ACTION_RESUME, but only when the - * PORT is wakeup-capable — that is what pm_device_wakeup_is_capable() tests, - * and it is set by this property. Per-PIN arming is separate and comes from - * patches/zephyr/0003, which passes GPIO_INT_WAKEUP on the DIO1 configure. - * Both halves are needed: the port enables the wake source, the pin selects - * which line triggers it. + * Switches on the ESP32 GPIO wake source (ESP_SLEEP_WAKEUP_GPIO): + * gpio_esp32_pm_action() calls esp_sleep_enable_gpio_wakeup() on RESUME, which + * pm_device_driver_init() runs at boot, when the port is wakeup-capable. The + * source is SoC-wide, so gpio0 alone also covers pins on gpio1. * - * gpio0 covers GPIO 0-31, which is where every RTC-capable pin lives on the - * S3 (0-21) — so this is also the only port that can carry a LoRa DIO1 wake. + * WHICH pins wake the SoC is decided by helpers/pm_esp32_wake.c: the LoRa IRQ + * line and the user button (alias sw0) become level wakes for the length of + * each sleep. GPIO_INT_WAKEUP is deliberately NOT used anywhere: in Zephyr's + * ESP32 driver it means EXT1, which takes RTC pads only, has one trigger + * polarity for all its pins on the S3 (an active-low button and an active-high + * DIO1 cannot both be armed: the one configured second is refused), and leaves + * each pad on the RTC mux after a sleep. devdocs/lld/13-power-management.md + * F-1/F-2. + * + * Side effect worth knowing: this trigger keeps RTC_PERIPH powered in every + * light sleep (esp_sleep_start forces it ON for RTC_GPIO_TRIG_EN). */ &gpio0 { wakeup-source; }; /* - * Hold the SX1262 chip-select across light sleep. + * PAD HOLD — with CONFIG_PM, gpio_esp32_sys_init() (and the HAL's + * esp_sleep_startup_init()) give every pad an isolated sleep configuration — + * input off, output off, no pulls — and turn on the automatic switch to it on + * sleep entry. So EVERY output that is not held floats for the length of each + * sleep, whatever its power domain: RTC pads and digital pads alike. Only + * ESP32_GPIO_SLEEP_HOLD_EN (esp32_sleep_gpio_prepare() in power.c) keeps a + * level through a sleep. * - * Light sleep powers down RTC_PERIPH on this SoC, and every LoRa pin on the - * allowlisted boards is an RTC-capable pad (GPIO 0-21), so their levels are - * NOT retained unless explicitly held. The chain: - * - * 1. patches/zephyr/0003 passes GPIO_INT_WAKEUP on DIO1. On the S3 that - * lands on esp_sleep_enable_ext1_wakeup_io() (drivers/gpio/gpio_esp32.c, - * SOC_PM_SUPPORT_EXT1_WAKEUP), i.e. RTC_EXT1_TRIG_EN. - * 2. esp_sleep_start() only forces ESP_PD_DOMAIN_RTC_PERIPH on for - * RTC_EXT0_TRIG_EN | RTC_GPIO_TRIG_EN. EXT1 is not in that mask, so the - * domain is left AUTO and powered OFF for the whole sleep. - * 3. ext1_wakeup_prepare() then calls rtcio_hal_hold_enable() for the EXT1 - * pin *only*, and only because RTC_PERIPH is off — IDF's own code - * treating "RTC_PERIPH down" as "this pad cannot hold its own state". - * DIO1 survives; nothing else does. - * 4. Zephyr's general mechanism, esp32_sleep_gpio_prepare(), holds only pins - * carrying ESP32_GPIO_SLEEP_HOLD_EN. Hence this override. - * - * NSS is the pin that matters. Undriven, it floats, and the SX1262 samples - * SPI commands whenever NSS is low — so a sleeping chip sitting in warm-start - * sleep with its entire configuration (PA config, OCP, SetTxParams) in - * retention RAM is exposed to whatever the floating bus does. Nothing in the - * driver ever re-issues SetTxParams: lora_config() is its only writer and - * LoRaRadioBase::configure() short-circuits on every transmit after the first, - * so a corrupted TX power persists until reboot or a `set tx `. - * Symptom is a node that still relays but transmits ~30 dB down. - * - * Holding NSS high for the duration deselects the chip, which also makes SCK - * and MOSI harmless — nothing can be clocked in while CS is deasserted, so - * those pins do not need the pinctrl-side `sleep-hold-en`. - * - * All five allowlisted boards (heltec_wifi_lora32_v3/v4/v43, - * heltec_wireless_tracker/_v2) wire the radio identically — &spi2, NSS GPIO8, - * RESET GPIO12 — which is what lets this live in one shared overlay. Re-check - * that before adding a board to the allowlist in CMakeLists.txt. + * The radio keeps receiving while the SoC sleeps, so every line it depends on + * must be held by the board: NSS (a floating chip-select lets the sleeping + * SX1262 clock garbage into its retention RAM — the node then transmits ~30 dB + * down until reboot), RESET, an MCU-driven RF switch or FEM enable, and the + * FEM's supply regulator. The flag is inert when CONFIG_PM=n, so boards carry + * it unconditionally. Check a board's radio lines before declaring + * `light_sleep: true` in its zephcore.yml. */ -&spi2 { - cs-gpios = <&gpio0 8 (GPIO_ACTIVE_LOW | ESP32_GPIO_SLEEP_HOLD_EN)>; -}; - -/* - * The user button must wake the SoC too. - * - * DIO1 is not the only thing that needs to interrupt a sleeping node — a person - * pressing the button expects the display to come on. Marking the PORT - * wakeup-source above only enables esp_sleep_enable_gpio_wakeup(); the PIN is - * armed separately, and until now only DIO1 got that (via patches/zephyr/0003). - * The result was a node that woke for packets and ignored its own button: press - * it, nothing happens, the display never lights. Reported from the field. - * - * GPIO_INT_WAKEUP is (1 << 6) in dt-bindings/gpio/gpio.h, so it fits the 16-bit - * gpio_dt_flags_t and can be set here in DT rather than needing a driver patch — - * gpio_pin_configure_dt() ORs spec->dt_flags into the flags it passes, so it - * reaches gpio_esp32_config() and arms the wake. - * - * All five allowlisted boards put the USER key on the same pad (&gpio0 0 = - * GPIO0, inside the S3's RTC-capable 0-21 range) under the same node label, - * which is what lets this be one shared override. Re-check when allowlisting - * a new board. - */ -&button0 { - gpios = <&gpio0 0 (GPIO_PULL_UP | GPIO_ACTIVE_LOW | GPIO_INT_WAKEUP)>; -}; - -/* - * RESET is open-drain active-low against the SX1262's internal pull-up, so an - * unheld pad going high-Z already reads as "not reset" and this is belt and - * braces rather than a known fault. It costs nothing and removes the question - * of what the pad does on the way into and out of power-down — a spurious low - * here would reset the radio outright. - */ -&lora0 { - reset-gpios = <&gpio0 12 (GPIO_OPEN_DRAIN | GPIO_ACTIVE_LOW | - ESP32_GPIO_SLEEP_HOLD_EN)>; -}; diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.overlay b/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.overlay index 01c392b..aaf9d6f 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.overlay +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.overlay @@ -19,6 +19,7 @@ #include #include +#include #include #include #include @@ -77,7 +78,7 @@ vext_enable: vext-enable { compatible = "regulator-fixed"; regulator-name = "vext"; - enable-gpios = <&gpio1 4 (GPIO_PULL_UP | GPIO_ACTIVE_LOW)>; + enable-gpios = <&gpio1 4 (GPIO_PULL_UP | GPIO_ACTIVE_LOW | ESP32_GPIO_SLEEP_HOLD_EN)>; regulator-boot-on; regulator-always-on; }; @@ -130,6 +131,15 @@ &lora0 { dio1-gpios = <&gpio0 14 (GPIO_PULL_DOWN | GPIO_ACTIVE_HIGH)>; rx-boosted; + /* Held across light sleep with NSS below; inert when CONFIG_PM=n. */ + reset-gpios = <&gpio0 12 (GPIO_OPEN_DRAIN | GPIO_ACTIVE_LOW | ESP32_GPIO_SLEEP_HOLD_EN)>; +}; + +/* NSS held across light sleep (boards/common/pm_esp32.overlay): an + * isolated, floating chip-select lets the sleeping SX1262 clock garbage + * into its retention RAM. Inert when CONFIG_PM=n. */ +&spi2 { + cs-gpios = <&gpio0 8 (GPIO_ACTIVE_LOW | ESP32_GPIO_SLEEP_HOLD_EN)>; }; /* GPS on UART1 — optional external NMEA module on the pin header, wired to the diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v4/heltec_wifi_lora32_v4_procpu.dts b/zephcore/boards/esp32/heltec_wifi_lora32_v4/heltec_wifi_lora32_v4_procpu.dts index f770bc8..be01c51 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v4/heltec_wifi_lora32_v4_procpu.dts +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v4/heltec_wifi_lora32_v4_procpu.dts @@ -93,7 +93,7 @@ vext_enable: vext-enable { compatible = "regulator-fixed"; regulator-name = "vext"; - enable-gpios = <&gpio1 4 GPIO_ACTIVE_LOW>; /* GPIO36 = gpio1 pin 4 */ + enable-gpios = <&gpio1 4 (GPIO_ACTIVE_LOW | ESP32_GPIO_SLEEP_HOLD_EN)>; /* GPIO36 = gpio1 pin 4 */ regulator-boot-on; regulator-always-on; }; @@ -107,7 +107,7 @@ fem_power: fem-power { compatible = "regulator-fixed"; regulator-name = "fem-power"; - enable-gpios = <&gpio0 7 GPIO_ACTIVE_HIGH>; /* VFEM_Ctrl = GPIO7 */ + enable-gpios = <&gpio0 7 (GPIO_ACTIVE_HIGH | ESP32_GPIO_SLEEP_HOLD_EN)>; /* VFEM_Ctrl = GPIO7, held across light sleep */ regulator-boot-on; regulator-always-on; }; @@ -209,12 +209,16 @@ pinctrl-0 = <&spim2_default>; pinctrl-names = "default"; - cs-gpios = <&gpio0 8 GPIO_ACTIVE_LOW>; /* NSS = GPIO8 */ + /* NSS held across light sleep (boards/common/pm_esp32.overlay): an + * isolated, floating chip-select lets the sleeping SX1262 clock garbage + * into its retention RAM. Inert when CONFIG_PM=n. */ + cs-gpios = <&gpio0 8 (GPIO_ACTIVE_LOW | ESP32_GPIO_SLEEP_HOLD_EN)>; /* NSS = GPIO8 */ lora0: modem@0 { compatible = "semtech,sx1262"; reg = <0>; - reset-gpios = <&gpio0 12 (GPIO_OPEN_DRAIN | GPIO_ACTIVE_LOW)>; /* RESET = GPIO12 */ + reset-gpios = <&gpio0 12 (GPIO_OPEN_DRAIN | GPIO_ACTIVE_LOW | + ESP32_GPIO_SLEEP_HOLD_EN)>; /* RESET = GPIO12 */ busy-gpios = <&gpio0 13 GPIO_ACTIVE_HIGH>; /* BUSY = GPIO13 */ dio1-gpios = <&gpio0 14 GPIO_ACTIVE_HIGH>; /* DIO1 = GPIO14 */ @@ -223,19 +227,18 @@ * GPIO46 = CPS (TX select): HIGH during TX, LOW during RX * V4.3 (KCT8103L) uses GPIO5 instead of GPIO46 — see heltec_wifi_lora32_v43. */ /* - * ESP32_GPIO_SLEEP_HOLD_EN on CSD only. Light sleep powers down - * RTC_PERIPH (chain documented in boards/common/pm_esp32.overlay) - * and GPIO2 is an RTC-capable pad (0-21), so it needs the hold or - * the FEM comes out of a sleep disabled — no PA on transmit, no - * LNA on receive. CPS on GPIO46 is above the RTC range and sits - * in VDD3P3_CPU, which stays powered through light sleep, so it - * is deliberately left alone rather than given a flag the pad may - * not support. (V4.3 moved this line to GPIO5, which IS in range - * and does get the flag — see heltec_wifi_lora32_v43.) + * ESP32_GPIO_SLEEP_HOLD_EN on CSD and CPS. Light sleep switches + * every pad that is not held to an isolated sleep configuration + * (input and output off, no pulls) — RTC pad or not, so GPIO46 is + * no exception (boards/common/pm_esp32.overlay). An unheld CSD + * disables the FEM for the length of each sleep, and the radio + * keeps receiving through it: no LNA on receive. CPS is held so + * the RX path stays selected. Inert when CONFIG_PM=n. */ antenna-enable-gpios = <&gpio0 2 (GPIO_ACTIVE_HIGH | ESP32_GPIO_SLEEP_HOLD_EN)>; /* GC1109 CSD = GPIO2 */ - tx-enable-gpios = <&gpio1 14 GPIO_ACTIVE_HIGH>; /* GC1109 CPS = GPIO46 = gpio1 pin 14 */ + tx-enable-gpios = <&gpio1 14 (GPIO_ACTIVE_HIGH | + ESP32_GPIO_SLEEP_HOLD_EN)>; /* GC1109 CPS = GPIO46 = gpio1 pin 14 */ /* DIO2 drives the SX1262 internal RF switch (TX path select). * Combined with tx-enable-gpios, this keeps both the internal and diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v43/heltec_wifi_lora32_v43_procpu.dts b/zephcore/boards/esp32/heltec_wifi_lora32_v43/heltec_wifi_lora32_v43_procpu.dts index 183ac3d..47e523f 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v43/heltec_wifi_lora32_v43_procpu.dts +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v43/heltec_wifi_lora32_v43_procpu.dts @@ -96,7 +96,7 @@ vext_enable: vext-enable { compatible = "regulator-fixed"; regulator-name = "vext"; - enable-gpios = <&gpio1 4 GPIO_ACTIVE_LOW>; /* GPIO36 = gpio1 pin 4 */ + enable-gpios = <&gpio1 4 (GPIO_ACTIVE_LOW | ESP32_GPIO_SLEEP_HOLD_EN)>; /* GPIO36 = gpio1 pin 4 */ regulator-boot-on; regulator-always-on; }; @@ -110,7 +110,7 @@ fem_power: fem-power { compatible = "regulator-fixed"; regulator-name = "fem-power"; - enable-gpios = <&gpio0 7 GPIO_ACTIVE_HIGH>; /* VFEM_Ctrl = GPIO7 */ + enable-gpios = <&gpio0 7 (GPIO_ACTIVE_HIGH | ESP32_GPIO_SLEEP_HOLD_EN)>; /* VFEM_Ctrl = GPIO7, held across light sleep */ regulator-boot-on; regulator-always-on; }; @@ -212,12 +212,16 @@ pinctrl-0 = <&spim2_default>; pinctrl-names = "default"; - cs-gpios = <&gpio0 8 GPIO_ACTIVE_LOW>; /* NSS = GPIO8 */ + /* NSS held across light sleep (boards/common/pm_esp32.overlay): an + * isolated, floating chip-select lets the sleeping SX1262 clock garbage + * into its retention RAM. Inert when CONFIG_PM=n. */ + cs-gpios = <&gpio0 8 (GPIO_ACTIVE_LOW | ESP32_GPIO_SLEEP_HOLD_EN)>; /* NSS = GPIO8 */ lora0: modem@0 { compatible = "semtech,sx1262"; reg = <0>; - reset-gpios = <&gpio0 12 (GPIO_OPEN_DRAIN | GPIO_ACTIVE_LOW)>; /* RESET = GPIO12 */ + reset-gpios = <&gpio0 12 (GPIO_OPEN_DRAIN | GPIO_ACTIVE_LOW | + ESP32_GPIO_SLEEP_HOLD_EN)>; /* RESET = GPIO12 */ busy-gpios = <&gpio0 13 GPIO_ACTIVE_HIGH>; /* BUSY = GPIO13 */ dio1-gpios = <&gpio0 14 GPIO_ACTIVE_HIGH>; /* DIO1 = GPIO14 */ @@ -232,15 +236,15 @@ * off` gate CSD instead in 1.17.2, shutting the FEM down and taking * the receive path with it (~69 dB, measured). */ /* - * ESP32_GPIO_SLEEP_HOLD_EN on both: light sleep powers down - * RTC_PERIPH (see boards/common/pm_esp32.overlay for the full - * chain), and GPIO2/GPIO5 are RTC-capable pads (0-21), so their - * state is not retained across a sleep unless explicitly held. - * These two lines are the FEM's entire control surface — CSD low - * disables the PA and the LNA with it, CTX high bypasses the LNA — - * so losing them means transmitting through a bypassed PA, tens of - * dB down, with RX sensitivity gone too, while the node otherwise - * looks healthy and keeps relaying. + * ESP32_GPIO_SLEEP_HOLD_EN on both: light sleep switches every pad + * that is not held to an isolated sleep configuration (input and + * output off, no pulls), so these lines float for the length of + * each sleep (boards/common/pm_esp32.overlay). They are the FEM's + * entire control surface — CSD low disables the PA and the LNA with + * it, CTX high bypasses the LNA — and the radio keeps receiving + * through the sleep, so a floating pair means RX without its LNA + * and TX through a bypassed PA, tens of dB down, while the node + * otherwise looks healthy and keeps relaying. * The flag is ignored when CONFIG_PM=n, so this is inert on * builds that never light-sleep. */ diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8_procpu.dts b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8_procpu.dts index 72fa348..858e894 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8_procpu.dts +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8_procpu.dts @@ -124,7 +124,7 @@ vext_enable: vext-enable { compatible = "regulator-fixed"; regulator-name = "vext"; - enable-gpios = <&gpio1 8 GPIO_ACTIVE_LOW>; /* GPIO40 = gpio1 pin 8 */ + enable-gpios = <&gpio1 8 (GPIO_ACTIVE_LOW | ESP32_GPIO_SLEEP_HOLD_EN)>; /* GPIO40 = gpio1 pin 8 */ regulator-boot-on; regulator-always-on; }; @@ -140,7 +140,7 @@ fem_power: fem-power { compatible = "regulator-fixed"; regulator-name = "fem-power"; - enable-gpios = <&gpio0 7 GPIO_ACTIVE_HIGH>; /* VFEM_Ctrl = GPIO7 */ + enable-gpios = <&gpio0 7 (GPIO_ACTIVE_HIGH | ESP32_GPIO_SLEEP_HOLD_EN)>; /* VFEM_Ctrl = GPIO7, held across light sleep */ regulator-boot-on; regulator-always-on; }; @@ -247,12 +247,16 @@ pinctrl-0 = <&spim2_default>; pinctrl-names = "default"; - cs-gpios = <&gpio0 8 GPIO_ACTIVE_LOW>; /* NSS = GPIO8 */ + /* NSS held across light sleep (boards/common/pm_esp32.overlay): an + * isolated, floating chip-select lets the sleeping SX1262 clock garbage + * into its retention RAM. Inert when CONFIG_PM=n. */ + cs-gpios = <&gpio0 8 (GPIO_ACTIVE_LOW | ESP32_GPIO_SLEEP_HOLD_EN)>; /* NSS = GPIO8 */ lora0: modem@0 { compatible = "semtech,sx1262"; reg = <0>; - reset-gpios = <&gpio0 12 (GPIO_OPEN_DRAIN | GPIO_ACTIVE_LOW)>; /* RESET = GPIO12 */ + reset-gpios = <&gpio0 12 (GPIO_OPEN_DRAIN | GPIO_ACTIVE_LOW | + ESP32_GPIO_SLEEP_HOLD_EN)>; /* RESET = GPIO12 */ busy-gpios = <&gpio0 13 GPIO_ACTIVE_HIGH>; /* BUSY = GPIO13 */ dio1-gpios = <&gpio0 14 GPIO_ACTIVE_HIGH>; /* DIO1 = GPIO14 */ @@ -267,15 +271,15 @@ * off` gate CSD instead in 1.17.2, shutting the FEM down and taking * the receive path with it (~69 dB, measured). */ /* - * ESP32_GPIO_SLEEP_HOLD_EN on both: light sleep powers down - * RTC_PERIPH (see boards/common/pm_esp32.overlay for the full - * chain), and GPIO2/GPIO5 are RTC-capable pads (0-21), so their - * state is not retained across a sleep unless explicitly held. - * These two lines are the FEM's entire control surface — CSD low - * disables the PA and the LNA with it, CTX high bypasses the LNA — - * so losing them means transmitting through a bypassed PA, tens of - * dB down, with RX sensitivity gone too, while the node otherwise - * looks healthy and keeps relaying. + * ESP32_GPIO_SLEEP_HOLD_EN on both: light sleep switches every pad + * that is not held to an isolated sleep configuration (input and + * output off, no pulls), so these lines float for the length of + * each sleep (boards/common/pm_esp32.overlay). They are the FEM's + * entire control surface — CSD low disables the PA and the LNA with + * it, CTX high bypasses the LNA — and the radio keeps receiving + * through the sleep, so a floating pair means RX without its LNA + * and TX through a bypassed PA, tens of dB down, while the node + * otherwise looks healthy and keeps relaying. * The flag is ignored when CONFIG_PM=n, so this is inert on * builds that never light-sleep. */ diff --git a/zephcore/boards/esp32/heltec_wireless_tracker/board.overlay b/zephcore/boards/esp32/heltec_wireless_tracker/board.overlay index ceb97f2..3f8324f 100644 --- a/zephcore/boards/esp32/heltec_wireless_tracker/board.overlay +++ b/zephcore/boards/esp32/heltec_wireless_tracker/board.overlay @@ -28,6 +28,7 @@ */ #include +#include #include #include #include @@ -230,12 +231,16 @@ &spi2 { pinctrl-0 = <&spim2_lora>; - cs-gpios = <&gpio0 8 GPIO_ACTIVE_LOW>; + /* NSS held across light sleep (boards/common/pm_esp32.overlay): an + * isolated, floating chip-select lets the sleeping SX1262 clock garbage + * into its retention RAM. Inert when CONFIG_PM=n. */ + cs-gpios = <&gpio0 8 (GPIO_ACTIVE_LOW | ESP32_GPIO_SLEEP_HOLD_EN)>; }; &lora0 { dio1-gpios = <&gpio0 14 (GPIO_PULL_DOWN | GPIO_ACTIVE_HIGH)>; rx-boosted; + reset-gpios = <&gpio0 12 (GPIO_OPEN_DRAIN | GPIO_ACTIVE_LOW | ESP32_GPIO_SLEEP_HOLD_EN)>; }; /* diff --git a/zephcore/boards/esp32/heltec_wireless_tracker_v2/heltec_wireless_tracker_v2_procpu.dts b/zephcore/boards/esp32/heltec_wireless_tracker_v2/heltec_wireless_tracker_v2_procpu.dts index ae89fa8..d5cd4c2 100644 --- a/zephcore/boards/esp32/heltec_wireless_tracker_v2/heltec_wireless_tracker_v2_procpu.dts +++ b/zephcore/boards/esp32/heltec_wireless_tracker_v2/heltec_wireless_tracker_v2_procpu.dts @@ -85,7 +85,7 @@ vext_enable: tft_pwr_enable: vext-enable { compatible = "regulator-fixed"; regulator-name = "vext"; - enable-gpios = <&gpio0 3 GPIO_ACTIVE_HIGH>; + enable-gpios = <&gpio0 3 (GPIO_ACTIVE_HIGH | ESP32_GPIO_SLEEP_HOLD_EN)>; regulator-boot-on; regulator-always-on; /* Documents the rail's requirement, but do not rely on it. It is @@ -120,7 +120,7 @@ fem_power: fem-power { compatible = "regulator-fixed"; regulator-name = "fem-power"; - enable-gpios = <&gpio0 7 GPIO_ACTIVE_HIGH>; + enable-gpios = <&gpio0 7 (GPIO_ACTIVE_HIGH | ESP32_GPIO_SLEEP_HOLD_EN)>; /* held across light sleep */ regulator-boot-on; regulator-always-on; /* Documents the FEM's requirement, but do not rely on it — inert for @@ -244,12 +244,16 @@ status = "okay"; pinctrl-0 = <&spim2_default>; pinctrl-names = "default"; - cs-gpios = <&gpio0 8 GPIO_ACTIVE_LOW>; + /* NSS held across light sleep (boards/common/pm_esp32.overlay): an + * isolated, floating chip-select lets the sleeping SX1262 clock garbage + * into its retention RAM. Inert when CONFIG_PM=n. */ + cs-gpios = <&gpio0 8 (GPIO_ACTIVE_LOW | ESP32_GPIO_SLEEP_HOLD_EN)>; lora0: lora@0 { compatible = "semtech,sx1262"; reg = <0>; - reset-gpios = <&gpio0 12 (GPIO_OPEN_DRAIN | GPIO_ACTIVE_LOW)>; + reset-gpios = <&gpio0 12 (GPIO_OPEN_DRAIN | GPIO_ACTIVE_LOW | + ESP32_GPIO_SLEEP_HOLD_EN)>; busy-gpios = <&gpio0 13 GPIO_ACTIVE_HIGH>; dio1-gpios = <&gpio0 14 (GPIO_PULL_DOWN | GPIO_ACTIVE_HIGH)>; @@ -265,14 +269,12 @@ * path select is DIO2 in hardware, which is what leaves CTX * free to mean LNA-or-bypass. */ /* - * ESP32_GPIO_SLEEP_HOLD_EN on both: light sleep powers down - * RTC_PERIPH (chain documented in boards/common/pm_esp32.overlay) - * and GPIO4/GPIO5 are RTC-capable pads (0-21), so without the - * hold the FEM comes out of a sleep disabled and with an - * indeterminate receive path — transmit goes out through a - * bypassed PA, tens of dB down, and RX may lose the LNA, while - * the node keeps relaying and looks healthy. Inert when - * CONFIG_PM=n. + * ESP32_GPIO_SLEEP_HOLD_EN on both: light sleep switches every pad + * that is not held to an isolated sleep configuration (input and + * output off, no pulls; boards/common/pm_esp32.overlay), so without + * the hold the FEM is disabled, with an indeterminate receive path, + * for the length of each sleep — and the radio keeps receiving + * through it. Inert when CONFIG_PM=n. */ antenna-enable-gpios = <&gpio0 4 (GPIO_ACTIVE_HIGH | ESP32_GPIO_SLEEP_HOLD_EN)>; diff --git a/zephcore/boards/esp32/station_g2/board.overlay b/zephcore/boards/esp32/station_g2/board.overlay index c69cafe..e30cf27 100644 --- a/zephcore/boards/esp32/station_g2/board.overlay +++ b/zephcore/boards/esp32/station_g2/board.overlay @@ -13,6 +13,7 @@ */ #include +#include / { aliases { @@ -27,7 +28,11 @@ buttons: buttons { compatible = "gpio-keys"; user_button: user_button { - gpios = <&gpio1 6 GPIO_ACTIVE_HIGH>; /* GPIO38 = gpio1 pin 6 */ + /* Active-LOW, as upstream reads it (variants/station_g2 + * target.cpp: MomentaryButton(PIN_USER_BTN, 1000, true), where + * `reverse` means pressed == LOW). The pull-up keeps the level + * defined: it is also a light-sleep wake pin. */ + gpios = <&gpio1 6 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>; /* GPIO38 = gpio1 pin 6 */ label = "User Button"; zephyr,code = ; }; @@ -36,14 +41,17 @@ /* SX1262 + PA on SPI2 */ &spi2 { - cs-gpios = <&gpio0 11 GPIO_ACTIVE_LOW>; /* NSS = GPIO11 */ + /* NSS held across light sleep (boards/common/pm_esp32.overlay): an + * isolated, floating chip-select lets the sleeping SX1262 clock garbage + * into its retention RAM. Inert when CONFIG_PM=n. */ + cs-gpios = <&gpio0 11 (GPIO_ACTIVE_LOW | ESP32_GPIO_SLEEP_HOLD_EN)>; /* NSS = GPIO11 */ lora: lora@0 { compatible = "semtech,sx1262"; reg = <0>; spi-max-frequency = <8000000>; - reset-gpios = <&gpio0 21 GPIO_ACTIVE_LOW>; /* RESET = GPIO21 */ + reset-gpios = <&gpio0 21 (GPIO_ACTIVE_LOW | ESP32_GPIO_SLEEP_HOLD_EN)>; /* RESET = GPIO21 */ busy-gpios = <&gpio1 15 GPIO_ACTIVE_HIGH>; /* BUSY = GPIO47 = gpio1 pin 15 */ dio1-gpios = <&gpio1 16 (GPIO_PULL_DOWN | GPIO_ACTIVE_HIGH)>; /* DIO1 = GPIO48 = gpio1 pin 16 */ diff --git a/zephcore/boards/esp32/xiao_esp32s3/board.overlay b/zephcore/boards/esp32/xiao_esp32s3/board.overlay index 13f5392..0888d99 100644 --- a/zephcore/boards/esp32/xiao_esp32s3/board.overlay +++ b/zephcore/boards/esp32/xiao_esp32s3/board.overlay @@ -17,26 +17,43 @@ #include #include +#include / { aliases { lora0 = &lora; sw0 = &user_button; + /delete-property/ led0; }; buttons: buttons { compatible = "gpio-keys"; user_button: user_button { - gpios = <&gpio0 21 GPIO_ACTIVE_LOW>; + /* GPIO21 is also the XIAO's onboard LED line, which does not + * pull the pad to a defined level: without the pull-up the + * input floats, reads "pressed", and (with CONFIG_PM) wakes + * the SoC as soon as it sleeps. */ + gpios = <&gpio0 21 (GPIO_ACTIVE_LOW | GPIO_PULL_UP)>; label = "User Button"; zephyr,code = ; }; }; }; +/* GPIO21 is the user button on the Wio-SX1262 kit (as upstream MeshCore: + * PIN_USER_BTN=21) and also the XIAO's onboard LED line. The Zephyr board + * declares it led0, and ZephCore's heartbeat drives led0 — which pulled the + * button low, read as a press, and (with CONFIG_PM) woke the SoC and reopened + * the console window after every sleep. The button owns the pin. */ +/delete-node/ &led0; + /* SX1262 on SPI2 (SCK=GPIO7, MISO=GPIO8, MOSI=GPIO9 — in board pinctrl) */ +/* ESP32_GPIO_SLEEP_HOLD_EN on NSS, RESET and RXEN: light sleep isolates every + * pad that is not held (boards/common/pm_esp32.overlay), and the radio keeps + * receiving through the sleep. RXEN is the MCU-driven half of the RF switch: + * floating, the receive path is undefined. Inert when CONFIG_PM=n. */ &spi2 { - cs-gpios = <&gpio1 9 GPIO_ACTIVE_LOW>; /* GPIO41 NSS */ + cs-gpios = <&gpio1 9 (GPIO_ACTIVE_LOW | ESP32_GPIO_SLEEP_HOLD_EN)>; /* GPIO41 NSS */ lora: lora@0 { compatible = "semtech,sx1262"; @@ -44,12 +61,12 @@ spi-max-frequency = <8000000>; /* LoRa control pins */ - reset-gpios = <&gpio1 10 GPIO_ACTIVE_LOW>; /* GPIO42 */ + reset-gpios = <&gpio1 10 (GPIO_ACTIVE_LOW | ESP32_GPIO_SLEEP_HOLD_EN)>; /* GPIO42 */ busy-gpios = <&gpio1 8 GPIO_ACTIVE_HIGH>; /* GPIO40 */ dio1-gpios = <&gpio1 7 (GPIO_PULL_DOWN | GPIO_ACTIVE_HIGH)>; /* GPIO39 */ /* RF switch: RXEN for RX, DIO2 drives TX enable */ - rx-enable-gpios = <&gpio1 6 GPIO_ACTIVE_HIGH>; /* GPIO38 RXEN */ + rx-enable-gpios = <&gpio1 6 (GPIO_ACTIVE_HIGH | ESP32_GPIO_SLEEP_HOLD_EN)>; /* GPIO38 RXEN */ dio2-tx-enable; /* TCXO 1.8V via DIO3 */ diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index 4b7c0c4..d8503fa 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -7,6 +7,7 @@ #include "battery_curve.h" #include "led_gate.h" #include "buzzer_gate.h" +#include "pm_sleep_guard.h" #include #if IS_ENABLED(CONFIG_ZEPHCORE_UI_DISPLAY) #include @@ -664,8 +665,36 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch gps_get_last_known_position(&pos) ? "fix" : "no fix", (int)gsi.satellites); } + } else if (memcmp(command, "powersaving on", 14) == 0) { + /* Upstream's replies. ESP32 light-sleep builds: allowed again + * once the console window closes (upstream ESP32 waits 2 min + * from boot). nRF always idles in System ON, which is what + * upstream's nRF branch turns on, so it is simply stored. */ +#if ZC_PM_LIGHT_SLEEP + _prefs->powersaving_enabled = 1; + savePrefs(); + zc_pm_set_powersaving(true); + uint32_t win_s = zc_pm_console_window_remaining_ms() / 1000U; + if (win_s > 0) { + snprintf(reply, CLI_REPLY_SIZE, "on - After %u s (console window)", + (unsigned)win_s); + } else { + strcpy(reply, "on - Immediate effect"); + } +#elif defined(CONFIG_SOC_FAMILY_NORDIC_NRF) + _prefs->powersaving_enabled = 1; + savePrefs(); + strcpy(reply, "on - Immediate effect"); +#else + strcpy(reply, "Board not supported"); +#endif + } else if (memcmp(command, "powersaving off", 15) == 0) { + _prefs->powersaving_enabled = 0; + savePrefs(); + zc_pm_set_powersaving(false); + strcpy(reply, "off"); } else if (memcmp(command, "powersaving", 11) == 0) { - strcpy(reply, "Not implemented"); + strcpy(reply, _prefs->powersaving_enabled ? "on" : "off"); } else if (memcmp(command, "log start", 9) == 0) { _callbacks->setLoggingOn(true); strcpy(reply, " logging on"); @@ -984,6 +1013,12 @@ bool CommonCLI::handleRadioCmd(uint32_t sender_timestamp, const char* command, c void CommonCLI::handleGetCmd(uint32_t sender_timestamp, const char* command, char* reply) { const char* config = &command[4]; + if (strcmp(config, "pm") == 0) { + if (zc_pm_format_stats(reply, CLI_REPLY_SIZE) == 0) { + strcpy(reply, "Error: no light sleep on this build"); + } + return; + } /* MUST stay above the "leds" branch: that one compares only the first * four characters, so "leds.radio" and "leds.hb" both match it and * would otherwise return the master switch instead. */ diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index 2eda4a9..6f8ab5a 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -130,6 +130,11 @@ struct NodePrefs { uint8_t leds_hb_mode; // LEDS_HB_* — heartbeat LED behaviour (0 = all, as before) // Power saving uint8_t powersaving_enabled; + /* 1 = powersaving_enabled is a real choice. The field was stored but + * did nothing before ESP32 light sleep honoured it (2026-09-26), so a + * stored 0 without this marker takes the build's default once + * (powersaving_upgrade). */ + uint8_t powersaving_set; // GPS settings uint8_t gps_enabled; /* Servers only: 1 = gps_enabled is a real choice. Servers before @@ -304,6 +309,22 @@ static inline void auto_shutdown_upgrade(NodePrefs *p) { p->auto_shutdown_set = 1; } +/* `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) +#define POWERSAVING_DEFAULT 1 +#else +#define POWERSAVING_DEFAULT 0 +#endif +static inline void powersaving_upgrade(NodePrefs *p) { + if (!p->powersaving_set && p->powersaving_enabled == 0) { + p->powersaving_enabled = POWERSAVING_DEFAULT; + } + p->powersaving_set = 1; +} + #define GPS_INTERVAL_MIN_SEC 10u #define GPS_INTERVAL_MAX_SEC 604800u static inline uint32_t clampGpsInterval(uint32_t sec) { @@ -363,7 +384,8 @@ static inline void sanitizeNodePrefs(NodePrefs* p) { * restoring the default — left over from when the default was dry-run. */ p->cad_auto = saneBool(p->cad_auto, 1); p->allow_read_only = saneBool(p->allow_read_only, 0); - p->powersaving_enabled = saneBool(p->powersaving_enabled, 0); + p->powersaving_enabled = saneBool(p->powersaving_enabled, POWERSAVING_DEFAULT); + p->powersaving_set = saneBool(p->powersaving_set, 0); p->display_rotate = saneBool(p->display_rotate, 0); p->input_rotate = saneBool(p->input_rotate, 0); /* Defaults that are on, not off. */ @@ -469,7 +491,8 @@ static inline void initNodePrefs(NodePrefs* prefs) { prefs->leds_disabled = 0; // LEDs on prefs->leds_radio_mode = LEDS_RADIO_TX; // activity LED on transmit, as before prefs->leds_hb_mode = LEDS_HB_ALL; // heartbeat + unread, as before - prefs->powersaving_enabled = 0; + prefs->powersaving_enabled = POWERSAVING_DEFAULT; + prefs->powersaving_set = 1; prefs->gps_enabled = 0; prefs->gps_interval = 300; // 5 minutes prefs->advert_loc_policy = ADVERT_LOC_NONE; diff --git a/zephcore/helpers/PrefsJson.cpp b/zephcore/helpers/PrefsJson.cpp index 8d6eedc..ab1d17f 100644 --- a/zephcore/helpers/PrefsJson.cpp +++ b/zephcore/helpers/PrefsJson.cpp @@ -71,6 +71,7 @@ protected: def("xsf", (void *)_p->extra_sf, EXTRA_SF_MAX); def("disp_rot", _p->display_rotate); def("in_rot", _p->input_rotate); + def("pwr_sav_set", _p->powersaving_set); } public: @@ -140,6 +141,7 @@ protected: def("vc_flags", _p->v_contact_flags); def("vbat_mv", _p->v_battery_alert_mv); def("adc_mult", _p->adc_multiplier); + def("pwr_sav_en", _p->powersaving_enabled); } public: @@ -320,10 +322,12 @@ bool companionPrefsFromJson(NodePrefs &p, Stream &in) /* Absent keys keep the caller's value: clear the marker first, so a file * from before it existed is recognised (NodePrefs.h). */ t.auto_shutdown_set = 0; + t.powersaving_set = 0; if (!j.load(in)) { return false; } auto_shutdown_upgrade(&t); + powersaving_upgrade(&t); saneRadio(t); sanitizeNodePrefs(&t); p = t; @@ -339,6 +343,7 @@ bool serverPrefsFromJson(NodePrefs &p, Stream &in) * file from before it existed was written by firmware that ran the GPS * regardless of gps.en, and keeps doing so (NodePrefs.h). */ t.gps_enabled_set = 0; + t.powersaving_set = 0; if (!j.load(in)) { return false; } @@ -346,6 +351,7 @@ bool serverPrefsFromJson(NodePrefs &p, Stream &in) t.gps_enabled = 1; t.gps_enabled_set = 1; } + powersaving_upgrade(&t); saneRadio(t); sanitizeNodePrefs(&t); p = t; diff --git a/zephcore/helpers/pm_esp32_console.c b/zephcore/helpers/pm_esp32_console.c index 0c1575a..3a8c54e 100644 --- a/zephcore/helpers/pm_esp32_console.c +++ b/zephcore/helpers/pm_esp32_console.c @@ -19,20 +19,18 @@ * 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). + * The answer here is a console window rather than a wake source: sleep is + * blocked outright for ZEPHCORE_PM_BOOT_AWAKE_MS after boot, and again + * after every press of the user button (helpers/pm_esp32_wake.c), so a + * node is reachable for that long whenever someone is standing at it. + * On Heltec V3 a terminal that asserts DTR on open also resets the board + * through its USB bridge, which opens the window too. Boards that console + * on the S3's own USB Serial/JTAG (V4, V4.3, Wireless Tracker V2, XIAO, + * Station G2) do not reset on open, and their USB device detaches on every + * sleep; the button is the way back in, or `powersaving off` over LoRa. * - * 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. + * Remote admin over LoRa is unaffected by any of this: the radio's IRQ line + * wakes the SoC on a received packet (helpers/pm_esp32_wake.c). */ #include @@ -65,38 +63,55 @@ static struct pm_notifier console_notifier = { .state_entry = pm_console_flush, }; -/* ========== 2. Keep the node awake for a window after boot ========== */ +/* ========== 2. Keep the node awake for a console window ========== */ -#if CONFIG_ZEPHCORE_PM_BOOT_AWAKE_MS > 0 +/* 1 while the window holds its sleep lock. The lock is taken only on the + * 0 -> 1 edge and released only on 1 -> 0, so re-opening an open window just + * moves its deadline. */ +static atomic_t window_locked; -static void boot_window_expired(struct k_work *work) +static void console_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)"); + if (atomic_cas(&window_locked, 1, 0)) { + zc_pm_unblock_sleep(); + LOG_INF("console window closed — light sleep enabled " + "(press the user button to reopen it)"); + } } -static K_WORK_DELAYABLE_DEFINE(boot_window_work, boot_window_expired); +static K_WORK_DELAYABLE_DEFINE(console_window_work, console_window_expired); -#endif /* CONFIG_ZEPHCORE_PM_BOOT_AWAKE_MS > 0 */ +void zc_pm_console_window_open(void) +{ +#if CONFIG_ZEPHCORE_PM_BOOT_AWAKE_MS > 0 + if (atomic_cas(&window_locked, 0, 1)) { + zc_pm_block_sleep(); + } + k_work_reschedule(&console_window_work, + K_MSEC(CONFIG_ZEPHCORE_PM_BOOT_AWAKE_MS)); +#endif +} + +uint32_t zc_pm_console_window_remaining_ms(void) +{ + if (!atomic_get(&window_locked)) { + return 0; + } + return (uint32_t)k_ticks_to_ms_floor64( + k_work_delayable_remaining_get(&console_window_work)); +} 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)); - + /* Opened here, at POST_KERNEL, so there is no stretch at startup in + * which the node could sleep before the guard is in place. */ + zc_pm_console_window_open(); LOG_INF("light sleep deferred %d ms (console configuration window)", CONFIG_ZEPHCORE_PM_BOOT_AWAKE_MS); -#endif return 0; } diff --git a/zephcore/helpers/pm_esp32_wake.c b/zephcore/helpers/pm_esp32_wake.c new file mode 100644 index 0000000..a186ec8 --- /dev/null +++ b/zephcore/helpers/pm_esp32_wake.c @@ -0,0 +1,310 @@ +/* + * SPDX-License-Identifier: MIT + * ESP32 light sleep: wake pins, the `powersaving` switch and `get pm`. + * + * WHY NOT EXT1. Zephyr's ESP32 GPIO driver turns GPIO_INT_WAKEUP into an EXT1 + * wake, and that failed three ways (devdocs/lld/13-power-management.md, F-1/F-2): + * EXT1 takes RTC pads only (GPIO 0-21 on the S3, so not the XIAO's DIO1 on + * GPIO39); on the S3 it has a single trigger polarity for all its pins, so an + * active-low button and an active-high DIO1 cannot both be armed — the button, + * configured first, won and DIO1 was silently refused; and before every sleep + * the HAL moves each EXT1 pad to the RTC mux and nothing moves it back. + * + * WHAT THIS DOES INSTEAD. ESP-IDF's GPIO wake (gpio_wakeup_enable()) works on + * any pad in light sleep, each pin with its own level. The LoRa IRQ line and + * the user button (DT alias sw0) become level wakes for the length of each + * sleep and go back to their normal edge interrupts afterwards: + * + * entry (IRQs locked): save the pin's interrupt type and enable, mask its CPU + * interrupt, arm a level wake at the pin's active level, keep the pad's + * input live through the sleep (sleep-select off). + * exit (IRQs already back on, power.c post_ops): disarm, restore the saved + * type, clear the status the level mode latched, unmask, and if the pin + * is at its active level, re-raise its interrupt through status_w1ts. + * + * The CPU interrupt stays masked while the pin is in level mode because on + * this SoC the exit notifiers run after interrupts are re-enabled: a level + * interrupt on a line the radio holds high until ClearIrqStatus would storm. + * The re-raise is the missed edge: the level that woke us was reached while + * the pin was in level mode, so no edge will come. The driver's own ISR then + * runs the normal callbacks (the radio's dio1_isr, gpio-keys). A duplicate is + * harmless: both only queue work that re-reads the hardware. + * + * The ESP32 GPIO wake source itself (ESP_SLEEP_WAKEUP_GPIO) is switched on by + * `&gpio0 { wakeup-source; }` in boards/common/pm_esp32.overlay. + */ + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#if defined(CONFIG_WIFI_ESP32) +#include +#endif + +#include +#include + +#include "pm_sleep_guard.h" + +#include +LOG_MODULE_REGISTER(zephcore_pm_wake, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); + +/* ========== Wake pins from devicetree ========== */ + +#define ZC_LORA_NODE DT_ALIAS(lora0) +#if DT_NODE_HAS_PROP(ZC_LORA_NODE, dio1_gpios) +#define ZC_WAKE_RADIO_PROP dio1_gpios +#elif DT_NODE_HAS_PROP(ZC_LORA_NODE, irq_gpios) +#define ZC_WAKE_RADIO_PROP irq_gpios +#endif + +#define ZC_BTN_NODE DT_ALIAS(sw0) +#define ZC_WAKE_HAS_BUTTON DT_NODE_HAS_PROP(ZC_BTN_NODE, gpios) + +struct wake_pin { + const char *name; + struct gpio_dt_spec spec; + uint8_t num; /* SoC GPIO number, 0-48 */ + bool active_low; + uint8_t saved_type; + uint8_t saved_ena; + uint32_t hits; /* sleeps that ended with this pin active */ +}; + +static struct wake_pin wake_pins[] = { +#ifdef ZC_WAKE_RADIO_PROP + { .name = "radio", .spec = GPIO_DT_SPEC_GET(ZC_LORA_NODE, ZC_WAKE_RADIO_PROP) }, +#endif +#if ZC_WAKE_HAS_BUTTON + { .name = "btn", .spec = GPIO_DT_SPEC_GET(ZC_BTN_NODE, gpios) }, +#endif +}; + +/* ========== Counters (`get pm`) ========== */ + +static struct { + uint32_t entries; /* times the idle path chose light sleep */ + uint32_t slept; /* ... and the SoC actually slept */ + uint32_t wake_timer; + uint32_t wake_gpio; + uint32_t wake_other; + uint64_t asleep_us; +} stats; + +static int64_t entry_us; + +/* power.c skips the sleep (and returns at once) when the time left to the + * kernel deadline is under min-residency after its own lead time; a real light + * sleep always lasts longer than this. Only used to tell the two apart for + * the counters. */ +#define SLEPT_MIN_US 500 + +/* ========== Sleep entry / exit ========== */ + +static void wake_state_entry(enum pm_state state) +{ + if (state != PM_STATE_STANDBY) { + return; + } + + stats.entries++; + entry_us = esp_timer_get_time(); + + ARRAY_FOR_EACH_PTR(wake_pins, p) { + p->saved_type = GPIO.pin[p->num].int_type; + p->saved_ena = GPIO.pin[p->num].int_ena; + GPIO.pin[p->num].int_ena = 0; + (void)gpio_wakeup_enable(p->num, p->active_low ? GPIO_INTR_LOW_LEVEL + : GPIO_INTR_HIGH_LEVEL); + gpio_ll_sleep_sel_dis(&GPIO, p->num); + } +} + +static void wake_state_exit(enum pm_state state) +{ + if (state != PM_STATE_STANDBY) { + return; + } + + int64_t dt = esp_timer_get_time() - entry_us; + bool slept = dt >= SLEPT_MIN_US; + uint32_t causes = slept ? esp_sleep_get_wakeup_causes() : 0; + + if (slept) { + stats.slept++; + stats.asleep_us += (uint64_t)dt; + if (causes & BIT(ESP_SLEEP_WAKEUP_GPIO)) { + stats.wake_gpio++; + } else if (causes & BIT(ESP_SLEEP_WAKEUP_TIMER)) { + stats.wake_timer++; + } else { + stats.wake_other++; + } + } + + ARRAY_FOR_EACH_PTR(wake_pins, p) { + uint32_t bit = BIT(p->num & 31); + + (void)gpio_wakeup_disable(p->num); + gpio_ll_sleep_sel_en(&GPIO, p->num); + GPIO.pin[p->num].int_type = p->saved_type; + if (p->num < 32) { + GPIO.status_w1tc = bit; + } else { + GPIO.status1_w1tc.val = bit; + } + GPIO.pin[p->num].int_ena = p->saved_ena; + + bool active = gpio_ll_get_level(&GPIO, p->num) == (p->active_low ? 0 : 1); + + if (!active) { + continue; + } + if (slept) { + p->hits++; + } + if (p->saved_ena != 0 && p->saved_type != 0) { + if (p->num < 32) { + GPIO.status_w1ts = bit; + } else { + GPIO.status1_w1ts.val = bit; + } + } + } +} + +static struct pm_notifier wake_notifier = { + .state_entry = wake_state_entry, + .state_exit = wake_state_exit, +}; + +/* ========== Button: re-open the console window ========== */ + +#if ZC_WAKE_HAS_BUTTON +static struct gpio_callback button_cb; + +static void button_pressed(const struct device *port, struct gpio_callback *cb, + gpio_port_pins_t pins) +{ + ARG_UNUSED(port); + ARG_UNUSED(cb); + ARG_UNUSED(pins); + zc_pm_console_window_open(); +} +#endif + +/* ========== powersaving ========== */ + +static atomic_t powersave_blocked; + +void zc_pm_set_powersaving(bool on) +{ + if (on) { + if (atomic_cas(&powersave_blocked, 1, 0)) { + zc_pm_unblock_sleep(); + } + } else if (atomic_cas(&powersave_blocked, 0, 1)) { + zc_pm_block_sleep(); + } +} + +int zc_pm_format_stats(char *buf, size_t len) +{ + uint64_t up_ms = (uint64_t)k_uptime_get(); + 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", + atomic_get(&powersave_blocked) ? "off" : "on", + pct10 / 10U, pct10 % 10U, + stats.slept, stats.entries, + stats.wake_timer, stats.wake_gpio, stats.wake_other); + + ARRAY_FOR_EACH_PTR(wake_pins, p) { + if (n > 0 && (size_t)n < len) { + n += snprintf(buf + n, len - n, " %s%u", p->name, p->hits); + } + } + if (n > 0 && (size_t)n < len && win_s > 0) { + n += snprintf(buf + n, len - n, " win %us", win_s); + } + return (n > 0 && (size_t)n < len) ? n : (int)strlen(buf); +} + +/* ========== esp_timer deadlines bound the sleep ========== */ + +#if defined(CONFIG_PM_CUSTOM_TICKS_HOOK) +/* power.c sizes each light sleep from the kernel's next timeout only. Work + * scheduled on esp_timer (its own systimer alarm, which does not wake the SoC) + * 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). */ +int64_t pm_policy_next_custom_ticks(void) +{ + int64_t next = esp_timer_get_next_alarm_for_wake_up(); + + if (next == INT64_MAX) { + return -1; + } + int64_t delta = next - esp_timer_get_time(); + + if (delta <= 0) { + return 0; + } + return MIN((int64_t)k_us_to_ticks_floor64((uint64_t)delta), (int64_t)INT32_MAX); +} +#endif + +/* ========== Init ========== */ + +static int pm_wake_init(void) +{ + ARRAY_FOR_EACH_PTR(wake_pins, p) { + if (!gpio_is_ready_dt(&p->spec)) { + LOG_ERR("wake pin %s: port not ready", p->name); + return -ENODEV; + } + p->num = p->spec.pin; +#if DT_NODE_EXISTS(DT_NODELABEL(gpio1)) + if (p->spec.port == DEVICE_DT_GET(DT_NODELABEL(gpio1))) { + p->num += 32; + } +#endif + p->active_low = (p->spec.dt_flags & GPIO_ACTIVE_LOW) != 0; + LOG_INF("light-sleep wake: %s GPIO%u (%s)", p->name, p->num, + p->active_low ? "low" : "high"); + } + +#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 + * (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: + * the driver's AP enable (`start ota`) and STA connect (uplink, WiFi + * companion) both call esp_wifi_start() themselves. */ + (void)esp_wifi_stop(); +#endif + +#if ZC_WAKE_HAS_BUTTON + gpio_init_callback(&button_cb, button_pressed, BIT(wake_pins[ARRAY_SIZE(wake_pins) - 1].spec.pin)); + (void)gpio_add_callback(wake_pins[ARRAY_SIZE(wake_pins) - 1].spec.port, &button_cb); +#endif + + pm_notifier_register(&wake_notifier); + return 0; +} + +/* APPLICATION: after the radio and gpio-keys have configured their pins, so + * the interrupt types saved at the first sleep are theirs. */ +SYS_INIT(pm_wake_init, APPLICATION, 0); diff --git a/zephcore/helpers/pm_sleep_guard.h b/zephcore/helpers/pm_sleep_guard.h index ac4d68e..dcce2c1 100644 --- a/zephcore/helpers/pm_sleep_guard.h +++ b/zephcore/helpers/pm_sleep_guard.h @@ -9,10 +9,19 @@ * * Both calls compile to nothing without CONFIG_PM, so callers do not need to * guard their own use sites. + * + * The second half is the ESP32 light-sleep policy (helpers/pm_esp32_wake.c and + * helpers/pm_esp32_console.c, built only with CONFIG_PM on an ESP32): the + * `powersaving` switch, the console window and the `get pm` counters. Every + * other build gets the stubs, so callers need no #if either. */ #pragma once +#include +#include +#include + #if defined(CONFIG_PM) #include @@ -33,3 +42,37 @@ static inline void zc_pm_block_sleep(void) { } static inline void zc_pm_unblock_sleep(void) { } #endif /* CONFIG_PM */ + +#if defined(CONFIG_PM) && defined(CONFIG_SOC_FAMILY_ESPRESSIF_ESP32) + +#ifdef __cplusplus +extern "C" { +#endif + +/* The `powersaving` pref: off holds a sleep lock for as long as it stays off. */ +void zc_pm_set_powersaving(bool on); + +/* (Re)open the console window: no light sleep for ZEPHCORE_PM_BOOT_AWAKE_MS + * from now. Taken at boot and on every user-button press; ISR-safe. */ +void zc_pm_console_window_open(void); +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); + +#ifdef __cplusplus +} +#endif + +#define ZC_PM_LIGHT_SLEEP 1 + +#else + +static inline void zc_pm_set_powersaving(bool on) { (void)on; } +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; } + +#define ZC_PM_LIGHT_SLEEP 0 + +#endif /* CONFIG_PM && CONFIG_SOC_FAMILY_ESPRESSIF_ESP32 */ diff --git a/zephcore/helpers/ui-joystick/joystick_ui_task.cpp b/zephcore/helpers/ui-joystick/joystick_ui_task.cpp index 7a4c4be..2d98272 100644 --- a/zephcore/helpers/ui-joystick/joystick_ui_task.cpp +++ b/zephcore/helpers/ui-joystick/joystick_ui_task.cpp @@ -1520,6 +1520,7 @@ void JoystickUITask::shutdown(bool restart) #endif if (restart) { + zephcore_persist_before_off(); _display.turnOff(); sys_reboot(SYS_REBOOT_COLD); } else { @@ -1530,6 +1531,7 @@ void JoystickUITask::shutdown(bool restart) zephcore_shutdown_reason_save(ZC_SHUTDOWN_USER); zephcore_power_off(); #else + zephcore_persist_before_off(); _display.turnOff(); sys_reboot(SYS_REBOOT_COLD); #endif diff --git a/zephcore/helpers/ui/ui_mesh_actions.cpp b/zephcore/helpers/ui/ui_mesh_actions.cpp index 7a7344d..2db59ab 100644 --- a/zephcore/helpers/ui/ui_mesh_actions.cpp +++ b/zephcore/helpers/ui/ui_mesh_actions.cpp @@ -24,6 +24,7 @@ LOG_MODULE_REGISTER(zephcore_ui_actions, CONFIG_ZEPHCORE_UI_ACTIONS_LOG_LEVEL); #include #include #include +#include #include #include #include @@ -325,6 +326,7 @@ extern "C" void mesh_handle_ui_actions(void) if (actions & UI_ACTION_SAVE_RESTART) { LOG_INF("rebooting (save+restart action)"); + zephcore_persist_before_off(); sys_reboot(SYS_REBOOT_COLD); } } diff --git a/zephcore/patches/zephyr/0003-lora-sx126x-native.patch b/zephcore/patches/zephyr/0003-lora-sx126x-native.patch index 9b713f9..6faff1f 100644 --- a/zephcore/patches/zephyr/0003-lora-sx126x-native.patch +++ b/zephcore/patches/zephyr/0003-lora-sx126x-native.patch @@ -98,61 +98,32 @@ Applied from sx126x_config() after SetRfFrequency so a runtime band change re-selects it, and re-applied after each Calibrate(ALL) alongside the existing CalibrateImage re-issue. -== Mark DIO1 as a system wakeup source == +== DIO1 is a plain input: ESP32 light-sleep wake lives in ZephCore == -Without this, enabling CONFIG_PM on an ESP32 gives a node that light-sleeps -correctly and is deaf: the SX1262 asserts DIO1 on a received packet, but the -SoC is not armed to wake on that pin, so the frame is dropped and the CPU only -resumes on the next RTC timer deadline. The failure is silent and looks like a -success on a current meter, which is the worst possible shape for it. +DIO1 is configured GPIO_INPUT, as upstream does. Until 2026-09-26 this patch +passed GPIO_INT_WAKEUP on it and, on ESP32 with CONFIG_PM, registered a +pm_notifier that handed the pad back from the RTC IO mux after every light +sleep (rtc_gpio_hold_dis + rtc_gpio_deinit) and ran the DIO1 handler by hand +when the line was still high. Both are gone, because in Zephyr's ESP32 GPIO +driver GPIO_INT_WAKEUP means an EXT1 wake, and EXT1 was the wrong mechanism: -The Zephyr ESP32 GPIO driver arms the wake in its CONFIGURE path -- -gpio_esp32_config() reads (flags & GPIO_INT_WAKEUP) at drivers/gpio/gpio_esp32.c -and calls esp_sleep_enable_ext1_wakeup_io() -- not in the interrupt path, which -strips the bit. So the flag has to go on gpio_pin_configure_dt(), not on the -gpio_pin_interrupt_configure_dt() call below it. + * It takes RTC pads only (GPIO 0-21 on the S3). xiao_esp32s3 (DIO1 on + GPIO39), station_g2 (GPIO48) and thinknode_m9 (GPIO42) could not wake on + a packet at all. + * On the S3 it has one trigger polarity for all of its pins. ZephCore's + pm_esp32.overlay also armed the active-low user button, gpio-keys + configured it first (lower DT ordinal, same init priority), and the + driver refused DIO1 ("wakeup polarity conflicts with other EXT1 pins", + invisible without logging). Every light-sleeping Heltec from 1.17.1 on + woke only on its RTC timer and the button, and received by polling. + * Before every sleep the HAL routes each EXT1 pad to the RTC mux, and + nothing routes it back. -GPIO_INT_WAKEUP is deliberately NOT part of GPIO_INT_MASK (see -include/zephyr/drivers/gpio.h), precisely so it can be passed to -gpio_pin_configure() without tripping its "Interrupt flags are not supported" -assert. Drivers that do not implement wakeup ignore the bit, so this stays a -no-op on nRF/STM32/MG24 rather than needing a per-platform guard. - -Board caveat, not fixable here: on ESP32-S3 esp_sleep_is_valid_wakeup_gpio() -reduces to RTC_GPIO_IS_VALID_GPIO(), i.e. GPIO 0-21 only. Boards wiring DIO1 -above that (xiao_esp32s3 GPIO39, station_g2 GPIO48, thinknode_m9 GPIO42) log -"Pin N is not wakeup capable" and cannot use light sleep with LoRa RX at all. -Heltec V3/V4/V43 and the Wireless Trackers put DIO1 on GPIO14 and are fine. - -== Return DIO1 to the digital GPIO block after every light sleep == - -The wake above is only half of what EXT1 needs. Before each light sleep, -ESP-IDF's ext1_wakeup_prepare() (esp_hw_support/sleep_modes.c) routes the -wake pad to the RTC IO mux (RTCIO_LL_FUNC_RTC) and, with RTC_PERIPH powered -down, engages its hold. Nothing on the way out undoes either: -esp_light_sleep_start() leaves both, and Zephyr's esp32_sleep_gpio_restore() -releases only pins flagged ESP32_GPIO_SLEEP_HOLD_EN. Espressif puts the -obligation on the caller -- sleep_modes.rst, EXT1 warnings: rtc_gpio_deinit() -before using the pad as a digital GPIO again, rtc_gpio_hold_dis() after a -light-sleep wake. Nothing called either, so from the first sleep on DIO1 sat -on the RTC mux, held. - -sx126x_hal.c now registers a pm_notifier (CONFIG_PM on -SOC_FAMILY_ESPRESSIF_ESP32 only) whose state_exit does both, and then checks -the DIO1 level. The edge that woke the SoC happened while the pad belonged to -the RTC domain, so the digital edge detector never saw it; SX126x IRQs are -sticky, so DIO1 still being high means an IRQ is pending and the handler is run -by hand. Gated on the level, not unconditional: the handler reads IRQ status -over SPI, and an NSS edge in the duty-cycle sleep phase disturbs the chip's own -RX cycle. A duplicate kick is harmless (k_work_submit on a queued item is a -no-op; a handler that finds the status clear does nothing). - -Symptom it addresses: on the allowlisted Heltec repeaters a TX_DONE that lands -while the SoC sleeps -- the common case, nothing else runs during airtime -- is -lost; the adapter's wait thread times out and the packet, which did go out, -is counted nowhere (reported on a Heltec V4.3 as "sent never increments", -1.17.4/1.17.5c). The C++ adapter additionally holds a PM lock across each -transmit. Not yet verified on hardware. +ESP32 light sleep now arms the radio IRQ line and the user button as level +GPIO wakes (ESP-IDF gpio_wakeup_enable(), any pad, per-pin level) for the +length of each sleep, and re-raises the pin interrupt a sleep swallowed, from +ZephCore's helpers/pm_esp32_wake.c. Nothing here is ESP32-specific any more. +See ZephCore devdocs/lld/13-power-management.md. == Bound the lifetime of the RX-busy latch (payload-phase deadline) == @@ -3640,137 +3611,10 @@ index 0122958c32a..9d39100a713 100644 #endif /* ZEPHYR_DRIVERS_LORA_SX126X_SX126X_INTERNAL_H_ */ diff --git a/drivers/lora/native/sx126x/sx126x_hal.c b/drivers/lora/native/sx126x/sx126x_hal.c -index 34f7089584f..c21085e372e 100644 +index 34f7089584f..7093bf6b622 100644 --- a/drivers/lora/native/sx126x/sx126x_hal.c +++ b/drivers/lora/native/sx126x/sx126x_hal.c -@@ -10,6 +10,12 @@ - - #include "sx126x.h" - -+#if defined(CONFIG_PM) && defined(CONFIG_SOC_FAMILY_ESPRESSIF_ESP32) -+#include -+#include -+#define SX126X_HAL_ESP32_EXT1_WAKE 1 -+#endif -+ - #include - LOG_MODULE_REGISTER(sx126x_hal, CONFIG_LORA_LOG_LEVEL); - -@@ -107,6 +113,84 @@ void sx126x_hal_dio1_irq_enable(const struct device *dev) - ARG_UNUSED(dev); - } - -+#ifdef SX126X_HAL_ESP32_EXT1_WAKE -+/* Hand DIO1 back to the digital GPIO block after every light sleep. -+ * -+ * GPIO_INT_WAKEUP (sx126x_hal_init) makes the Zephyr ESP32 GPIO driver arm -+ * DIO1 as an EXT1 wake source. Before each light sleep, ESP-IDF's -+ * ext1_wakeup_prepare() then routes the pad to the RTC IO mux -+ * (RTCIO_LL_FUNC_RTC) and, because RTC_PERIPH is powered down, puts it in -+ * hold. Nothing on the way out undoes either: esp_light_sleep_start() leaves -+ * both in place, and Zephyr's esp32_sleep_gpio_restore() only releases pins -+ * carrying ESP32_GPIO_SLEEP_HOLD_EN. Espressif documents the obligation on -+ * the caller (sleep_modes.rst, EXT1 warnings): call rtc_gpio_deinit() before -+ * using the pad as a digital GPIO again, and rtc_gpio_hold_dis() after a -+ * light-sleep wake. Nobody did, so from the first sleep on DIO1 sat on the -+ * RTC mux with its hold engaged. -+ * -+ * Restoring the pad is not enough on its own. The DIO1 edge that woke us -+ * happened while the pad belonged to the RTC domain, so the digital edge -+ * detector never saw it and the interrupt is gone. An SX126x IRQ is sticky -+ * -- DIO1 stays asserted until ClearIrqStatus -- so the level is the -+ * authoritative record: if DIO1 is high after the restore, an IRQ is pending -+ * and the handler has to be run by hand. Without it a TX_DONE that lands -+ * while the SoC sleeps (the common case: nothing else runs during airtime) -+ * is lost -- the C++ adapter's wait thread times out and the dispatcher never -+ * counts a packet that did go out on air -- and an RX_DONE waits until -+ * something else clears the chip's IRQ status. -+ * -+ * Deliberately gated on the level rather than kicking the handler on every -+ * wake: the handler reads IRQ status over SPI, and an NSS edge during the -+ * duty-cycle sleep phase disturbs the chip's own RX cycle. A duplicate kick -+ * when the digital side did catch the edge is harmless -- k_work_submit on a -+ * queued item is a no-op, and a handler that finds the status already -+ * cleared does nothing. -+ * -+ * Runs from pm_state_notify(false), which may be in the waking ISR; every -+ * call here is ISR-safe. One radio per board, so a single static device. */ -+static const struct device *sx126x_pm_wake_dev; -+ -+static int sx126x_hal_dio1_gpio_num(const struct sx126x_hal_config *config) -+{ -+#if DT_NODE_EXISTS(DT_NODELABEL(gpio1)) -+ if (config->dio1.port == DEVICE_DT_GET(DT_NODELABEL(gpio1))) { -+ return 32 + config->dio1.pin; -+ } -+#endif -+ return config->dio1.pin; -+} -+ -+static void sx126x_hal_pm_state_exit(enum pm_state state) -+{ -+ const struct device *dev = sx126x_pm_wake_dev; -+ const struct sx126x_hal_config *config; -+ struct sx126x_hal_data *data; -+ int num; -+ -+ if (state != PM_STATE_STANDBY || dev == NULL) { -+ return; -+ } -+ -+ config = dev->config; -+ data = get_hal_data(dev); -+ num = sx126x_hal_dio1_gpio_num(config); -+ if (!rtc_gpio_is_valid_gpio(num)) { -+ return; -+ } -+ -+ (void)rtc_gpio_hold_dis(num); -+ (void)rtc_gpio_deinit(num); -+ -+ if (data->dio1_callback != NULL && gpio_pin_get_dt(&config->dio1) > 0) { -+ data->dio1_callback(dev); -+ } -+} -+ -+static struct pm_notifier sx126x_pm_notifier = { -+ .state_exit = sx126x_hal_pm_state_exit, -+}; -+#endif /* SX126X_HAL_ESP32_EXT1_WAKE */ -+ - int sx126x_hal_init(const struct device *dev) - { - const struct sx126x_hal_config *config = dev->config; -@@ -150,7 +234,13 @@ int sx126x_hal_init(const struct device *dev) - LOG_ERR("DIO1 GPIO not ready"); - return -ENODEV; - } -- ret = gpio_pin_configure_dt(&config->dio1, GPIO_INPUT); -+ /* GPIO_INT_WAKEUP: let a DIO1 assertion wake the SoC from a system -+ * low-power state, so an inbound packet is not slept through when -+ * CONFIG_PM is enabled. Outside GPIO_INT_MASK by design, so this is -+ * legal here and ignored by drivers without wakeup support. -+ */ -+ ret = gpio_pin_configure_dt(&config->dio1, -+ GPIO_INPUT | GPIO_INT_WAKEUP); - if (ret < 0) { - LOG_ERR("Failed to configure DIO1 GPIO: %d", ret); - return ret; -@@ -164,6 +254,13 @@ int sx126x_hal_init(const struct device *dev) - return ret; - } - -+#ifdef SX126X_HAL_ESP32_EXT1_WAKE -+ if (sx126x_pm_wake_dev == NULL) { -+ sx126x_pm_wake_dev = dev; -+ pm_notifier_register(&sx126x_pm_notifier); -+ } -+#endif -+ - /* Configure optional GPIOs */ - ret = sx126x_hal_configure_gpio(&config->antenna_enable, GPIO_OUTPUT_INACTIVE, - "antenna enable"); -@@ -183,6 +280,14 @@ int sx126x_hal_init(const struct device *dev) +@@ -183,6 +183,14 @@ int sx126x_hal_init(const struct device *dev) return ret; } @@ -3785,7 +3629,7 @@ index 34f7089584f..c21085e372e 100644 LOG_DBG("HAL initialized"); return 0; } -@@ -220,15 +325,54 @@ int sx126x_hal_configure_tx_params(const struct device *dev, int8_t power, +@@ -220,15 +228,54 @@ int sx126x_hal_configure_tx_params(const struct device *dev, int8_t power, } tx_power = CLAMP(power, SX1261_MIN_POWER, SX1261_MAX_POWER_TX_PARAM); } else { diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index d5da8db..0f72443 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -64,6 +64,7 @@ LOG_MODULE_REGISTER(zephcore_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #ifdef ZEPHCORE_LORA #include #include +#include #include #include #include @@ -89,6 +90,7 @@ extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line) #define MESH_EVENT_PREFS_DIRTY BIT(MESH_EVENT_ROLE_BASE + 2) /* Prefs mutated off-main; main flushes to flash */ #define MESH_EVENT_CONTACT_ITER BIT(MESH_EVENT_ROLE_BASE + 3) /* Continue contact-dump iteration on main thread */ #define MESH_EVENT_LINK BIT(MESH_EVENT_ROLE_BASE + 4) /* A transport connected or disconnected */ +#define MESH_EVENT_PERSIST BIT(MESH_EVENT_ROLE_BASE + 5) /* Flush deferred writes: a reboot/power-off waits on it */ #ifdef ZEPHCORE_LORA static void save_prefs_to_flash(void); @@ -98,7 +100,7 @@ static void power_policy_tick(void); #define MESH_EVENT_BASE (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | \ MESH_EVENT_BLE_RX | MESH_EVENT_HOUSEKEEPING | MESH_EVENT_UI_ACTION | \ MESH_EVENT_GPS_ACTION | MESH_EVENT_TX_DRAIN | MESH_EVENT_PREFS_DIRTY | \ - MESH_EVENT_CONTACT_ITER | MESH_EVENT_LINK) + MESH_EVENT_CONTACT_ITER | MESH_EVENT_LINK | MESH_EVENT_PERSIST) #if IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK) #define MESH_EVENT_ALL (MESH_EVENT_BASE | MESH_EVENT_JOYSTICK_LOOP) #else @@ -147,6 +149,46 @@ K_TIMER_DEFINE(housekeeping_timer, housekeeping_timer_fn, NULL); #ifdef ZEPHCORE_LORA static CompanionMesh *companion_mesh_ptr; + +/* The thread running mesh_event_loop(), once it is running. */ +static k_tid_t mesh_thread; +static K_SEM_DEFINE(persist_done, 0, 1); + +/* Bounds a reboot/power-off requested off the main thread. A full contacts + * rewrite is a second or two on internal flash; past this the device goes + * down anyway rather than hang on a wedged main thread. */ +#define PERSIST_WAIT_MS 5000 + +/* Main thread: the deferred contacts/channels writes, and a prefs save still + * waiting in the event mask. */ +static void persist_pending_writes(void) +{ + if (k_event_clear(&mesh_events, MESH_EVENT_PREFS_DIRTY) & MESH_EVENT_PREFS_DIRTY) { + save_prefs_to_flash(); + } + companion_mesh_ptr->flushPendingWrites(); +} + +/* zephyr_poweroff.h: every clean reboot and power-off path calls this. The + * lazy contacts deadline is long (CompanionMesh.h), so a clean shutdown that + * skipped it would lose the last hour of contact updates. */ +extern "C" void zephcore_persist_before_off(void) +{ + if (!companion_mesh_ptr) { + return; + } + if (!mesh_thread || k_current_get() == mesh_thread) { + persist_pending_writes(); + return; + } + /* The mesh state belongs to the main thread: hand the flush over. */ + k_sem_reset(&persist_done); + k_event_post(&mesh_events, MESH_EVENT_PERSIST); + if (k_sem_take(&persist_done, K_MSEC(PERSIST_WAIT_MS)) != 0) { + LOG_WRN("persist before off: main thread did not flush in %d ms", + PERSIST_WAIT_MS); + } +} #endif /* ========== Companion transports ========== @@ -308,6 +350,9 @@ static void run_contact_iteration(void) static void mesh_event_loop(void) { LOG_INF("starting event-driven loop"); +#ifdef ZEPHCORE_LORA + mesh_thread = k_current_get(); +#endif k_timer_start(&housekeeping_timer, K_MSEC(HOUSEKEEPING_INTERVAL_MS), K_MSEC(HOUSEKEEPING_INTERVAL_MS)); @@ -325,6 +370,14 @@ static void mesh_event_loop(void) process_link_change(); } +#ifdef ZEPHCORE_LORA + /* A reboot/power-off on another thread is waiting for this. */ + if ((events & MESH_EVENT_PERSIST) && companion_mesh_ptr) { + persist_pending_writes(); + k_sem_give(&persist_done); + } +#endif + #ifdef ZEPHCORE_LORA /* UI button actions (advert, pref saves) */ if (events & MESH_EVENT_UI_ACTION) { @@ -925,6 +978,8 @@ int main(void) companion_mesh.prefs.gps_interval = CONFIG_ZEPHCORE_GPS_POLL_INTERVAL_SEC; /* 0 = always on */ data_store.loadPrefs(companion_mesh.prefs); + /* ESP32 light sleep: `powersaving off` holds a sleep lock. No-op elsewhere. */ + zc_pm_set_powersaving(companion_mesh.prefs.powersaving_enabled != 0); /* Until the first fix, the position is the configured one. */ sensors.node_lat = companion_mesh.prefs.node_lat; sensors.node_lon = companion_mesh.prefs.node_lon; diff --git a/zephcore/src/server_main_common.cpp b/zephcore/src/server_main_common.cpp index 8dbd47d..0d25fdb 100644 --- a/zephcore/src/server_main_common.cpp +++ b/zephcore/src/server_main_common.cpp @@ -54,6 +54,7 @@ extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line) #include "mesh_events.h" #include "../adapters/datastore/ZephyrFsFormat.h" #include +#include #include #include #include @@ -751,6 +752,9 @@ int server_main(const ServerRole &role) } } + /* ESP32 light sleep: `powersaving off` holds a sleep lock. No-op elsewhere. */ + zc_pm_set_powersaving(prefs->powersaving_enabled != 0); + /* Apply RX boost and duty cycle from prefs */ lora_radio.setRxBoost(prefs->rx_boost != 0); lora_radio.setFemRxEnable(prefs->fem_rxgain != 0);