diff --git a/.gitignore b/.gitignore index 3d500c698..1fcac043c 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,4 @@ __pycache__/ .DS_Store Thumbs.db desktop.ini +zephcore/lib/zcpfs/SPEC.md diff --git a/AGENTS.md b/AGENTS.md index 3f4b69365..8f616b3b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,8 @@ touch before writing code. Decisions are in `docs/adr/`. ## Invariants: do not change without an ADR - Byte compatibility with Arduino MeshCore on the air and on the companion protocol; `prefs.json` format. +- Settings persist in `prefs.json` only. The legacy binary prefs files are frozen: no new fields in + `PrefsCodec`, no change to its size constants or to the pinned offsets and lengths in its tests. - The wall clock only moves forward. - Mesh, GPS state and UI run on the main thread; loops are event-driven, no polling. - No heap in the packet path. Companion builds are RAM-bound. @@ -48,10 +50,14 @@ touch before writing code. Decisions are in `docs/adr/`. ## Mechanics - Branch from `dev`, target `dev`, rebase instead of merging. +- After a rebase, re-read the whole diff against `dev`: a branch older than the code around it can undo newer + work without a conflict. - Tabs for indentation, except the upstream-ported files. - Build with `--pristine` when the board or role changes; ESP32 S3/C-series repeaters need `--sysbuild`. - Host tests: `python3 tests/run.py run --profile quick` (Linux or WSL). A new case needs a - `tests/catalog.json` entry. + `tests/catalog.json` entry. A failing pinned test is a finding: do not edit the pinned values to make it pass. +- PR CI runs the host tests only. Build every board you touched and one you did not, and run each role you + changed. Releases build from `zephcore.yml` alone, so nothing may depend on a hand-passed fragment. - New board: `zephcore.yml` without `release:`, then `python zephcore/scripts/board_manifest.py check` and `... docs`. - Docs in the same PR: a clause in `docs/ARCHITECTURE.md` when behaviour, an interface or a limit changes; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b9365f151..c31ccd2e5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -600,7 +600,7 @@ Handles the binary BLE protocol with ~50 command opcodes. Key features: - **ACK tracking**: 8-slot table, computes expected ACK = SHA256(secret + hash)[0:4] - **Contact iteration**: Streaming protocol with `lastmod` filtering for incremental sync. The dump advances only while a client is connected and every connected transport has room; it is cancelled when the last client leaves -- **Lazy write batching**: Dirty contacts/channels flush after 5-second delay +- **Lazy write batching**: dirty contacts and channels are written 5 s after the last change, so a burst (a contacts import) is written once; a contact change that only refreshes liveness waits up to an hour (`LAZY_WRITE_LIVENESS_MS`) - **Protocol versioning**: V2/V3 frame format negotiation with phone app - **Ed25519 signing**: 3-phase flow (start→data→finish) for signing up to 8KB - **Flood scope**: Transport key filtering for region-scoped sends @@ -738,6 +738,7 @@ Listen-only node (ESP32 only): receives LoRa packets and publishes them to an MQ ### 6.6 CommonCLI Commands System: `ver`, `board`, `reboot`, `start dfu`, `start ota`, `erase` +Hardware: `hw`, `hw board/rtc/i2c [scan]/gps/sensors/all` — see `CLI_commands.md` Config: `set name/freq/radio/tx/flood.max/password/...`, corresponding getters System (power): `poweroff`/`shutdown`, `get pwrmgt.support/source/bootreason/bootmv` GPS: `gps`, `gps on/off/sync/setloc/advert`, `get gps`, `set gps duty ` @@ -838,6 +839,8 @@ Repeaters and room servers default to `CONFIG_ZEPHCORE_REPEATER_GPS_INTERVAL_SEC went away is reported as a DTR drop in three cases: DTR low, VBUS removed, and a bus reset. The reset case exists because the CDC class keeps the old line state across a reset, so nothing else says that the port the host had open is gone (a hub resetting upstream leaves VBUS up). Bus suspend, resume and reset also reach a second callback. + While a host is on the bus (from reset, configuration or resume until suspend or VBUS loss) the module blocks + ESP32 light sleep: sleep stops the USB clock and the host loses the device without being told. - **`ZephyrCompanionUSB`** (companion): the wired companion transport, over the CDC port or a plain UART (`zephcore,companion-uart`). Framing: `<` (to the node) or `>` (from it), a little-endian 16-bit length, the payload; a first byte that is printable and not `<` starts the text CLI instead. It has its own receive queue and an @@ -850,7 +853,7 @@ Repeaters and room servers default to `CONFIG_ZEPHCORE_REPEATER_GPS_INTERVAL_SEC ### 7.5 Board (`adapters/board/`) -- Battery ADC with optional regulator-gated voltage divider, 8-sample average (boards with `zephyr,user` ADC node; MG24 has no battery divider, ADC disabled) +- Battery ADC with optional regulator-gated voltage divider, 8-sample average (boards with `zephyr,user` ADC node; MG24 has no battery divider, ADC disabled). A board with a fuel gauge (AXP2101) reads voltage and charge from it instead; one with an nPM1300 charger (XIAO nRF54LM20A) reads the voltage from the PMIC's own ADC through the sensor API and takes the percentage from the discharge curve - UF2 bootloader entry via GPREGRET magic (0x57 = UF2, 0xA8 = BLE DFU) - TX LED bracketing for LoRa transmissions (gated by the LED master switch below) - Bootloader version detection via flash memory scan @@ -858,9 +861,9 @@ Repeaters and room servers default to `CONFIG_ZEPHCORE_REPEATER_GPS_INTERVAL_SEC - `powerOff()` (CLI `poweroff`/`shutdown`, UI power-off, low-battery shutdown all end in `zephcore_power_off()`, `board/zephyr_poweroff.c`): UI hook (heartbeat, display), GPS/sensor/buzzer rails off, LoRa held in reset, sw0 SENSE armed (nRF), power latch released, System OFF - Upstream's pwrmgt getters: reset cause from `boot_info`, the last fatal error from `fatal_reboot.c`'s `__noinit` record (reason, pc, lr, thread; resolve pc with `addr2line` on the same build), shutdown reason from the `/lfs/shutdn` marker, battery voltage at boot -**Telemetry** (`REQ_TYPE_GET_TELEMETRY_DATA`, every role, upstream's shape): battery on channel 1, then `sensors.querySensors()` (`adapters/sensors/ZephyrSensorManager.cpp`): GPS position on channel 1 while the GPS is on and has a fix, each environment sensor and power-monitor channel found at boot on its own channel from 2 up (probe order), board-local analog sensors (T1000-E) on channel 1; then the MCU temperature on channel 1. Servers give guests battery + MCU temperature only and honour the requester's inverse permission mask, as upstream. Encoded by `helpers/compat/CayenneLPP.h`: upstream's library API and wire format, but values round to the nearest step where the library truncates. +**Telemetry** (`REQ_TYPE_GET_TELEMETRY_DATA`, every role, upstream's shape): battery on channel 1, then `sensors.querySensors()` (`adapters/sensors/ZephyrSensorManager.cpp`): GPS position on channel 1 while the GPS is on and has a fix, each environment sensor and power-monitor channel found at boot on its own channel from 2 up (probe order), board-local analog sensors (T1000-E) on channel 1; then the MCU temperature on channel 1 (the SoC's die sensor: the `die-temp0` alias, which is `coretemp` on ESP32-S3/C3/C6, else the Nordic nodelabel `temp`; omitted where there is none: classic ESP32, STM32WL, MG24). Servers give guests battery + MCU temperature only and honour the requester's inverse permission mask, as upstream. Encoded by `helpers/compat/CayenneLPP.h`: upstream's library API and wire format, but values round to the nearest step where the library truncates. -**Clock** (`adapters/clock/ZephyrRTCClock`): `setCurrentTime()` also writes the hardware RTC when the board has one (coalesced on the system work queue; a time outside 2000-2099 is not written), as upstream's `AutoDiscoverRTCClock` writes its chip; `seedCurrentTime()` is the boot restore, not written back. A chip is adopted at boot only once it is identified (`adapters/clock/ZephyrRTCDiscover.c`), because other parts can answer at the same address: a device is passed over when two reads each show a bit set that the chip's data sheet fixes at zero, or a time field out of range, and a time is restored only from a read in which every field is valid. The time is written in each chip's own encoding (the RX8130CE takes a one-hot weekday, `weekday-one-hot` in its descriptor), and an RV-3028 whose descriptor carries `rv3028-eeprom-config` (the RAK12002 on a RAK4631) has that configuration, its backup switchover, stored in the chip's EEPROM. Without a hardware RTC, the last time read is kept in no-init memory (RTC slow memory on ESP32) and restored after a reset that keeps RAM (reboot, crash, watchdog), as upstream's `ESP32RTCClock`; a power cycle starts from 1970 again. +**Clock** (`adapters/clock/ZephyrRTCClock`): `setCurrentTime()` also writes the hardware RTC when the board has one (coalesced on the system work queue; a time outside 2000-2099 is not written), as upstream's `AutoDiscoverRTCClock` writes its chip; `seedCurrentTime()` is the boot restore, not written back. A chip is adopted at boot only once it is identified (`adapters/clock/ZephyrRTCDiscover.c`), because other parts can answer at the same address: a device is passed over when two reads each show a bit set that the chip's data sheet fixes at zero, or a time field out of range, and a time is restored only from a read in which every field is valid. An RV-3028 cleanly identified in 12-hour mode has its `twelve-hour-bit` cleared first, so the chip converts its own hours. No time is taken from a cleanly identified RV-3028 whose `twelve-hour-bit` register cannot be read or reads FFh, or whose clear or re-read fails. The time is written in each chip's own encoding (the RX8130CE takes a one-hot weekday, `weekday-one-hot` in its descriptor), and an RV-3028 whose descriptor carries `rv3028-eeprom-config` (the RAK12002 on a RAK4631) has that configuration, its backup switchover, stored in the chip's EEPROM. `zephcore_rtc_snapshot()` gives `hw rtc` each candidate's outcome from the last probe run and the adopted chip, published together when the run ends. Without a hardware RTC, the last time read is kept in no-init memory (RTC slow memory on ESP32) and restored after a reset that keeps RAM (reboot, crash, watchdog), as upstream's `ESP32RTCClock`; a power cycle starts from 1970 again. **LED master switch** (`helpers/led_gate.{c,h}`, `set leds on|off`, all roles): one process-wide flag every LED driver consults — heartbeat and unread-message LEDs in `helpers/ui/ui_common.c`, the @@ -871,10 +874,29 @@ hook so a CLI change also stops a lit heartbeat and refreshes the UI's LEDs page `NodePrefs.leds_disabled` (companion offset 93; repeater offset 120, magic-encoded — see §13). Does not cover the display backlight, which has its own UI brightness setting (`display_brightness`). +**LED brightness** (`set leds.brightness 0-100`): a board that aliases `heartbeat-pwm-led` / +`lora-tx-pwm-led` drives that LED through PWM (`zephcore_led_pwm_write()`), and the PWM alias wins over +`led0` / `lora-tx-led`. `CONFIG_ZEPHCORE_LED_PWM` follows the aliases: it selects `CONFIG_PWM` and stops +the server and observer mains from configuring `led0` as a GPIO, because that pin belongs to the PWM +driver. Persisted in `prefs.json` as `zc.leds_brightness`; it is not part of the legacy binary files. + ### 7.6 WiFi / MQTT / TCP Transports -- **`adapters/wifi/ZephyrWiFiStation.c`**: WiFi STA client (ESP32) used by the observer, the repeater uplink and the WiFi companion -- **`adapters/mqtt/ZephyrMQTTPublisher.c`**: MQTT publisher for observed/uplinked packets +- **`adapters/wifi/ZephyrWiFiStation.c`**: WiFi STA client (ESP32) used by the observer, the repeater uplink and the WiFi companion. + The observer and the uplink run with the driver's WiFi power save (minimum modem sleep: the WiFi radio is off + between the access point's DTIM beacons); the WiFi companion requests power save off, as before. On ESP32 + light-sleep builds the station blocks SoC light sleep until the link is ready (DHCP, SNTP) and its client has + reported a session with `zc_wifi_station_session_up()`, and again whenever either is lost. With no such client + (the WiFi companion) the block stays for the life of the boot. While the SoC sleeps the WiFi MAC wakes it for + each beacon it listens to, and `helpers/pm_esp32_wake.c` refuses a sleep while the MAC's beacon timer is active. + On light-sleep companion builds the WiFi companion runs with power save too and reports a session at once, so + the station blocks sleep only while the link is not ready. +- **`adapters/mqtt/ZephyrMQTTPublisher.c`**: MQTT publisher for observed/uplinked packets. It reports its session to + the station: up at CONNACK, down when the session ends for any reason. Keepalive: PINGREQ once a quarter of + the 60 s keepalive is left, so a broker that enforces the keepalive without grace does not close an idle + session. + TLS is used without certificate verification, but the chain still has to be parsed: the uplink and observer + builds carry P-256 and P-384 with SHA-256 and SHA-384 for that. - **`adapters/ota/wifi_ota.c`**: WiFi SoftAP + HTTP firmware upload to MCUboot slot1 (ESP32, requires `--sysbuild`) Companion transports, as upstream's `companion_radio`: each is a `BaseSerialInterface` @@ -882,7 +904,9 @@ Companion transports, as upstream's `companion_radio`: each is a `BaseSerialInte BLE, USB/UART and TCP can all be connected at once; every connected client gets every frame (a reply to one app also reaches the other). -- **`adapters/ble/ZephyrBLE.cpp`** (+ `ble_gatt_layout.cpp`, `ble_dfu.cpp`): BLE NUS +- **`adapters/ble/ZephyrBLE.cpp`** (+ `ble_gatt_layout.cpp`, `ble_dfu.cpp`): BLE NUS. Blocks ESP32 light sleep from + connect until the link is encrypted (pairing does not survive sleep); an encrypted link and advertising do not + block it. - **`adapters/usb/ZephyrCompanionUSB.cpp`**: USB CDC-ACM, or a plain UART (`zephcore,companion-uart` chosen node; the only link on the Bluetooth-less LoRa-E5), with the text CLI - **`adapters/transport/TcpCompanionTransport.c`**: TCP (port 5000, MeshCore `SerialWifiInterface` framing) on native Linux and on WiFi companions (`app/CompanionWifi.cpp`, `capabilities: wifi: true`) - **`adapters/transport/frame_txq.c`**: the TX queue BLE and TCP share (congestion, overflow slot, lossless replies) diff --git a/docs/CLI_commands.md b/docs/CLI_commands.md index 78498992a..a2fdbd291 100644 --- a/docs/CLI_commands.md +++ b/docs/CLI_commands.md @@ -45,7 +45,118 @@ The text CLI of every ZephCore role. Commands are sent over USB serial (CDC-ACM) | `start ota` | ESP32: start WiFi AP + HTTP OTA server. nRF52: reboot into BLE OTA DFU mode | | `stop ota` | *(ZephCore only)* Stop WiFi OTA server (ESP32 only) | | `clkreboot` | Set clock to a fixed reference time (15 May 2024 8:50pm UTC) then reboot. On a board with a hardware RTC the reference time is written to the chip too, as upstream, so the reset survives the reboot | -| `powersaving` / `powersaving on` / `powersaving off` | Upstream's command and replies. On ESP32 light-sleep repeaters (boards with `light_sleep: true`) it gates light sleep: `off` (persisted) keeps the SoC awake, which also keeps a USB Serial/JTAG console attached; `on` allows sleep again, replying `on - After N s (console window)` while the console window is open, else `on - Immediate effect`. Default **on** on those builds, and prefs from firmware that stored the field without acting on it are read as on once. nRF52/nRF54L: stored, replies `on - Immediate effect` (they always idle in System ON; `off` changes nothing). Other boards: `Board not supported` | +| `powersaving` / `powersaving on` / `powersaving off` | Upstream's command and replies. On ESP32 light-sleep builds (repeaters of boards with `light_sleep: true`, companions of boards with `light_sleep_companion: true`) it gates light sleep: `off` (persisted) keeps the SoC awake, which also keeps a USB Serial/JTAG console attached; `on` allows sleep again, replying `on - After N s (console window)` while the console window is open, else `on - Immediate effect`. Default **on** on those repeaters, and prefs from firmware that stored the field without acting on it are read as on once. Default **off** on companions: a companion sleeps only after `powersaving on`, and then not while a USB host is attached, the display is on, or a Bluetooth link is not yet encrypted. nRF52/nRF54L: stored, replies `on - Immediate effect` (they always idle in System ON; `off` changes nothing). Other boards: `Board not supported` | + +--- + +## Hardware Report *(ZephCore only)* + +`hw` reports what the firmware is and what hardware it actually found. A release build +compiles every `MESH_DEBUG_*` call away, so this is the only way to get these facts off a +stock node. Implemented in `helpers/HardwareReport.cpp` and dispatched from `CommonCLI`, so +it is available on the **three roles that route through it** — companion, repeater and room +server. The observer has a CLI of its own and does not offer `hw`. On a companion `hw` is local +only (USB, the app's CLI command and the V-Contact chat): the companion forwards remote admin +commands only from upstream's set, and ZephCore-only commands stay local by design. + +| Command | Description | +|---------|-------------| +| `hw` | Summary. Emits exactly these fields, one per line: ` ()` — the short board name and SoC, not the full board target — then `fw`/`role`, `rtc` (the chip the last probe run adopted for write-back), `gnss`, `i2c` count, `reset`. `hw N` resumes it | +| `hw board [start]` | `board`, `name`, `soc`, `zephyr`, `fw`, `role`, `bootloader` (when the board can report one), `reset`, `devid` | +| `hw rtc [start]` | Declared I2C RTC candidates and the last probe run's outcome for each | +| `hw i2c [start]` | Devicetree-declared I2C inventory. **Not a bus scan** — no bus traffic | +| `hw i2c scan [start]` | Live scan of 0x08–0x77 on each bus carrying a declared device, naming declared addresses (see the coverage note below) | +| `hw gps [start]` | GNSS driver compatible, transport, baud, and enable state. A GNSS node disabled in devicetree reports `declared but disabled` (the summary says `disabled`) | +| `hw sensors [start]` | Each sensor the boot probe found, environment and power monitors alike, and the fields a fresh read of it returns. Each one on the page being returned is read on the spot, as for a telemetry request | +| `hw all [start]` | `board`, `rtc`, `i2c`, `gps` and `sensors`, in that order, reading each sensor whose line is on the page. Not the summary, and not `hw i2c scan`, which probes every address | +| `hw ` | Usage string | + +`start` is an optional line index for resuming a truncated reply (see **Paging** below). + +**Radio parameters are not reported here.** `get freq`, `get sf`, `get bw`, `get tx` and the +`stats-radio` family already cover them. Duplicating a live setting in a second place is how the +two drift apart. + +### `hw i2c` and `hw i2c scan` answer different questions + +This is deliberate and the replies say which is which. + +- **`hw i2c`** enumerates what the **devicetree declares**. It costs no bus traffic and + carries each node's address, bus and compatible for free. It **cannot see a chip the board + does not declare.** +- **`hw i2c scan`** probes the wire with a one-byte read of each address, so nothing is written to a + device that has not been identified. It finds + undeclared hardware, but a bare address is all the bus itself reveals; declared addresses + are annotated with their compatible. Where several nodes share an address (the common + sensor list puts both the BME280 and the BMP388 at 0x77) every one is listed, `|`-separated, + since a scan cannot tell which part answered. A probe that times out or finds the bus busy stops + that bus's scan with `bus not responding at 0xNN (err N), scan stopped`, so a dead or held + bus cannot keep the CLI waiting through every address. + +A scan and a devicetree enumeration are not the same fact. Reporting one as the other is how +a tool ends up stating something nobody established. + +**Coverage.** Neither is tied to a bus nodelabel: both are built by walking the whole +devicetree, so a device on `i2c22` or `i2c30` is covered exactly like one on `i2c0`. The tree +does have boards on those labels. + +What each covers, precisely: + +- `hw i2c` lists **every declared I2C device**, on any bus. +- `hw i2c scan` probes **each bus that carries at least one declared device**. + +So one gap exists and is worth knowing: an enabled bus with **no declared device at all** is +never scanned, because the set of buses is derived from the declared devices. In practice a +bus is enabled because something is on it, but a board that enables a bus purely for an +undeclared or hot-plugged chip would not see it probed. + +### What the report will not do + +Every field is something the firmware *knows*. It never infers: + +- The GNSS model is reported as the **bound driver's** compatible. A board overlay comment + naming a part is documentation, not detection — `rak4631` documents a u-blox MAX-7Q while + binding `gnss-nmea-generic`, which happily drives whatever NMEA receiver is fitted, so the + reply says `model: not identified (generic NMEA driver)` rather than repeating the comment. +- A board that declares no RTC reports `rtc: none declared in devicetree`, **not** "no RTC". + `boards/common/rtc-i2c.dtsi` is opt-in per board, because its fixed addresses (0x68, 0x51, + 0x52, 0x32) collide with common parts — an IMU sits at 0x68, same as a DS3231. A board with + an RTC fitted but not declared is invisible to the firmware, and the reply says exactly that. + A board that declares candidates in a build with RTC autodiscovery disabled reports + `rtc: N declared, autodiscovery disabled`. +- An RTC candidate the probe never reached, whose I2C bus was not ready, or whose read + failed with any error other than `-EIO`, reports `unprobed`, not `absent`. The probe stops at the + first chip holding a valid time. +- `absent` means the read ended in `-EIO`, which is how every driver here reports an address + NACK, or that two reads each showed a device that is not this RTC. On every driver here a + data NACK is also `-EIO`, and the nRF drivers report any other bus error event the same way, + so such a failure reads as `absent` too; the drivers give nothing finer. `present` means the + probe identified the chip as this RTC, which includes a chip whose time could not be read. + The first chip identified is adopted for write-back (marked `*`). +- `all 0xff` means the first read returned 0xFF in every byte: an erased EEPROM, or an RTC + that has not started. The probe skips it. If no RTC was adopted, the first time save + probes once more, which can settle it. Like + `unprobed`, it keeps the summary at `none found, N unsettled` rather than `none present`. +- A build with no sensor support reports `not compiled in`, which is a different statement + from a sensor manager that looked and found nothing. + +### Paging + +A reply is bounded by `CLI_REPLY_SIZE` (256) locally and by `CLI_REMOTE_REPLY_SIZE` (161) over +remote admin, since a remote reply rides the caller's LoRa packet buffer. On a companion a local +reply is bounded by the app's CLI frame (175 bytes) instead, because the app path cannot be told +apart from the USB console. Either bound is less any `xx|` prefix the line carried, local or +remote. When a section does not fit, +the reply ends with a line `... next:N`, as `sensor list` does, and `hw
N` (or `hw N` for the summary) resumes from that +line. A single line too long for a whole page, or longer than 159 characters, is cut to fit and ends in `...`, so a resume +always moves past it. A start index past the last line answers `no line N, the report has M`. + +Each page is computed afresh, so a resumed `hw i2c scan` re-scans the bus. If a chip appears or +disappears between pages, the line numbering can move and a resumed page may skip or repeat a +line. + +Room is reserved for the marker before the buffer fills, so the resume index is never itself +truncated — a page that cannot say where to resume is unresumable. --- @@ -292,11 +403,13 @@ Companion builds only. Local only: they run from USB and the app's CLI, and a re ## Observer (ZephCore only) -The observer role (ESP32, `boards/common/observer.conf`) has its own CLI and none of the commands in the other sections: no `reboot`, `ver`, `erase`, `set radio` or `set tx`. USB serial only — an observer never transmits, so there is no remote admin. A `get` replies with the bare value, without the `> ` prefix; a `set` echoes `key=value`; errors start with `ERR`. Every `set` is saved and applied at once, with no reboot. +The observer role (ESP32, `boards/common/observer.conf`) has its own CLI and, apart from `reboot` and `erase`, none of the commands in the other sections: no `ver`, `set radio` or `set tx`. USB serial only — an observer never transmits, so there is no remote admin. A `get` replies with the bare value, without the `> ` prefix; a `set` echoes `key=value`; errors start with `ERR`. Every `set` is saved and applied at once, with no reboot. | Command | Description | |---------|-------------| | `help` | Status banner (node, key, radio, WiFi and MQTT state) followed by the command list. Also printed at boot | +| `reboot` | Restart the node. Replies `OK - rebooting`, as on a repeater | +| `erase` | Factory reset, as on a repeater: erases the whole LittleFS volume (identity, name, radio, WiFi and MQTT settings) and restarts onto defaults. Replies `File system erase: OK - rebooting` | | `get role` | `observer` | | `get name` | Node name. Default `Observer-XXXXXXXX` | | `get board` | Board name | @@ -358,8 +471,9 @@ The observer role (ESP32, `boards/common/observer.conf`) has its own CLI and non | `get owner.info` | Owner/contact info (pipes `\|` display as newlines) | | `get int.thresh` | Interference threshold | | `get leds` | *(ZephCore only)* LED master switch: `on` or `off` | -| `get leds.radio` | *(ZephCore only)* Activity-LED mode: `tx`, `rx`, `all` or `off`. Appends `(no radio LED on this board)` where the board has no `lora-tx-led` alias | +| `get leds.radio` | *(ZephCore only)* Activity-LED mode: `tx`, `rx`, `all` or `off`. Appends `(no radio LED on this board)` where the board has neither a `lora-tx-led` nor a `lora-tx-pwm-led` alias | | `get leds.hb` | *(ZephCore only)* Heartbeat-LED mode: `all`, `hb`, `unread` or `off`. Appends `(no heartbeat LED on this board)` where the board has neither `led0` nor `led1` | +| `get leds.brightness` | *(ZephCore only)* Brightness of a PWM-capable heartbeat/activity LED, `0` to `100` as a percentage. Boards without a `heartbeat-pwm-led` / `lora-tx-pwm-led` alias still report a value (nothing reads it) | | `get buzzer` | *(ZephCore only; room server only)* Buzzer/vibration mode as ` ()`: `0 (silent)`, `1 (sound+vib)`, `2 (vibrate)`, `3 (sound)`. Compiled out on repeater builds (`#ifndef ZEPHCORE_REPEATER`) — a repeater answers `unknown config: buzzer`. | | `get agc.reset.interval` | Removed — replies `Removed - Automatic AGC reset is on`. Periodic AGC recalibration was deleted (it reset the noise floor to its unseeded sentinel on every fire). Use `set rxduty` to cut RX current. | | `get multi.acks` | Extra ACK transmit count (`0` or `1`) | @@ -387,7 +501,7 @@ The observer role (ESP32, `boards/common/observer.conf`) has its own CLI and non | `get extra.sf` | LR2021 side detectors: the extra spreading factors currently received alongside `sf`, comma-separated (bare, no `> ` prefix), or `No extra SF configured`. Reflects the saved prefs, not what the chip accepted — if the set became invalid after an `sf`/`bw` change it is reported here but was refused at boot (a `WRN` line says so). | | `get adc.multiplier` | Battery voltage ADC calibration multiplier | | `get bootloader.ver` | Bootloader version string | -| `get pm` | *(ZephCore only)* ESP32 light-sleep builds: `> on asleep P% sleeps S/E wake tT gG oO radioR btnB [win Ns]` (`off` when powersaving is off): powersaving state, share of uptime asleep, sleeps taken out of light-sleep entries, wake causes (RTC timer, GPIO, other), sleeps that ended with the radio IRQ / user button active, and the console window left. Radio wakes should track the packets the node hears. Other builds: `Error: no light sleep on this build` | +| `get pm` | *(ZephCore only)* ESP32 light-sleep builds: `> on asleep P% sleeps S/E wake tT gG wW oO radioR btnB [win Ns]` (`off` when powersaving is off): powersaving state, share of uptime asleep, sleeps taken out of light-sleep entries, wake causes (RTC timer, GPIO, WiFi, other), sleeps that ended with the radio IRQ / user button active, and the console window left. Radio wakes should track the packets the node hears. WiFi wakes are the beacons an associated station (the uplink) wakes for: about ten a second on most access points, none without the uplink. Other builds: `Error: no light sleep on this build` | | `get pwrmgt.support` | `> supported` on nRF52 (VBUS detection, boot voltage), else `> unsupported` — upstream's reply | | `get pwrmgt.source` | nRF52: `> external` (VBUS present) or `> battery`; elsewhere `ERROR: Power management not supported` | | `get pwrmgt.bootreason` | `> Reset: ; Shutdown: `. The cause is this boot's hardware reset cause as labels (`PIN`, `SOFTWARE`, `BROWNOUT`, `POR`, `WATCHDOG`, `LOWPOWER` for a wake from System OFF, ...), followed by `(crash in , pc 0x...)` when the previous run ended in a fatal error and rebooted — resolve the pc with `addr2line` against the same build. The shutdown reason is `User Request`, `Low Voltage` or `None` | @@ -441,8 +555,9 @@ four radio parameters together, since they are one interop-critical set. | `set int.thresh ` | | Interference detection threshold | | `set buzzer <0\|1\|2\|3>` | or `off` / `on` / `vibrate` / `sound` | *(ZephCore only; room server only)* `0`/`off` silent, `1`/`on` sound + vibration, `2`/`vibrate` vibration only, `3`/`sound` sound only. Modes 2 and 3 need a vibration motor; without one the node replies `Error: no vibration motor on this board - use 0 or 1`. Applied live and persisted. Compiled out on repeater builds. | | `set leds ` | default **on** | *(ZephCore only)* Master switch for every LED on the node, applied live and persisted: heartbeat, unread-message and LoRa TX-activity LEDs, plus the message and shutdown flashes. Works on every role, including headless repeaters where the TX LED is the only one that ever lights. Does **not** cover the display backlight, which is a separate UI brightness setting. | -| `set leds.radio ` | default **tx** | *(ZephCore only)* What the LoRa activity LED reacts to, applied live and persisted. `tx` lights it for the duration of each transmit (the behaviour before this setting existed), `rx` gives a 30 ms blink per valid packet received, `all` does both, `off` keeps it dark. Sits **below** `set leds` — the master switch off keeps it dark whatever this says. Only boards defining the `lora-tx-led` alias have this LED; elsewhere the value is stored but does nothing, and the reply says so. | +| `set leds.radio ` | default **tx** | *(ZephCore only)* What the LoRa activity LED reacts to, applied live and persisted. `tx` lights it for the duration of each transmit (the behaviour before this setting existed), `rx` gives a 30 ms blink per valid packet received, `all` does both, `off` keeps it dark. Sits **below** `set leds`: the master switch off keeps it dark whatever this says. Only boards defining a `lora-tx-led` or `lora-tx-pwm-led` alias have this LED; elsewhere the value is stored but does nothing, and the reply says so. | | `set leds.hb ` | default **all** | *(ZephCore only)* What the heartbeat LED reacts to, applied live and persisted. `all` is the 4 s liveness tick that widens from 20 ms to 200 ms while messages are unread (the behaviour before this setting existed), `hb` never widens, `unread` stays dark until there are unread messages, `off` keeps it dark. Also sits below `set leds`. See the LED-topology notes below for what this does on single-LED boards. | +| `set leds.brightness <0-100>` | default **100** | *(ZephCore only)* Brightness of a PWM-capable heartbeat/activity LED, applied live and persisted, `%` suffix accepted. Only boards defining a `heartbeat-pwm-led` or `lora-tx-pwm-led` alias have a dimmable LED; elsewhere the value is stored but has no effect. Also sits below `set leds`. | | `set agc.reset.interval ` | Accepted, ignored | Removed — replies `Removed - Automatic AGC reset is on`. The prefs byte is still read and written so the on-flash layout stays byte-exact, but nothing acts on it. | | `set multi.acks <0\|1>` | | Enable extra ACK transmits | | `set path.hash.mode ` | 0, 1, or 2 | Path hashing algorithm | @@ -477,9 +592,9 @@ four radio parameters together, since they are one interop-critical set. ## Notes -- **LED topology differs by board, and it changes what `leds.radio` / `leds.hb` can do.** Two DT aliases - decide it: `lora-tx-led` drives the radio activity LED, and `led0` (falling back to `led1`) drives the - heartbeat. Of the 35 boards in tree: +- **LED topology differs by board, and it changes what `leds.radio` / `leds.hb` can do.** DT aliases + decide it: `lora-tx-led` or `lora-tx-pwm-led` drives the radio activity LED, and `led0` (falling back + to `led1`) drives the heartbeat. Of the 35 boards in tree: - **Separate pins (6)** — `rak4631`, `sensecap_solar`, `thinknode_m1`, `thinknode_m6`, `lilygo_techo`, `xiao_nrf52840`. Both settings are fully independent. `xiao_nrf52840` has three LEDs: blue heartbeat, green unread, red radio. @@ -487,8 +602,17 @@ four radio parameters together, since they are one interop-critical set. `gat562_30s`, `rak3401_1watt`, `rak_wismesh_tag`, `lilygo_timpulse_plus`. Here `lora-tx-led` **is** the heartbeat pin, so both settings drive one LED. Radio activity takes priority and the heartbeat yields while the radio holds the pin, so a transmit is never blanked mid-packet by the heartbeat's off-timer - (and vice versa). Use `set leds.hb off` for an unambiguous radio indicator. - - **No radio LED (20)** — including `wio_tracker_l1`, `t1000_e`, `meshtracker_x1`, Heltec V3/V4/V43 and + (and vice versa). Use `set leds.hb off` for an unambiguous radio indicator. `heltec_t096` and + `heltec_wireless_tracker_v2` also alias the same LED to `heartbeat-pwm-led`/`lora-tx-pwm-led`, so it is + dimmable with `leds.brightness`. + - **One shared PWM-only pin (1)**: `heltec_wifi_lora32_v43` has no plain `lora-tx-led`, only + `lora-tx-pwm-led` aliased to the same PWM LED as the heartbeat: a radio LED that exists only through + the PWM path, dimmable with `leds.brightness` like the boards above. On ESP32, a board in either PWM + category must not configure that pin as plain GPIO in `main()`, or the GPIO matrix takes the pin back + from the LEDC peripheral and the PWM output goes dark; `src/server_main_common.cpp` and + `app/main_observer.cpp` skip it automatically (`CONFIG_ZEPHCORE_LED_PWM`) whenever a PWM LED alias + is present. + - **No radio LED (19)**: including `wio_tracker_l1`, `t1000_e`, `meshtracker_x1`, Heltec V3/V4 and both ProMicros. `set leds.radio` is accepted and stored (so the setting survives onto a board that does have the LED) but does nothing; the reply says `(no radio LED on this board)`. - **No heartbeat LED (1)** — `lilygo_t3s3` has `lora-tx-led` but neither `led0` nor `led1`, so `set leds.hb` diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f7b601705..650a17cd7 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -302,6 +302,10 @@ it polls external state. Repeater and room server share one loop (`server_main_c - Low-battery policy for every companion (`PowerPolicy`): three low readings 30 s apart, never on external power, never on a reading under 2000 mV (no battery). - GPS: duty-cycled; holds the SoC light-sleep lock only while acquiring. +- ESP32 companions on boards that declare it can light-sleep after `powersaving on` (off by default): never + while a USB host is attached, the display is on or a BLE link is not yet encrypted. +- WiFi uplink and observer: the station uses WiFi modem sleep. On ESP32 light-sleep repeaters it holds the SoC + light-sleep lock only until the MQTT session is up, and again whenever the link or the session is lost. ### 8.4 Security - On-air: Ed25519 identities, X25519 ECDH, AES-128 + truncated HMAC (upstream scheme). diff --git a/docs/adr/0009-wifi-companion-boards.md b/docs/adr/0009-wifi-companion-boards.md index 648b337c5..e739ae10d 100644 --- a/docs/adr/0009-wifi-companion-boards.md +++ b/docs/adr/0009-wifi-companion-boards.md @@ -7,7 +7,7 @@ Status: accepted (recorded 2026-09-25) **WiFi companion** (`CONFIG_ZEPHCORE_COMPANION_WIFI`, `boards/common/wifi_companion.conf`): auto-added to companion builds of boards whose `zephcore.yml` declares `capabilities: wifi: true`. On the PSRAM ESP32-S3 boards the WiFi heap, network buffers and the `CompanionMesh` object go to PSRAM. Four no-PSRAM boards also -declare it (Heltec V3 and Wireless Tracker at 140/130 contacts, both C6 boards at 280; the offline queue stays +declare it (Heltec V3 and Wireless Tracker at 140/130 contacts, 120/110 since 2026-10-09 when their companions gained light sleep, both C6 boards at 280; the offline queue stays 256): each sets `ZEPHCORE_MAX_CONTACTS` in `board.conf` to the largest count that links, and an S3 without PSRAM also gets bounded WiFi buffers and a measured 72 KB heap (Kconfig, `HEAP_MEM_POOL_IGNORE_MIN`). Tracker V2, C3 and the classic ESP32 boards do not fit WiFi + BLE (DRAM). Upstream's diff --git a/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md b/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md index 0b24b30d2..c7631138d 100644 --- a/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md +++ b/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md @@ -4,10 +4,14 @@ Faster GPS fixes on three trackers, a clock that survives a reboot, fixes for US could stop responding right after boot or when the port closed during a contact sync, a contacts import that no longer stalls every few seconds, more reliable detection of clock chips, repeaters that power down an external flash chip they do not use, a formatter that erases that chip on every -board that has one, and a few fixes ported from upstream MeshCore. +board that has one, ESP32 repeaters with the WiFi uplink and observers that run much cooler and keep +their broker connection, light sleep for XIAO ESP32-S3 and Station G2 repeaters, optional light +sleep for ESP32-S3 companions, and a few fixes ported from upstream MeshCore. > [!NOTE] -> A normal upgrade keeps your identity, settings, contacts and phone pairing. +> A normal upgrade keeps your identity, settings, contacts and phone pairing. One exception: +> companions on the Heltec V3 and the Heltec Wireless Tracker now hold 120 and 110 contacts +> instead of 140 and 130; see "Companions can light-sleep" below. --- @@ -66,6 +70,20 @@ valid. Thanks to **ptr727** for finding and fixing this ([PR #99](https://github.com/liquidraver/ZephCore/pull/99)). +Four more clock-chip fixes, also from **ptr727**: + +- **RAK4631 with a RAK12002 clock module**: the module is now used, and its switchover to the backup + supply is stored in the chip, so the clock keeps running when the board loses power + ([PR #98](https://github.com/liquidraver/ZephCore/pull/98)). +- **RX8130CE**: the weekday was written in the wrong encoding. Time and date were not affected + ([PR #101](https://github.com/liquidraver/ZephCore/pull/101)). +- **A time outside 2000-2099 is no longer written to a clock chip.** The chip stores only two digits + of the year, so a time before 2000 came back after a reboot as a date late in this century + ([PR #105](https://github.com/liquidraver/ZephCore/pull/105)). +- **RV-3028 left in 12-hour mode by other firmware**: it is switched back to 24-hour mode at boot, so + the hours are read correctly + ([PR #106](https://github.com/liquidraver/ZephCore/pull/106)). + ## USB companion: a hang right after boot A companion on USB could stop responding when the host sent it a command straight after opening the @@ -128,6 +146,107 @@ This was found while looking into a report of a XIAO nRF52840 repeater drawing a 1.17.6 than on 1.17.4 (thanks to **Kimotu**). Whether it accounts for that difference is not confirmed yet. +## ESP32 repeaters with the WiFi uplink, and observers, run cooler + +**svenlange2** reported in [#107](https://github.com/liquidraver/ZephCore/issues/107) that a Heltec +V4.3 repeater ran about 20 C cooler than stock firmware, and was back at 45 C as soon as the WiFi + +MQTT uplink was built in and connected. Thank you for the report and for the measurements: this +whole section came out of them. + +The report was right. A repeater with the uplink kept its WiFi radio fully on all the time, and on +the boards that light-sleep as repeaters it never slept again once the uplink was in the build. +Observers kept the WiFi radio on in the same way. Neither was necessary. This release changes both, +which resolves #107: + +- **WiFi power save is on** for the uplink and for observers: the WiFi radio sleeps between the + access point's beacons. The connection to the access point and to the broker is kept. +- **Light sleep works with the uplink connected**, on the boards that light-sleep as repeaters + (Heltec V3, V4, V4.3, Wireless Tracker, Wireless Tracker V2, and from this release the XIAO + ESP32-S3 and the Station G2; see the next section). The node sleeps between beacons once + it is connected to the broker. It stays awake while it is connecting, and for as long as the WiFi + network or the broker cannot be reached. +- **Brokers with a current Let's Encrypt certificate can be reached.** An uplink repeater or an + observer joined the WiFi network but never connected to a broker whose certificate chain uses + P-384 keys or SHA-384 signatures, which Let's Encrypt's ECDSA certificates do. The firmware does + not check the certificate, but it has to read it, and it could not read those. It can now. +- **An idle connection to the broker is no longer dropped every two minutes.** Some brokers, and TLS + front ends placed before one, close a connection that has been silent for exactly the keepalive + time (60 seconds). The keepalive message was sent only when that time was already up, so on a + quiet mesh the node lost the connection every two minutes and reconnected. It is now sent 15 + seconds earlier. +- **Observers run the CPU at 80 MHz**, like every other role (240 MHz before, 160 MHz on C3 and C6 + boards). Connecting to the broker takes a few tenths of a second longer. + +On a XIAO ESP32-S3 the chip temperature went from 47 C to about 35 C with WiFi power save alone, and +to about 26 C with light sleep as well, with the uplink connected the whole time. The Heltec V4.3 +from the report is one of the boards that now sleeps with the uplink connected. A received LoRa +packet wakes the node as before, and `powersaving off` still keeps it awake. + +The MCU temperature that made the comparison possible is also from #107: see "Also in this +release". + +`get pm` now counts the wakes for WiFi beacons on their own: its reply has a new `w` field between +`g` and `o`. + +A light-sleep repeater with a display now stays awake while the display is on. The display turns +itself off 10 seconds after boot or after the last button press, as before. + +## Repeaters on the XIAO ESP32-S3 and the Station G2 light-sleep + +Repeaters on the Seeed XIAO ESP32-S3 and the UnitEng Station G2 now put the processor into light +sleep between events, like the Heltec boards already do. The radio keeps listening, and a received +packet wakes the node. It is on by default; `powersaving off` keeps the node awake and +`powersaving on` allows sleep again, and the setting is kept across reboots. `get pm` shows how +much of the time the node is asleep. + +This is new on both boards, and the Station G2 most of all: reports of how it behaves are welcome. + +> [!NOTE] +> **USB console on a sleeping repeater:** the node stays awake for 10 minutes after boot and after +> each press of the user button. Outside that window a computer it is plugged into sees the USB +> port disconnect and reconnect, and what is typed is lost. Press the user button to open the +> window again, or send `powersaving off` over LoRa to keep the console. + +## Companions can light-sleep on seven ESP32-S3 boards (off by default) + +Companions on these boards can now put the processor into light sleep between events: + +- Heltec V3, V4, V4.3, Wireless Tracker and Wireless Tracker V2, +- Seeed XIAO ESP32-S3, +- UnitEng Station G2. + +It is **off by default**. Turn it on with `powersaving on` from the app's command line, and off +again with `powersaving off`; the setting is kept across reboots. + +This is new, and it is offered for testing: reports of how it behaves on your board are welcome, +the Station G2 most of all. + +With it on, the node keeps advertising, stays connected to the app over Bluetooth or WiFi and +receives LoRa packets as before. It does not sleep: + +- while a computer is attached over USB, +- while the display is on, +- while a phone is pairing, +- for 10 minutes after boot and after each press of the user button. + +> [!NOTE] +> **USB on a sleeping companion:** a companion that is asleep is not seen by a computer it is +> plugged into. Press the user button after plugging in, or send `powersaving off` from the app +> first. + +WiFi companions on these boards now use WiFi power save, with powersaving on or off: the WiFi +radio sleeps between the access point's beacons and the connection is kept. + +> [!NOTE] +> **Heltec V3 and Wireless Tracker: fewer contacts.** These two run WiFi and Bluetooth without +> extra memory, and light sleep needs some of it. The contact limit goes from 140 to 120 on the +> Heltec V3 and from 130 to 110 on the Wireless Tracker, whether or not you turn powersaving on. +> A node holding more keeps the first 120 (or 110) after the update; export your contacts from +> the app before updating if you are above that. The offline message queue stays at 256. + +On the Heltec V3 a computer talking to the companion over USB also keeps it awake, from its +first message until the next reboot. + ## Formatter: the external flash chip is erased on every board that has one The nRF52 formatters (`SoftDevice_v6_formatter` and `SoftDevice_v7_formatter`, as `.uf2` and as the @@ -161,6 +280,17 @@ matches the SoftDevice version of your bootloader. puts it back to 47 hours. - **Malformed encrypted packets are rejected earlier**, before any cryptography runs. - **Large contact lists**: up to 24 contacts can share the same one-byte hash, up from 8. +- **ESP32-S3, ESP32-C3 and ESP32-C6 boards report the MCU temperature** in telemetry, as nRF52 boards + already did ([#107](https://github.com/liquidraver/ZephCore/issues/107), thanks to **svenlange2**). + The older ESP32 in the TTGO LoRa32 and T-Beam has no usable sensor and still reports none. On the + ThinkNode M9 and Meshnology W12 the radio now also recalibrates itself when the temperature has + moved by 5 C or more, as it already does on nRF boards with an LR1110 or LR2021. +- **Observers have `reboot` and `erase`** on their serial console, with the same replies as on a + repeater. `erase` is the factory reset: it wipes the identity, the name and the radio, WiFi and + MQTT settings, and restarts the node on defaults. Until now an observer could only be wiped by + erasing the flash from a computer. +- **`sensor list`** with a negative start index is rejected instead of being used + ([PR #100](https://github.com/liquidraver/ZephCore/pull/100), thanks to **ptr727**). - **T1000-E**: a pin that was wrongly driven as a sensor enable is left alone. Sensor readings are unchanged. - **LR2021 with `rxduty` on** (MeshTracker X1, both LR2021 EVK kits): after every noise-floor @@ -173,15 +303,15 @@ matches the SoftDevice version of your bootloader. too, like the LED and advert shortcuts. - **New board**: Seeed LR2021 LoRa Plus EVK with a XIAO nRF54LM20A. Build it from source; there is no published firmware for it yet. -- **Zephyr** updated to `74b7173e9c9`. One change in it would have been visible and is handled: +- **Zephyr** updated to `11808e9ea8a`. One change in it would have been visible and is handled: e-paper displays would have drawn white text on a black page. The RAK4631's LEDs are now declared by ZephCore, since Zephyr's board file no longer describes them; nothing changes on the board. It also brings a fix for boards on WiFi: the starting sequence number of a TCP connection (the companion port and the update page) was predictable, and is now derived from a secret as intended. - **Heltec V3 and Wireless Tracker companions** (WiFi and Bluetooth together, no PSRAM) had under - 1 KB of memory left; they now have about 2.5 KB. The Bluetooth controller's stack on ESP32-S3 and - ESP32-C3 companions was 4 KB and is now 2 KB; it uses about 1.1 KB with WiFi connected. Nothing - changes in use. + 1 KB of memory left; they now have about 2.5 KB, with light sleep included. The Bluetooth + controller's stack on ESP32-S3 and ESP32-C3 companions was 4 KB and is now 2 KB; it uses about + 1.1 KB with WiFi connected. - **Documentation**: the README was reorganised, build and flashing instructions moved to `docs/BUILDING.md`, and the example board and porting guide were rewritten for the current board layout. diff --git a/tests/unit/prefs_json_tests.cpp b/tests/unit/prefs_json_tests.cpp index 6cd807e65..faa8a75de 100644 --- a/tests/unit/prefs_json_tests.cpp +++ b/tests/unit/prefs_json_tests.cpp @@ -33,7 +33,7 @@ NodePrefs sample() { p.backoff_multiplier = 0.6f; p.allow_read_only = 1; p.multi_acks = 1; p.flood_max = 20; p.flood_max_unscoped = 10; p.flood_max_advert = 4; p.interference_threshold = 7; p.leds_disabled = 1; p.leds_radio_mode = LEDS_RADIO_RX; - p.leds_hb_mode = LEDS_HB_UNREAD; p.powersaving_enabled = 1; p.gps_enabled = 1; + p.leds_hb_mode = LEDS_HB_UNREAD; p.led_brightness = 42; p.powersaving_enabled = 1; p.gps_enabled = 1; p.gps_interval = 600; p.advert_loc_policy = ADVERT_LOC_SHARE; p.discovery_mod_timestamp = 123456; p.adc_multiplier = 1.25f; strcpy(p.owner_info, "owner info"); p.rx_boost = 0; p.fem_rxgain = 0; p.rx_duty_cycle = 1; @@ -73,6 +73,8 @@ TEST(pjson_companion_roundtrip, "UNIT-PJSON-001", "Companion prefs survive prefs CHECK(companionPrefsFromJson(q, s)); // Every field of the binary layout, as a legacy file migrated to JSON. CHECK(companionBytes(q) == companionBytes(p)); + // prefs.json only: not in the binary layout. + CHECK(q.led_brightness == 42); } TEST(pjson_server_roundtrip, "UNIT-PJSON-002", "Server prefs survive prefs.json field for field") { @@ -82,6 +84,7 @@ TEST(pjson_server_roundtrip, "UNIT-PJSON-002", "Server prefs survive prefs.json NodePrefs q = defaults(); CHECK(serverPrefsFromJson(q, s)); CHECK(serverBytes(q) == serverBytes(p)); + CHECK(q.led_brightness == 42); } TEST(pjson_upstream_file, "UNIT-PJSON-003", "A companion prefs.json as upstream v1.17 writes it loads") { diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index 013bdbfc2..a8d4c0215 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -172,27 +172,37 @@ if(ZEPHCORE_PLATFORM STREQUAL "esp32") # first-order term in a battery node's draw — unlike nRF52, which already # idles at microamps. Every ESP32 build runs at 80 MHz, matching Arduino # MeshCore; hal_espressif derives ESP_DEFAULT_CPU_FREQ_MHZ from cpu@0, so no - # module patch is needed. Observers keep the SoC maximum (240 MHz S3/classic, - # 160 MHz C3/C6): full WiFi + TLS + MQTT on mains or big-panel solar, so - # throughput matters more than current. "Don't apply the overlay" rather than - # an override, so the per-SoC maximum is inherited, not hardcoded. - if(NOT ZEPHCORE_BUILD_ROLE STREQUAL "observer") - zephcore_add_overlay(${_zc}/esp32_cpu_80mhz.overlay) - message(STATUS " ESP32 CPU: 80 MHz (observers keep the SoC maximum)") + # module patch is needed. Every role, observers included: 80 MHz is the + # lowest clock WiFi runs at and carries WiFi + TLS + MQTT + # (devdocs/lld/13-power-management.md, section 12). + zephcore_add_overlay(${_zc}/esp32_cpu_80mhz.overlay) + message(STATUS " ESP32 CPU: 80 MHz") + + # SoC temperature sensor (MCU temperature in telemetry, image-calibration + # drift on LR11xx/LR2021): every role, every SoC that has the node. The + # classic ESP32 (".../esp32/...") has neither the node nor a driver. + if(NOT BOARD MATCHES "/esp32/") + zephcore_add_overlay(${_zc}/esp32_coretemp.overlay) endif() - # Light sleep: REPEATERS on boards that declare it. 80 MHz cuts the idle draw; - # light sleep is what removes it (mechanism and requirements: - # boards/common/pm_esp32.conf). Repeaters only: the Espressif HCI driver takes - # no pm_policy locks, so a companion would light-sleep mid-advertising or - # mid-connection; repeater.conf sets CONFIG_BT=n. Which boards qualify is a - # hardware fact (DIO1 on an RTC-wake-capable GPIO, and validated on hardware), - # so the board declares it: `capabilities: light_sleep: true` in its - # zephcore.yml. pm_esp32.conf lists which boards fail the wiring test and why; - # a board that passes it but was never validated (v4_r8) stays undeclared. + # Light sleep: on boards that declare it. 80 MHz cuts the idle draw; light + # sleep is what removes it (mechanism and requirements: + # boards/common/pm_esp32.conf). Which boards qualify is a hardware fact + # (the radio IRQ and button wake the SoC, pins are held, validated on + # hardware), so the board declares it in its zephcore.yml: + # `light_sleep: true` for the repeater, `light_sleep_companion: true` for the + # companion. Two lines because they differ: a repeater sleeps by default, so + # its line waits for validation on hardware (v4_r8, which only passes the + # wiring test, stays undeclared); a companion sleeps only after + # `powersaving on`, and its build costs about 4 KB of DRAM, which a WiFi + + # BLE companion without PSRAM gives up in contacts + # (devdocs/lld/13-power-management.md, section 13). if(ZEPHCORE_BUILD_ROLE STREQUAL "repeater" AND ZEPHCORE_BOARD_LIGHT_SLEEP) zephcore_add_conf(${_zc}/pm_esp32.conf) - message(STATUS " ESP32 light sleep: auto-enabled (DIO1 wake-capable repeater board)") + message(STATUS " ESP32 light sleep: auto-enabled (repeater, board declares it)") + elseif(ZEPHCORE_BUILD_ROLE STREQUAL "companion" AND ZEPHCORE_BOARD_LIGHT_SLEEP_COMPANION) + zephcore_add_conf(${_zc}/pm_esp32.conf) + message(STATUS " ESP32 light sleep: auto-enabled (companion, board declares it; opt-in at run time)") endif() # WiFi OTA for S3/C-series repeaters: WiFi AP + HTTP server + MCUboot image @@ -536,6 +546,7 @@ if(CONFIG_ZEPHCORE_ROLE_REPEATER OR CONFIG_ZEPHCORE_ROLE_ROOM_SERVER) helpers/compat/SHA256.cpp helpers/compat/ZephyrFS.cpp helpers/CommonCLI.cpp + helpers/HardwareReport.cpp # the `hw` command; only CommonCLI reaches it ) # Headless build (e.g. native-Linux SBC): no display/buttons/buzzer, so the # real ui_* implementation (helpers/ui-button or helpers/ui-joystick) isn't @@ -612,6 +623,7 @@ else() # must be compiled unconditionally. Builds without CONFIG_LOG/COMPANION_USB/ # COMPANION_SERIAL (e.g. plain ESP32/S3 companions) failed to link otherwise. helpers/CommonCLI.cpp + helpers/HardwareReport.cpp # the `hw` command; only CommonCLI reaches it ) # Companion transports: main_companion.cpp registers every one the build # has in upstream's MultiSerialInterface — BLE NUS when there is a diff --git a/zephcore/Kconfig b/zephcore/Kconfig index dd5d4d282..ace48d751 100644 --- a/zephcore/Kconfig +++ b/zephcore/Kconfig @@ -726,6 +726,14 @@ config ZEPHCORE_VBAT_MV_MULTIPLIER Formula: mv = (raw * multiplier) / 4096 +config ZEPHCORE_LED_PWM + def_bool $(dt_alias_enabled,heartbeat-pwm-led) || $(dt_alias_enabled,lora-tx-pwm-led) + select PWM + help + Set when the devicetree has a heartbeat-pwm-led or lora-tx-pwm-led + alias: that LED is driven through PWM and dimmed by leds.brightness. + Pulls in CONFIG_PWM automatically. + endmenu menu "Debug & Logging" diff --git a/zephcore/adapters/ble/ZephyrBLE.cpp b/zephcore/adapters/ble/ZephyrBLE.cpp index 49fc0c9cd..2052b3d8c 100644 --- a/zephcore/adapters/ble/ZephyrBLE.cpp +++ b/zephcore/adapters/ble/ZephyrBLE.cpp @@ -34,6 +34,7 @@ LOG_MODULE_REGISTER(zephcore_ble, CONFIG_ZEPHCORE_BLE_LOG_LEVEL); #include "ZephyrBLE.h" #include "ble_internal.h" #include "frame_txq.h" +#include /* ========== Constants ========== */ @@ -293,6 +294,22 @@ static void build_device_name_and_adv(const char *name_from_prefs) /* ========== Connection callbacks ========== */ +/* No SoC light sleep from connect until the link is encrypted (or gone): + * pairing does not survive it (devdocs/lld/13-power-management.md, section + * 13). An encrypted link may sleep between connection events. */ +static atomic_t unencrypted_hold; + +static void unencrypted_hold_set(bool hold) +{ + if (hold) { + if (atomic_cas(&unencrypted_hold, 0, 1)) { + zc_pm_block_sleep(); + } + } else if (atomic_cas(&unencrypted_hold, 1, 0)) { + zc_pm_unblock_sleep(); + } +} + static void connected(struct bt_conn *conn, uint8_t err) { char addr[BT_ADDR_LE_STR_LEN]; @@ -304,6 +321,7 @@ static void connected(struct bt_conn *conn, uint8_t err) LOG_INF("connected: %s", addr); current_conn = bt_conn_ref(conn); + unencrypted_hold_set(true); /* Zephyr stops advertising internally when the conn slot is consumed * (BT_MAX_CONN=1 — there's no slot left to advertise from). Sync our @@ -340,6 +358,7 @@ static void disconnected(struct bt_conn *conn, uint8_t reason) LOG_INF("disconnected: %s reason 0x%02x", addr, reason); k_work_cancel_delayable(&sec_timeout_work); + unencrypted_hold_set(false); if (conn == current_conn) { bt_conn_unref(current_conn); @@ -393,6 +412,7 @@ static void security_changed(struct bt_conn *conn, bt_security_t level, enum bt_ * so stand the unpaired-connection timeout down. */ if (level >= BT_SECURITY_L2) { k_work_cancel_delayable(&sec_timeout_work); + unencrypted_hold_set(false); } /* Enable TX when we have sufficient security (level 2+ = encrypted). diff --git a/zephcore/adapters/board/ZephyrBoard.cpp b/zephcore/adapters/board/ZephyrBoard.cpp index 7310826fe..e706a4e5a 100644 --- a/zephcore/adapters/board/ZephyrBoard.cpp +++ b/zephcore/adapters/board/ZephyrBoard.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -57,22 +58,24 @@ #endif #endif -/* LoRa radio activity LED (optional — defined per-board via DT alias). Still - * called tx_led after "set leds.radio" gave it an RX mode, because the DT alias - * it comes from is named lora-tx-led on every board that has one. */ -#if DT_NODE_EXISTS(DT_ALIAS(lora_tx_led)) +/* LoRa activity LED, optional per board. The PWM alias wins over the GPIO one. */ +#if DT_NODE_EXISTS(DT_ALIAS(lora_tx_pwm_led)) +static const struct pwm_dt_spec tx_led_pwm = PWM_DT_SPEC_GET(DT_ALIAS(lora_tx_pwm_led)); +#define HAS_TX_LED_PWM 1 +#define HAS_TX_LED 0 +/* Initialised in tx_led_init() below, with the GPIO path. */ +#elif DT_NODE_EXISTS(DT_ALIAS(lora_tx_led)) static const struct gpio_dt_spec tx_led = GPIO_DT_SPEC_GET(DT_ALIAS(lora_tx_led), gpios); +#define HAS_TX_LED_PWM 0 #define HAS_TX_LED 1 #else +#define HAS_TX_LED_PWM 0 #define HAS_TX_LED 0 #endif -/* True when this board wires the activity LED to the same pin as the heartbeat - * (8 of the supported boards do). Only those pay for the arbitration hold in - * led_gate.c — everywhere else the two LEDs are independent and the calls - * compile out. led1 is checked as well because ui_common.c falls back to it - * when a board has no led0. */ +/* Heartbeat and activity LED on one pin: only then is the arbitration hold in + * led_gate.c needed. led1 counts too, since ui_common.c falls back to it. */ #if HAS_TX_LED && DT_NODE_EXISTS(DT_ALIAS(led0)) && \ DT_SAME_NODE(DT_ALIAS(led0), DT_ALIAS(lora_tx_led)) #define ZEPHCORE_LED_PIN_SHARED 1 @@ -80,10 +83,26 @@ static const struct gpio_dt_spec tx_led = DT_NODE_EXISTS(DT_ALIAS(led1)) && \ DT_SAME_NODE(DT_ALIAS(led1), DT_ALIAS(lora_tx_led)) #define ZEPHCORE_LED_PIN_SHARED 1 +/* Same for one PWM LED node shared by both (T096, Wireless Tracker V2). */ +#elif HAS_TX_LED_PWM && DT_NODE_EXISTS(DT_ALIAS(heartbeat_pwm_led)) && \ + DT_SAME_NODE(DT_ALIAS(heartbeat_pwm_led), DT_ALIAS(lora_tx_pwm_led)) +#define ZEPHCORE_LED_PIN_SHARED 1 #else #define ZEPHCORE_LED_PIN_SHARED 0 #endif +#if HAS_TX_LED_PWM +static inline void tx_led_write(bool on) +{ + zephcore_led_pwm_write(&tx_led_pwm, on); +} +#elif HAS_TX_LED +static inline void tx_led_write(bool on) +{ + gpio_pin_set_dt(&tx_led, on ? 1 : 0); +} +#endif + #include LOG_MODULE_REGISTER(zephcore_board, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL); @@ -139,8 +158,8 @@ static const struct device *const pmic_charger_dev = #define HAS_PMIC_CHARGER 0 #endif -/* Initialize activity LED GPIO at boot */ -#if HAS_TX_LED +/* Initialize activity LED (PWM or GPIO) at boot */ +#if HAS_TX_LED_PWM || HAS_TX_LED /* Width of the receive blink. A transmit holds the LED for its whole airtime, * but a receive is a single edge — the packet is over by the time the driver * hands it up — so RX has to be a fixed one-shot. 30 ms is deliberately longer @@ -165,7 +184,7 @@ static void rx_pulse_off_handler(struct k_work *work) if (atomic_get(&s_tx_lit)) { return; } - gpio_pin_set_dt(&tx_led, 0); + tx_led_write(false); #if ZEPHCORE_LED_PIN_SHARED zephcore_led_radio_hold_pin(false); #endif @@ -173,9 +192,15 @@ static void rx_pulse_off_handler(struct k_work *work) static int tx_led_init(void) { +#if HAS_TX_LED_PWM + if (device_is_ready(tx_led_pwm.dev)) { + pwm_set_pulse_dt(&tx_led_pwm, 0); /* start dark, same as GPIO_OUTPUT_INACTIVE */ + } +#else if (gpio_is_ready_dt(&tx_led)) { gpio_pin_configure_dt(&tx_led, GPIO_OUTPUT_INACTIVE); } +#endif k_work_init_delayable(&s_rx_pulse_off, rx_pulse_off_handler); return 0; } @@ -343,9 +368,14 @@ float ZephyrBoard::getAdcMultiplier() const float ZephyrBoard::getMCUTemperature() { - /* nRF52840 die temperature sensor - "nordic,nrf-temp" at 0x4000c000 - * Nodelabel "temp" is defined in nrf52840.dtsi, status="okay" by default */ + /* SoC die temperature sensor: the `die-temp0` alias where the SoC dtsi + * declares one (Espressif `coretemp`), else the nodelabel `temp` (Nordic). + * NULL when the node is absent or disabled. */ +#if DT_NODE_EXISTS(DT_ALIAS(die_temp0)) + const struct device *dev = DEVICE_DT_GET_OR_NULL(DT_ALIAS(die_temp0)); +#else const struct device *dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(temp)); +#endif if (!dev || !device_is_ready(dev)) { return NAN; } @@ -364,12 +394,12 @@ const char *ZephyrBoard::getManufacturerName() const void ZephyrBoard::onBeforeTransmit() { -#if HAS_TX_LED /* Honour the LED master gate ("set leds off") and then the activity mode * ("set leds.radio"). On a headless repeater this is the only LED that ever * lights, so both have to be checked here and not just in the UI layer. * onAfterTransmit() still clears the pin unconditionally, so a gate or mode * flipped mid-transmit can't strand it lit. */ +#if HAS_TX_LED_PWM || HAS_TX_LED uint8_t mode = zephcore_leds_radio_mode(); if (!zephcore_leds_disabled() && (mode == LEDS_RADIO_TX || mode == LEDS_RADIO_ALL)) { @@ -380,16 +410,16 @@ void ZephyrBoard::onBeforeTransmit() #if ZEPHCORE_LED_PIN_SHARED zephcore_led_radio_hold_pin(true); #endif - gpio_pin_set_dt(&tx_led, 1); + tx_led_write(true); } #endif } void ZephyrBoard::onAfterTransmit() { -#if HAS_TX_LED +#if HAS_TX_LED_PWM || HAS_TX_LED atomic_set(&s_tx_lit, 0); - gpio_pin_set_dt(&tx_led, 0); + tx_led_write(false); #if ZEPHCORE_LED_PIN_SHARED zephcore_led_radio_hold_pin(false); #endif @@ -398,7 +428,7 @@ void ZephyrBoard::onAfterTransmit() void ZephyrBoard::onPacketReceived() { -#if HAS_TX_LED +#if HAS_TX_LED_PWM || HAS_TX_LED uint8_t mode = zephcore_leds_radio_mode(); if (zephcore_leds_disabled() || (mode != LEDS_RADIO_RX && mode != LEDS_RADIO_ALL)) { @@ -414,7 +444,7 @@ void ZephyrBoard::onPacketReceived() #if ZEPHCORE_LED_PIN_SHARED zephcore_led_radio_hold_pin(true); #endif - gpio_pin_set_dt(&tx_led, 1); + tx_led_write(true); /* Reschedule rather than schedule: back-to-back packets should extend the * blink, not have the first one's handler cut the second one short. */ k_work_reschedule(&s_rx_pulse_off, K_MSEC(RX_PULSE_MS)); diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.c b/zephcore/adapters/clock/ZephyrRTCDiscover.c index d439811df..1d19dbc4c 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.c +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.c @@ -40,6 +40,8 @@ struct rtc_desc { uint8_t status_mask; /* "time unreliable" bit within status_reg */ const uint8_t *zero; /* 7 bytes of bits the data sheet shows as 0, or NULL */ bool week_one_hot; /* weekday as one bit per day, not 0-6 */ + uint8_t h12_reg; /* register of the 12-hour bit */ + uint8_t h12_mask; /* 12-hour bit, or 0 if none */ #if RTC_RV3028_CFG const uint8_t *cfg; /* rv3028-eeprom-config triplets, or NULL */ uint8_t cfg_len; @@ -79,6 +81,10 @@ DT_FOREACH_STATUS_OKAY(RTC_COMPAT, RTC_CFG_ARRAY) #define RTC_CFG_FIELDS(node) #endif +#define RTC_H12(node, i) \ + COND_CODE_1(DT_NODE_HAS_PROP(node, twelve_hour_bit), \ + (DT_PROP_BY_IDX(node, twelve_hour_bit, i)), (0)) + #define RTC_DESC_ENTRY(node) \ { \ .bus = DEVICE_DT_GET(DT_BUS(node)), \ @@ -91,6 +97,8 @@ DT_FOREACH_STATUS_OKAY(RTC_COMPAT, RTC_CFG_ARRAY) DT_NODE_HAS_PROP(node, zero_mask), \ (RTC_ZERO_NAME(node)), (NULL)), \ .week_one_hot = DT_PROP(node, weekday_one_hot), \ + .h12_reg = (uint8_t)RTC_H12(node, 0), \ + .h12_mask = (uint8_t)RTC_H12(node, 1), \ RTC_CFG_FIELDS(node) \ .name = DT_NODE_FULL_NAME(node), \ }, @@ -107,6 +115,11 @@ static bool s_probed; static bool s_skipped_ff; static bool s_reprobed; +/* Per-candidate outcome of the last probe run; UNPROBED if not reached. */ +static uint8_t s_state[ARRAY_SIZE(rtc_descs)]; +/* Guards s_state and s_active, published together by rtc_probe(). */ +static struct k_spinlock s_report_lock; + #define BCD2BIN(x) ((((x) >> 4) & 0x0F) * 10 + ((x) & 0x0F)) #define BIN2BCD(x) ((((x) / 10) << 4) | ((x) % 10)) @@ -185,7 +198,9 @@ static bool rtc_ruled_out(const struct rtc_desc *d, const uint8_t blk[7]) return !rtc_fields_ok(d, blk) && rtc_power_flag(d, blk) != 1; } -enum rtc_verdict { RTC_ABSENT, RTC_ERASED, RTC_NOT_THIS, RTC_FOUND, RTC_FOUND_GARBLED }; +enum rtc_verdict { + RTC_UNREAD, RTC_ABSENT, RTC_ERASED, RTC_NOT_THIS, RTC_FOUND, RTC_FOUND_GARBLED +}; static bool rtc_all_ff(const uint8_t blk[7]) { @@ -198,15 +213,21 @@ static bool rtc_all_ff(const uint8_t blk[7]) } /* Decide whether the device at d is the RTC it declares: ruled out only when - * two reads each rule it out. RTC_FOUND leaves the block to decode in blk; - * RTC_FOUND_GARBLED is this RTC with no clean read to take a time from. */ + * two reads each rule it out. A first read ending in -EIO is RTC_ABSENT (a + * NACK); a bus not ready or any other failure is RTC_UNREAD. RTC_FOUND leaves + * the block to decode in blk; RTC_FOUND_GARBLED is this RTC with no clean + * read to take a time from. */ static enum rtc_verdict rtc_identify(const struct rtc_desc *d, uint8_t blk[7]) { uint8_t again[7]; - if (!device_is_ready(d->bus) || - i2c_burst_read(d->bus, d->addr, d->time_reg, blk, 7) != 0) { - return RTC_ABSENT; + if (!device_is_ready(d->bus)) { + return RTC_UNREAD; + } + int rc = i2c_burst_read(d->bus, d->addr, d->time_reg, blk, 7); + + if (rc != 0) { + return rc == -EIO ? RTC_ABSENT : RTC_UNREAD; } if (rtc_all_ff(blk)) { return RTC_ERASED; @@ -224,6 +245,30 @@ static enum rtc_verdict rtc_identify(const struct rtc_desc *d, uint8_t blk[7]) return RTC_FOUND; } +/* Clear a set 12-hour bit and re-read blk; the chip converts its hours + * itself. False on a failed or FFh read of the bit, or a failed clear or + * re-read. */ +static bool rtc_clear_12h(const struct rtc_desc *d, uint8_t blk[7]) +{ + uint8_t r; + + if (d->h12_mask == 0) { + return true; + } + if (i2c_reg_read_byte(d->bus, d->addr, d->h12_reg, &r) != 0 || r == 0xFF) { + return false; + } + if (!(r & d->h12_mask)) { + return true; + } + if (i2c_reg_write_byte(d->bus, d->addr, d->h12_reg, r & ~d->h12_mask) != 0 || + i2c_burst_read(d->bus, d->addr, d->time_reg, blk, 7) != 0) { + return false; + } + LOG_INF("%s: 12-hour mode cleared", d->name); + return true; +} + /* The 7-byte time block for an epoch, in d's register order. False outside * 2000-2099: the year is written as two BCD digits with no century. */ static bool rtc_time_block(const struct rtc_desc *d, uint32_t epoch, uint8_t blk[7]) @@ -562,28 +607,38 @@ static void rv3028_save_retry_fn(struct k_work *work) #endif /* RTC_RV3028_CFG */ -/* Probe the chips in order; cache the first one found in s_active. Stop at - * the first that holds a sane time, returned via epoch_out. */ -static bool rtc_probe(uint32_t *epoch_out) +/* Probe the chips in order; return the first one found in *adopted and each + * outcome in state[]. Stop at the first that holds a sane time, returned via + * epoch_out. */ +static bool rtc_probe_run(uint32_t *epoch_out, uint8_t state[], + const struct rtc_desc **adopted) { - s_skipped_ff = false; - for (size_t i = 0; i < ARRAY_SIZE(rtc_descs); i++) { const struct rtc_desc *d = &rtc_descs[i]; uint8_t blk[7]; enum rtc_verdict v = rtc_identify(d, blk); + if (v == RTC_UNREAD) { + continue; + } if (v == RTC_ERASED) { + state[i] = ZEPHCORE_RTC_ALL_FF; s_skipped_ff = true; continue; } if (v == RTC_NOT_THIS) { + state[i] = ZEPHCORE_RTC_ABSENT; LOG_INF("%s: device at the address is not this RTC, skipped", d->name); continue; } if (v == RTC_ABSENT) { + state[i] = ZEPHCORE_RTC_ABSENT; continue; } + state[i] = ZEPHCORE_RTC_PRESENT; + if (v == RTC_FOUND && !rtc_clear_12h(d, blk)) { + v = RTC_FOUND_GARBLED; + } #if RTC_RV3028_CFG /* Only a clean identification is configured: the store writes to @@ -596,8 +651,8 @@ static bool rtc_probe(uint32_t *epoch_out) clean = false; } #endif - if (s_active == NULL) { - s_active = d; /* RTC => our write-back target */ + if (*adopted == NULL) { + *adopted = d; /* RTC => our write-back target */ #if RTC_RV3028_CFG if (clean) { rv3028_configure(d); @@ -658,6 +713,26 @@ static bool rtc_probe(uint32_t *epoch_out) return false; } +/* Run a probe and publish its outcomes and adopted chip together, so a + * reader sees one run, never a mix. */ +static bool rtc_probe(uint32_t *epoch_out) +{ + uint8_t state[ARRAY_SIZE(rtc_descs)]; + const struct rtc_desc *adopted = NULL; + bool found; + + memset(state, ZEPHCORE_RTC_UNPROBED, sizeof(state)); + s_skipped_ff = false; + found = rtc_probe_run(epoch_out, state, &adopted); + + k_spinlock_key_t key = k_spin_lock(&s_report_lock); + + memcpy(s_state, state, sizeof(state)); + s_active = adopted; + k_spin_unlock(&s_report_lock, key); + return found; +} + bool zephcore_rtc_restore(uint32_t *epoch_out) { s_probed = true; @@ -709,6 +784,35 @@ void zephcore_rtc_save(uint32_t epoch) LOG_DBG("RTC %s: persisted time", d->name); } +size_t zephcore_rtc_declared(void) +{ + return ARRAY_SIZE(rtc_descs); +} + +size_t zephcore_rtc_snapshot(struct zephcore_rtc_entry *out, size_t max) +{ + size_t n = MIN(max, ARRAY_SIZE(rtc_descs)); + + k_spinlock_key_t key = k_spin_lock(&s_report_lock); + + for (size_t i = 0; i < n; i++) { + const struct rtc_desc *d = &rtc_descs[i]; + + out[i].name = d->name; + out[i].bus = d->bus->name; + out[i].addr = d->addr; + out[i].state = (enum zephcore_rtc_state)s_state[i]; + out[i].active = (s_active == d); + } + k_spin_unlock(&s_report_lock, key); + return n; +} + +bool zephcore_rtc_probed(void) +{ + return s_probed; +} + #else /* no zephcore,rtc-i2c node in DT — link-compatible stubs */ bool zephcore_rtc_restore(uint32_t *epoch_out) @@ -722,4 +826,21 @@ void zephcore_rtc_save(uint32_t epoch) ARG_UNUSED(epoch); } +size_t zephcore_rtc_declared(void) +{ + return 0; +} + +size_t zephcore_rtc_snapshot(struct zephcore_rtc_entry *out, size_t max) +{ + ARG_UNUSED(out); + ARG_UNUSED(max); + return 0; +} + +bool zephcore_rtc_probed(void) +{ + return false; +} + #endif /* DT_HAS_COMPAT_STATUS_OKAY(zephcore_rtc_i2c) */ diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.h b/zephcore/adapters/clock/ZephyrRTCDiscover.h index 7b745ec07..3ef8f1af0 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.h +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.h @@ -17,6 +17,7 @@ #pragma once #include +#include #include #ifdef __cplusplus @@ -28,7 +29,9 @@ extern "C" { * sane time (year >= 2025 and its power-loss flag clear): store its Unix * epoch in *epoch_out and return true. The first chip found (valid time or * not) is remembered as the write-back target. Returns false if none is - * present or none holds a trustworthy time. + * present or none holds a trustworthy time. On a clean identification, a set + * twelve-hour-bit is cleared before the time is taken. No time is taken if its + * register cannot be read or reads FFh, or if the clear or the re-read fails. * * A descriptor with rv3028-eeprom-config also has that config stored in the * chip's EEPROM, retried on the system work queue if it fails. @@ -44,6 +47,44 @@ bool zephcore_rtc_restore(uint32_t *epoch_out); */ void zephcore_rtc_save(uint32_t epoch); +/* Reporting accessors, for `hw`. Each probe run starts from no outcome and + * publishes its states and adopted chip together when it ends. */ + +enum zephcore_rtc_state { + ZEPHCORE_RTC_UNPROBED = 0, /* not probed: discovery stopped before + * reaching it, its bus was not ready, or + * the read failed other than with -EIO */ + ZEPHCORE_RTC_ABSENT, /* the read ended in -EIO (no ACK; on nRF + * also any bus error), or two reads each + * showed a device that is not this RTC */ + ZEPHCORE_RTC_PRESENT, /* identified as this RTC, including one + * whose time could not be read */ + ZEPHCORE_RTC_ALL_FF, /* the first read was all 0xFF: an erased + * EEPROM, or an RTC that has not started. + * Skipped; if no chip was adopted, the + * first save probes again */ +}; + +/* A devicetree-declared "zephcore,rtc-i2c" candidate, plus its probe outcome. */ +struct zephcore_rtc_entry { + const char *name; /* DT node full name, e.g. "rtc-rv3028@52" */ + const char *bus; /* I2C bus device name */ + uint16_t addr; /* I2C address */ + enum zephcore_rtc_state state; /* what the last probe run found */ + bool active; /* adopted as the write-back target */ +}; + +/* Number of RTC candidates discovery knows of: those the board declares in + * devicetree, or 0 when there are none or autodiscovery is compiled out. */ +size_t zephcore_rtc_declared(void); + +/* Copy up to max candidates into out[], in declaration order, and return how + * many were copied. States and the adopted marker come from one probe run. */ +size_t zephcore_rtc_snapshot(struct zephcore_rtc_entry *out, size_t max); + +/* Has boot-time discovery run? If false, every state above is UNPROBED. */ +bool zephcore_rtc_probed(void); + #ifdef __cplusplus } #endif diff --git a/zephcore/adapters/mqtt/ZephyrMQTTPublisher.c b/zephcore/adapters/mqtt/ZephyrMQTTPublisher.c index 79721c812..077cb6255 100644 --- a/zephcore/adapters/mqtt/ZephyrMQTTPublisher.c +++ b/zephcore/adapters/mqtt/ZephyrMQTTPublisher.c @@ -9,6 +9,9 @@ * 4. Publish retained "online" status (LWT handles "offline" on disconnect) * 5. Poll loop: drain publish queue + mqtt_input() keepalive * 6. On error/disconnect: publish "offline", back off 5s, goto 1 + * + * The session (CONNACK until the poll loop ends) is reported to the WiFi + * station, which blocks SoC light sleep outside it. */ #include "ZephyrMQTTPublisher.h" @@ -320,12 +323,20 @@ static void run_poll_loop(void) } } - /* Send keepalive ping if needed */ - int rc = mqtt_live(&s_client); - if (rc < 0 && rc != -EAGAIN) { - LOG_WRN("mqtt_live error: %d", rc); - s_connected = false; - return; + /* Keepalive ping, a quarter of the keepalive early. mqtt_live() + * pings only once the keepalive has fully elapsed, and a broker + * that enforces it without grace closes the session first + * (devdocs/lld/13-power-management.md, section 12). */ + int left_ms = mqtt_keepalive_time_left(&s_client); + + if (left_ms >= 0 && left_ms <= (int)s_client.keepalive * 250) { + int rc = mqtt_ping(&s_client); + + if (rc < 0 && rc != -EAGAIN) { + LOG_WRN("mqtt_ping error: %d", rc); + s_connected = false; + return; + } } if (!s_connected) { @@ -435,12 +446,18 @@ static void mqtt_thread_fn(void *p1, void *p2, void *p3) /* Connected — reset the failure backoff */ s_retry_backoff_s = RETRY_BACKOFF_MIN_S; + /* Session up: the station may let the SoC light-sleep until the + * poll loop ends. */ + zc_wifi_station_session_up(true); + /* Publish retained "online" status */ publish_status(&s_client, true); /* Run poll loop until disconnect or reconnect request */ run_poll_loop(); + zc_wifi_station_session_up(false); + /* Publish "offline" before closing (best-effort) */ if (s_connected) { publish_status(&s_client, false); diff --git a/zephcore/adapters/usb/ZephyrCompanionUSB.cpp b/zephcore/adapters/usb/ZephyrCompanionUSB.cpp index adb588785..3c2972b78 100644 --- a/zephcore/adapters/usb/ZephyrCompanionUSB.cpp +++ b/zephcore/adapters/usb/ZephyrCompanionUSB.cpp @@ -22,6 +22,7 @@ LOG_MODULE_REGISTER(zephcore_usb, CONFIG_ZEPHCORE_USB_LOG_LEVEL); #include "ZephyrCompanionUSB.h" #include "ZephyrUSBCDC.h" +#include #include "companion_framing.h" #define USB_RING_BUF_SIZE 512 /* USB RX ring buffer size */ @@ -223,6 +224,11 @@ static void usb_session_begin(uint8_t log_tag, bool is_text) } usb_session_active = true; usb_session_is_text = is_text; +#if !COMPANION_HAS_DTR + /* A plain UART has no DTR: the session never ends, and UART RX cannot + * wake a sleeping SoC. No light sleep from here on. */ + zc_pm_block_sleep(); +#endif LOG_INF("usb_rx: first traffic 0x%02x, session started (%s)", log_tag, is_text ? "text" : "binary"); usb_link_update(); diff --git a/zephcore/adapters/usb/ZephyrUSBCDC.cpp b/zephcore/adapters/usb/ZephyrUSBCDC.cpp index 81805b72c..9dc36cedd 100644 --- a/zephcore/adapters/usb/ZephyrUSBCDC.cpp +++ b/zephcore/adapters/usb/ZephyrUSBCDC.cpp @@ -30,6 +30,7 @@ LOG_MODULE_REGISTER(zephcore_usbd, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL); #include +#include /* ===== Per-role descriptor strings ====== */ #define ZEPHCORE_USB_VID 0x2fe3 /* Zephyr Project VID */ @@ -88,6 +89,23 @@ static void enter_bootloader(void) CODE_UNREACHABLE; } +/* A host is on the bus (it reset, configured or resumed us and has not + * suspended it since). No SoC light sleep while one is: sleep stops the USB + * clock, and the host loses the device without being told + * (devdocs/lld/13-power-management.md, section 13). */ +static atomic_t s_host_present; + +static void host_present(bool present) +{ + if (present) { + if (atomic_cas(&s_host_present, 0, 1)) { + zc_pm_block_sleep(); + } + } else if (atomic_cas(&s_host_present, 1, 0)) { + zc_pm_unblock_sleep(); + } +} + /* The host went away without a DTR=0: report it as a DTR drop. */ static void host_gone(void) { @@ -105,6 +123,15 @@ static void usbd_msg_callback(struct usbd_context *const ctx, { ARG_UNUSED(ctx); + if (msg->type == USBD_MSG_SUSPEND || msg->type == USBD_MSG_VBUS_REMOVED) { + host_present(false); + } else if (msg->type == USBD_MSG_RESET || msg->type == USBD_MSG_RESUME || + msg->type == USBD_MSG_CONFIGURATION || + msg->type == USBD_MSG_CDC_ACM_LINE_CODING || + msg->type == USBD_MSG_CDC_ACM_CONTROL_LINE_STATE) { + host_present(true); + } + if (msg->type == USBD_MSG_CDC_ACM_LINE_CODING) { uint32_t baudrate = 0; if (uart_line_ctrl_get(msg->dev, UART_LINE_CTRL_BAUD_RATE, @@ -309,4 +336,5 @@ extern "C" void zephcore_usbd_detach(void) (void)usbd_disable(&zephcore_usbd); s_initialized = false; s_dtr_active = false; + host_present(false); } diff --git a/zephcore/adapters/wifi/ZephyrWiFiStation.c b/zephcore/adapters/wifi/ZephyrWiFiStation.c index b6fae1c37..e8ddf2fa6 100644 --- a/zephcore/adapters/wifi/ZephyrWiFiStation.c +++ b/zephcore/adapters/wifi/ZephyrWiFiStation.c @@ -37,11 +37,42 @@ K_EVENT_DEFINE(g_wifi_events); static const char *s_ssid; static const char *s_psk; static void (*s_time_sync_cb)(uint32_t unix_ts); +static bool s_power_save; +static bool s_started; /* Protect link-up vs. SNTP state */ static volatile bool s_wifi_link_up; /* WiFi associate event received */ static volatile bool s_wifi_ready; /* DHCP + SNTP done */ +/* ========== Light-sleep block ========== */ + +/* Held unless the link is ready and the client's session is up + * (devdocs/lld/13-power-management.md, section 12). Called from the net_mgmt + * thread and from the client's thread. */ +static K_MUTEX_DEFINE(s_sleep_mutex); +static volatile bool s_session_up; +static bool s_sleep_blocked; + +static void sleep_block_update(void) +{ + if (!s_started) { + return; + } + + k_mutex_lock(&s_sleep_mutex, K_FOREVER); + + bool block = !(s_wifi_ready && s_session_up); + + if (block && !s_sleep_blocked) { + zc_pm_block_sleep(); + } else if (!block && s_sleep_blocked) { + zc_pm_unblock_sleep(); + } + s_sleep_blocked = block; + + k_mutex_unlock(&s_sleep_mutex); +} + /* ========== Reconnect work ========== */ static void connect_work_fn(struct k_work *work); @@ -68,6 +99,7 @@ static void do_sntp_and_signal(void) #endif s_wifi_ready = true; + sleep_block_update(); k_event_post(&g_wifi_events, WIFI_READY_BIT); LOG_INF("WiFi ready (DHCP+SNTP done)"); } @@ -104,18 +136,19 @@ static void wifi_event_handler(struct net_mgmt_event_callback *cb, if (success) { LOG_INF("WiFi link up (SSID: %s)", s_ssid ? s_ssid : "?"); s_wifi_link_up = true; - /* Disable power save — WIFI_PS_MIN_MODEM (default) sleeps between - * DTIM beacons, which delays ACKs and causes the MQTT broker's TCP - * stack to retransmit and eventually RST the connection (-ECONNRESET - * in the poll loop). WIFI_PS_DISABLED keeps the radio always awake. */ - struct wifi_ps_params ps = { - .enabled = WIFI_PS_DISABLED, - .type = WIFI_PS_PARAM_STATE, - }; - if (net_mgmt(NET_REQUEST_WIFI_PS, iface, &ps, sizeof(ps)) < 0) { - LOG_WRN("Failed to disable WiFi power save"); - } else { - LOG_INF("WiFi power save disabled"); + if (!s_power_save) { + /* Power save off: the WiFi radio stays on between + * beacons. Left alone, the driver runs minimum modem + * sleep. */ + struct wifi_ps_params ps = { + .enabled = WIFI_PS_DISABLED, + .type = WIFI_PS_PARAM_STATE, + }; + if (net_mgmt(NET_REQUEST_WIFI_PS, iface, &ps, sizeof(ps)) < 0) { + LOG_WRN("Failed to disable WiFi power save"); + } else { + LOG_INF("WiFi power save disabled"); + } } /* DHCP will fire ipv4_event_handler when lease is obtained */ } else { @@ -128,6 +161,7 @@ static void wifi_event_handler(struct net_mgmt_event_callback *cb, LOG_INF("WiFi disconnected"); s_wifi_link_up = false; s_wifi_ready = false; + sleep_block_update(); k_event_clear(&g_wifi_events, WIFI_READY_BIT); /* Reconnect after 5s backoff */ k_work_reschedule(&connect_work, K_SECONDS(5)); @@ -181,20 +215,21 @@ static void connect_work_fn(struct k_work *work) /* ========== Public API ========== */ void zc_wifi_station_start(const char *ssid, const char *psk, - void (*time_sync_cb)(uint32_t unix_ts)) + void (*time_sync_cb)(uint32_t unix_ts), bool power_save) { s_ssid = ssid; s_psk = psk; s_time_sync_cb = time_sync_cb; + s_power_save = power_save; s_wifi_link_up = false; s_wifi_ready = false; - /* Permanent, and intentionally never released: there is no - * zc_wifi_station_stop() — once an uplink or observer node associates it - * stays associated for the life of the boot. Light sleep would drop the - * association (nothing here coordinates with the WiFi modem's own sleep), - * so a node built with WiFi simply does not light-sleep. */ - zc_pm_block_sleep(); + /* No light sleep until the link is ready and the client's session is up. + * There is no zc_wifi_station_stop(): a station stays for the life of the + * boot. */ + s_started = true; + sleep_block_update(); + zc_pm_wifi_station_started(); /* Register WiFi event callback */ net_mgmt_init_event_callback(&wifi_cb, wifi_event_handler, @@ -222,10 +257,17 @@ void zc_wifi_station_reconnect(void) /* Clear state and immediately reschedule connect */ s_wifi_link_up = false; s_wifi_ready = false; + sleep_block_update(); k_event_clear(&g_wifi_events, WIFI_READY_BIT); k_work_reschedule(&connect_work, K_MSEC(200)); } +void zc_wifi_station_session_up(bool up) +{ + s_session_up = up; + sleep_block_update(); +} + bool zc_wifi_station_is_connected(void) { return s_wifi_ready; diff --git a/zephcore/adapters/wifi/ZephyrWiFiStation.h b/zephcore/adapters/wifi/ZephyrWiFiStation.h index f3d6cbc2e..0230cf987 100644 --- a/zephcore/adapters/wifi/ZephyrWiFiStation.h +++ b/zephcore/adapters/wifi/ZephyrWiFiStation.h @@ -33,11 +33,24 @@ extern struct k_event g_wifi_events; * ssid, psk — the network; pointers kept (read at every connect), so they * must remain valid. psk "" = open network. * time_sync_cb — called once after SNTP with Unix timestamp; may be NULL. + * power_save — true: run with the driver's WiFi power save (minimum modem + * sleep: the WiFi radio is off between the access point's DTIM + * beacons). false: request power save off at every link-up. * * Must be called once from main() before any other wifi_station_* calls. + * + * ESP32 light-sleep builds: the station blocks SoC light sleep until the link + * is ready and a client has reported its session up, and again whenever either + * is lost. With no such client the block stays for the life of the boot. */ void zc_wifi_station_start(const char *ssid, const char *psk, - void (*time_sync_cb)(uint32_t unix_ts)); + void (*time_sync_cb)(uint32_t unix_ts), bool power_save); + +/* + * The client's session over the link (the MQTT publisher's broker session) is + * up, or has ended. Safe to call from any thread. + */ +void zc_wifi_station_session_up(bool up); /* * Trigger an immediate reconnect (e.g. after credential change via CLI). diff --git a/zephcore/app/CompanionWifi.cpp b/zephcore/app/CompanionWifi.cpp index cbbbe08e9..e2b18318e 100644 --- a/zephcore/app/CompanionWifi.cpp +++ b/zephcore/app/CompanionWifi.cpp @@ -13,6 +13,7 @@ #include #include #include +#include LOG_MODULE_REGISTER(zephcore_wifi_companion, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); @@ -35,8 +36,17 @@ void companion_wifi_start(const NodePrefs &prefs) memcpy(s_pwd, prefs.wifi_pwd, sizeof(s_pwd)); LOG_INF("WiFi companion: joining %s", s_ssid); /* No SNTP time: the app sets the companion's clock, and time only moves - * forward (see time_sync.h). */ - zc_wifi_station_start(s_ssid, s_pwd, nullptr); + * forward (see time_sync.h). + * + * Light-sleep builds: WiFi power save on (the WiFi library gives its sleep + * lock up only in power save), and no client session to wait for, so the + * station blocks sleep only while the link is not ready. Other builds: + * power save off, as before (devdocs/lld/13-power-management.md, + * sections 12 and 13). */ + zc_wifi_station_start(s_ssid, s_pwd, nullptr, ZC_PM_LIGHT_SLEEP != 0); + if (ZC_PM_LIGHT_SLEEP) { + zc_wifi_station_session_up(true); + } } bool companion_wifi_cli(const char *command, NodePrefs &prefs, void (*save)(void), char *reply) diff --git a/zephcore/app/RepeaterMesh.cpp b/zephcore/app/RepeaterMesh.cpp index 68d541631..2787868e9 100644 --- a/zephcore/app/RepeaterMesh.cpp +++ b/zephcore/app/RepeaterMesh.cpp @@ -1102,7 +1102,8 @@ void RepeaterMesh::begin(RepeaterDataStore* store) { if (isUplinkEnabled() && _uplink_creds.wifi_ssid[0] && _uplink_creds.mqtt_host[0]) { s_uplink_mesh = this; - zc_wifi_station_start(_uplink_creds.wifi_ssid, _uplink_creds.wifi_psk, uplink_time_sync_cb); + zc_wifi_station_start(_uplink_creds.wifi_ssid, _uplink_creds.wifi_psk, uplink_time_sync_cb, + true /* WiFi power save */); mqtt_publisher_start(&_uplink_creds, _prefs.node_name, _uplink_status_topic, _uplink_packets_topic); mqtt_publisher_set_connect_cb([]() { diff --git a/zephcore/app/main_observer.cpp b/zephcore/app/main_observer.cpp index f83e6d9d6..21cd1cfab 100644 --- a/zephcore/app/main_observer.cpp +++ b/zephcore/app/main_observer.cpp @@ -44,7 +44,8 @@ LOG_MODULE_REGISTER(zephcore_observer_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); /* ========== LED (optional) ========== */ -#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) +/* With a PWM LED, led0 is the same pin and belongs to the PWM driver. */ +#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) && !IS_ENABLED(CONFIG_ZEPHCORE_LED_PWM) #include #define LED0_NODE DT_ALIAS(led0) static const struct gpio_dt_spec led0 = GPIO_DT_SPEC_GET(LED0_NODE, gpios); @@ -206,6 +207,10 @@ static void print_banner(void) cli_println("get mqtt.user MQTT username"); cli_println("get mqtt.iata Location code"); cli_println("get meshtimesync Time-sync consensus state (dry-run)"); + cli_println(""); + cli_println("--- System ---"); + cli_println("reboot Restart the node"); + cli_println("erase Factory reset: erase all settings, then restart"); cli_println("help Show this screen"); cli_println("========================="); } @@ -238,6 +243,10 @@ static void cli_echo_resume(void) static inline void cli_echo_resume(void) {} #endif +/* `reboot` and `erase`: handled here, beside the board and the data store, + * not in ObserverMesh. Defined below the global instances. */ +static bool cli_system_command(const char *line, char *reply, size_t reply_size); + static void process_cli_rx(void) { uint8_t byte; @@ -249,7 +258,9 @@ static void process_cli_rx(void) cli_reply[0] = '\0'; bool want_banner = false; - if (s_mesh_ptr) { + if (cli_system_command(cli_line, cli_reply, sizeof(cli_reply))) { + /* reply set; the node resets shortly */ + } else if (s_mesh_ptr) { want_banner = s_mesh_ptr->handleCLI( cli_line, cli_reply, sizeof(cli_reply)); @@ -311,6 +322,39 @@ static mesh::LoRaRadio lora_radio(lora_dev, s_board); static mesh::ObserverMesh observer_mesh(lora_radio, s_ms_clock, s_rtc_clock); static RepeaterDataStore data_store; +/* ========== reboot / erase ========== */ + +/* Deferred so the reply reaches the console before the reset. */ +static void cli_reboot_work_fn(struct k_work *work) +{ + ARG_UNUSED(work); + s_board.reboot(); +} + +static K_WORK_DELAYABLE_DEFINE(cli_reboot_work, cli_reboot_work_fn); + +/* Same commands and replies as the repeater's CLI (helpers/CommonCLI.cpp). + * `erase` is the factory reset: the whole volume, WiFi and MQTT settings + * included, then a reboot onto defaults. */ +static bool cli_system_command(const char *line, char *reply, size_t reply_size) +{ + if (strcmp(line, "reboot") == 0) { + snprintf(reply, reply_size, "OK - rebooting"); + k_work_schedule(&cli_reboot_work, K_MSEC(500)); + return true; + } + if (strcmp(line, "erase") == 0) { + if (data_store.formatFileSystem()) { + snprintf(reply, reply_size, "File system erase: OK - rebooting"); + k_work_schedule(&cli_reboot_work, K_MSEC(500)); + } else { + snprintf(reply, reply_size, "File system erase: Err"); + } + return true; + } + return false; +} + /* ========== main() ========== */ int main(void) @@ -320,7 +364,7 @@ int main(void) LOG_INF("=== ZephCore Observer starting ==="); /* Configure LED */ -#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) +#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) && !IS_ENABLED(CONFIG_ZEPHCORE_LED_PWM) if (gpio_is_ready_dt(&led0)) { gpio_pin_configure_dt(&led0, GPIO_OUTPUT_INACTIVE); } @@ -402,7 +446,8 @@ int main(void) print_banner(); /* Start WiFi (non-blocking — MQTT thread waits for WIFI_READY_BIT) */ - zc_wifi_station_start(s_creds.wifi_ssid, s_creds.wifi_psk, time_sync_cb); + zc_wifi_station_start(s_creds.wifi_ssid, s_creds.wifi_psk, time_sync_cb, + true /* WiFi power save */); /* Start MQTT publisher thread */ char client_id[64]; diff --git a/zephcore/boards/common/esp32_coretemp.overlay b/zephcore/boards/common/esp32_coretemp.overlay new file mode 100644 index 000000000..7c3961e2d --- /dev/null +++ b/zephcore/boards/common/esp32_coretemp.overlay @@ -0,0 +1,16 @@ +/* + * SPDX-License-Identifier: MIT + * ESP32-S3 / C3 / C6: enable the SoC's internal temperature sensor. + * + * Every Espressif SoC dtsi that has the sensor declares it as `coretemp`, + * disabled, behind the alias `die-temp0`. Enabling the node is all it takes: + * CONFIG_ESP32_TEMP defaults on with it, and ZephyrBoard::getMCUTemperature() + * finds it through the alias. + * + * Not for the classic ESP32, which has no such node (a reference to &coretemp + * is a dtc error there) and no driver. CMakeLists.txt skips it for that SoC. + */ + +&coretemp { + status = "okay"; +}; diff --git a/zephcore/boards/common/esp32_cpu_80mhz.overlay b/zephcore/boards/common/esp32_cpu_80mhz.overlay index cf1bf7503..a2c061dfd 100644 --- a/zephcore/boards/common/esp32_cpu_80mhz.overlay +++ b/zephcore/boards/common/esp32_cpu_80mhz.overlay @@ -26,11 +26,10 @@ * classic) and the single-core RISC-V parts (C3, C6) alike. Referencing * &cpu1 here would be a dtc error on the latter. * - * Applied automatically to non-observer ESP32 builds — see the ESP32 CPU - * clock block in CMakeLists.txt. Kept out of the per-board DTS files on - * purpose: one place to change, and it makes the observer exception a simple - * "don't include this" rather than an override that would have to know each - * SoC's maximum (240 MHz on S3/classic, 160 MHz on C3/C6). + * Applied automatically to every ESP32 build — see the ESP32 CPU clock block + * in CMakeLists.txt. Kept out of the per-board DTS files on purpose: one place + * to change. Without it a build runs at its SoC's maximum (240 MHz on + * S3/classic, 160 MHz on C3/C6). */ &cpu0 { diff --git a/zephcore/boards/common/observer.conf b/zephcore/boards/common/observer.conf index 8b24046c7..eb2feec49 100644 --- a/zephcore/boards/common/observer.conf +++ b/zephcore/boards/common/observer.conf @@ -53,6 +53,10 @@ CONFIG_MBEDTLS_SSL_SERVER_NAME_INDICATION=y # TLS 1.2 ciphersuite (auto-selects KEY_EXCHANGE + PSA_WANT_* dependencies) CONFIG_MBEDTLS_CIPHERSUITE_TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256=y CONFIG_MBEDTLS_CIPHERSUITE_TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256=y +# P-384 and SHA-384, to parse the broker's certificate chain +# (devdocs/lld/08-connectivity.md). Same lines in repeater_uplink.conf. +CONFIG_PSA_WANT_ALG_SHA_384=y +CONFIG_PSA_WANT_ECC_SECP_R1_384=y # TLS 1.2 only (TLS 1.3 causes handshake_failure on most MQTT brokers) # ========================================================= diff --git a/zephcore/boards/common/pm_esp32.conf b/zephcore/boards/common/pm_esp32.conf index 0ba63186b..976f53e97 100644 --- a/zephcore/boards/common/pm_esp32.conf +++ b/zephcore/boards/common/pm_esp32.conf @@ -1,10 +1,11 @@ # ESP32 system power management — light sleep between events. # # AUTO-ENABLED for repeater builds on boards whose zephcore.yml declares -# `light_sleep: true` (cmake/zephcore_board.cmake, CMakeLists.txt). Passing it -# by hand is supported for testing another board: +# `light_sleep: true`, and for companion builds on boards that declare +# `light_sleep_companion: true` (cmake/zephcore_board.cmake, CMakeLists.txt). +# Passing it by hand is supported for testing another board: # -# west build -b xiao_esp32s3/esp32s3/procpu zephcore --pristine --sysbuild -- # -DEXTRA_CONF_FILE="boards/common/repeater.conf;boards/common/pm_esp32.conf" +# west build -b heltec_wifi_lora32_v4_r8/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 @@ -37,16 +38,24 @@ # button to reopen the window, or send `powersaving off` (persisted) over LoRa; # `powersaving on` allows sleep again. Light-sleep builds default to on. # -# WiFi: the ESP32 WiFi driver starts WiFi at boot (NULL mode), and a started -# WiFi library holds its modem lock, so NO S3 repeater (they all carry WiFi OTA) -# ever light-slept before 2026-09-26. helpers/pm_esp32_wake.c stops WiFi at -# boot; `start ota` (AP) and a station connect start it again. While WiFi is in -# use a pm_policy lock is held — the OTA session for its duration -# (adapters/ota/wifi_ota.c), an uplink or observer station permanently -# (adapters/wifi/ZephyrWiFiStation.c). +# WiFi: the ESP32 WiFi driver starts WiFi at boot (NULL mode), and the WiFi +# library holds its modem lock for as long as the WiFi radio is on, so NO S3 +# repeater (they all carry WiFi OTA) ever light-slept before 2026-09-26. +# helpers/pm_esp32_wake.c stops WiFi at boot; `start ota` (AP) and a station +# connect start it again. An OTA session holds a pm_policy lock for its +# duration (adapters/ota/wifi_ota.c). A station (the MQTT uplink) runs in WiFi +# power save: the library gives its lock up between beacons and the WiFi MAC +# wakes the SoC for each one. The station holds a lock of its own until its +# MQTT session is up, and again whenever the link or the session is lost +# (adapters/wifi/ZephyrWiFiStation.c); a node whose access point is away stays +# awake. devdocs/lld/13-power-management.md, section 12. # -# REPEATERS ONLY for now. Companions keep BLE up; making the controller's -# modem sleep coexist with system light sleep is the open question in LLD 13. +# COMPANIONS sleep only after `powersaving on` (their default is off), and then +# not while a USB host is on the bus, the display is on, or a BLE link is not +# yet encrypted. The BLE controller gives its lock up between events and its +# wake timer bounds each sleep (CONFIG_PM_CUSTOM_TICKS_HOOK below). The build +# costs about 4 KB of DRAM, which is why the companion has its own capability +# line. devdocs/lld/13-power-management.md, section 13. # # CHECKING A BOARD: `get pm` shows the share of time asleep, the sleep count and # wake causes (t = RTC timer, g = GPIO, o = other) and per-pin wake counts. diff --git a/zephcore/boards/common/repeater_uplink.conf b/zephcore/boards/common/repeater_uplink.conf index b06af46fa..32217e546 100644 --- a/zephcore/boards/common/repeater_uplink.conf +++ b/zephcore/boards/common/repeater_uplink.conf @@ -43,6 +43,10 @@ CONFIG_MBEDTLS_SSL_MAX_CONTENT_LEN=4096 CONFIG_MBEDTLS_SSL_SERVER_NAME_INDICATION=y CONFIG_MBEDTLS_CIPHERSUITE_TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256=y CONFIG_MBEDTLS_CIPHERSUITE_TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256=y +# P-384 and SHA-384, to parse the broker's certificate chain +# (devdocs/lld/08-connectivity.md). Same lines in observer.conf. +CONFIG_PSA_WANT_ALG_SHA_384=y +CONFIG_PSA_WANT_ECC_SECP_R1_384=y # SNTP (used by ZephyrWiFiStation) CONFIG_SNTP=y diff --git a/zephcore/boards/common/rtc-i2c.dtsi b/zephcore/boards/common/rtc-i2c.dtsi index 2dabd133a..18d97bf32 100644 --- a/zephcore/boards/common/rtc-i2c.dtsi +++ b/zephcore/boards/common/rtc-i2c.dtsi @@ -65,6 +65,7 @@ rtc_rv3028: rtc-rv3028@52 { date-index = <4>; status-reg = <0x0e>; /* status */ status-mask = <0x01>; /* PORF — power-on reset */ + twelve-hour-bit = [10 02]; /* Control 2 12_24, p. 24 */ /* RV-3028-C7 Application Manual Rev. 1.4, 3.2, p. 12 */ zero-mask = [80 80 c0 f8 c0 e0 00]; }; diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.conf b/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.conf index e75c74c77..058f1f363 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.conf +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.conf @@ -49,8 +49,10 @@ CONFIG_POWER_DOMAIN=n CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS=3000 # WiFi + BLE companion without PSRAM (`capabilities: wifi` in zephcore.yml): -# contacts are what gives. 140 is the largest count that links with ~2 KB DRAM -# to spare (188 B per contact; measured 2026-09-24, see -# devdocs/lld/08-connectivity.md §14). The offline queue stays at 256. An -# existing contacts file holding more is truncated to 140 on first load. -CONFIG_ZEPHCORE_MAX_CONTACTS=140 +# contacts are what gives (188 B each; devdocs/lld/08-connectivity.md §14). +# 120 links with ~2 KB DRAM to spare with light sleep in the build +# (`light_sleep_companion`, about 4 KB; 140 before it, 2026-10-09, +# devdocs/lld/13-power-management.md section 13). The offline queue stays +# at 256. An existing contacts file holding more is truncated to 120 on +# first load. +CONFIG_ZEPHCORE_MAX_CONTACTS=120 diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v3/zephcore.yml b/zephcore/boards/esp32/heltec_wifi_lora32_v3/zephcore.yml index 5471959a8..46f79d18b 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v3/zephcore.yml +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v3/zephcore.yml @@ -3,6 +3,7 @@ target: heltec_wifi_lora32_v3/esp32s3/procpu capabilities: wifi: true light_sleep: true + light_sleep_companion: true release: roles: [companion, repeater] catalog: diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v4/zephcore.yml b/zephcore/boards/esp32/heltec_wifi_lora32_v4/zephcore.yml index 4e55c958a..c25e47a8f 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v4/zephcore.yml +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v4/zephcore.yml @@ -3,6 +3,7 @@ target: heltec_wifi_lora32_v4/esp32s3/procpu capabilities: wifi: true light_sleep: true + light_sleep_companion: true release: roles: [companion, repeater] catalog: diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v43/heltec_wifi_lora32_v43_procpu.dts b/zephcore/boards/esp32/heltec_wifi_lora32_v43/heltec_wifi_lora32_v43_procpu.dts index 47e523fe2..ae2235f4f 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 @@ -47,6 +47,13 @@ led0 = &led0; pwm-0 = &ledc0; pwm-led0 = &pwm_led_white; + /* Single physical LED, shared between the heartbeat and LoRa TX + * activity roles (same node aliased twice), same pattern as T096 and + * Wireless Tracker V2 -- see helpers/ui/ui_common.c and + * adapters/board/ZephyrBoard.cpp (ZEPHCORE_LED_PIN_SHARED covers the + * arbitration between the two roles automatically). */ + heartbeat-pwm-led = &pwm_led_white; + lora-tx-pwm-led = &pwm_led_white; uart-0 = &uart0; i2c-0 = &i2c0; lora0 = &lora0; diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v43/zephcore.yml b/zephcore/boards/esp32/heltec_wifi_lora32_v43/zephcore.yml index 9fd695064..a3bc9663e 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v43/zephcore.yml +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v43/zephcore.yml @@ -3,6 +3,7 @@ target: heltec_wifi_lora32_v43/esp32s3/procpu capabilities: wifi: true light_sleep: true + light_sleep_companion: true release: roles: [companion, repeater] catalog: diff --git a/zephcore/boards/esp32/heltec_wireless_tracker/board.conf b/zephcore/boards/esp32/heltec_wireless_tracker/board.conf index 0341046fe..8249214e9 100644 --- a/zephcore/boards/esp32/heltec_wireless_tracker/board.conf +++ b/zephcore/boards/esp32/heltec_wireless_tracker/board.conf @@ -32,8 +32,10 @@ CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS=3000 # WiFi + BLE companion without PSRAM (`capabilities: wifi` in zephcore.yml): -# contacts are what gives. 130 is the largest count that links with ~2 KB DRAM -# to spare (188 B per contact; measured 2026-09-24, see -# devdocs/lld/08-connectivity.md §14). The offline queue stays at 256. An -# existing contacts file holding more is truncated to 130 on first load. -CONFIG_ZEPHCORE_MAX_CONTACTS=130 +# contacts are what gives (188 B each; devdocs/lld/08-connectivity.md §14). +# 110 links with ~2 KB DRAM to spare with light sleep in the build +# (`light_sleep_companion`, about 4 KB; 130 before it, 2026-10-09, +# devdocs/lld/13-power-management.md section 13). The offline queue stays +# at 256. An existing contacts file holding more is truncated to 110 on +# first load. +CONFIG_ZEPHCORE_MAX_CONTACTS=110 diff --git a/zephcore/boards/esp32/heltec_wireless_tracker/zephcore.yml b/zephcore/boards/esp32/heltec_wireless_tracker/zephcore.yml index e108f0078..3ce3b1990 100644 --- a/zephcore/boards/esp32/heltec_wireless_tracker/zephcore.yml +++ b/zephcore/boards/esp32/heltec_wireless_tracker/zephcore.yml @@ -3,6 +3,7 @@ target: heltec_wireless_tracker/esp32s3/procpu capabilities: wifi: true light_sleep: true + light_sleep_companion: true release: roles: [companion, repeater] catalog: diff --git a/zephcore/boards/esp32/heltec_wireless_tracker_v2/heltec_wireless_tracker_v2-pinctrl.dtsi b/zephcore/boards/esp32/heltec_wireless_tracker_v2/heltec_wireless_tracker_v2-pinctrl.dtsi index 15c912d88..04c3cd3fa 100644 --- a/zephcore/boards/esp32/heltec_wireless_tracker_v2/heltec_wireless_tracker_v2-pinctrl.dtsi +++ b/zephcore/boards/esp32/heltec_wireless_tracker_v2/heltec_wireless_tracker_v2-pinctrl.dtsi @@ -72,4 +72,11 @@ output-low; }; }; + + ledc0_default: ledc0_default { + group1 { + pinmux = ; + output-enable; + }; + }; }; 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 ef959e52b..a56894bff 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 @@ -30,6 +30,7 @@ #include #include #include +#include #include "heltec_wireless_tracker_v2-pinctrl.dtsi" / { @@ -39,6 +40,8 @@ aliases { led0 = &status_led; lora-tx-led = &status_led; + heartbeat-pwm-led = &pwm_status_led; + lora-tx-pwm-led = &pwm_status_led; uart-0 = &uart0; uart-2 = &uart2; i2c-0 = &i2c0; @@ -69,6 +72,21 @@ }; }; + /* Same GPIO18 net as status_led above, driven through the LEDC PWM + * peripheral instead of a raw GPIO write. Single LED doubling as + * heartbeat, message/shutdown flash and LoRa TX indicator: the + * heartbeat-pwm-led/lora-tx-pwm-led aliases give every one of those + * events a single persisted 0-100 brightness knob, on top of the + * existing persisted on/off gate. */ + pwm_leds { + compatible = "pwm-leds"; + + pwm_status_led: pwm_led_gpio18 { + label = "Status PWM LED"; + pwms = <&ledc0 0 PWM_MSEC(10) PWM_POLARITY_NORMAL>; + }; + }; + buttons: buttons { compatible = "gpio-keys"; @@ -231,6 +249,19 @@ status = "okay"; }; +&ledc0 { + pinctrl-0 = <&ledc0_default>; + pinctrl-names = "default"; + status = "okay"; + #address-cells = <1>; + #size-cells = <0>; + + channel0@0 { + reg = <0x0>; + timer = <0>; + }; +}; + &i2c0 { status = "okay"; clock-frequency = ; diff --git a/zephcore/boards/esp32/heltec_wireless_tracker_v2/zephcore.yml b/zephcore/boards/esp32/heltec_wireless_tracker_v2/zephcore.yml index ffe28fb9d..785057036 100644 --- a/zephcore/boards/esp32/heltec_wireless_tracker_v2/zephcore.yml +++ b/zephcore/boards/esp32/heltec_wireless_tracker_v2/zephcore.yml @@ -2,6 +2,7 @@ target: heltec_wireless_tracker_v2/esp32s3/procpu capabilities: light_sleep: true + light_sleep_companion: true release: roles: [companion, repeater] catalog: diff --git a/zephcore/boards/esp32/station_g2/zephcore.yml b/zephcore/boards/esp32/station_g2/zephcore.yml index de8eafa12..43f612ab5 100644 --- a/zephcore/boards/esp32/station_g2/zephcore.yml +++ b/zephcore/boards/esp32/station_g2/zephcore.yml @@ -2,6 +2,8 @@ target: station_g2/esp32s3/procpu capabilities: wifi: true + light_sleep: true + light_sleep_companion: true release: roles: [companion, repeater] catalog: diff --git a/zephcore/boards/esp32/xiao_esp32s3/zephcore.yml b/zephcore/boards/esp32/xiao_esp32s3/zephcore.yml index 0391df8c1..faa96b8d9 100644 --- a/zephcore/boards/esp32/xiao_esp32s3/zephcore.yml +++ b/zephcore/boards/esp32/xiao_esp32s3/zephcore.yml @@ -2,6 +2,8 @@ target: xiao_esp32s3/esp32s3/procpu capabilities: wifi: true + light_sleep: true + light_sleep_companion: true release: roles: [companion, repeater] catalog: diff --git a/zephcore/boards/example_board/README.md b/zephcore/boards/example_board/README.md index 573f4e3c8..6b368cf01 100644 --- a/zephcore/boards/example_board/README.md +++ b/zephcore/boards/example_board/README.md @@ -142,6 +142,7 @@ Only `lora0` is required. Everything else enables its feature by being present. | `lfs_partition` + `filesystem.dtsi` | `/lfs`: identity, prefs, contacts, channels | | chosen `zephyr,settings-partition` | NVS for BLE bonds (nRF52, ESP32). nRF54L and MG24 keep bonds in a file on `/lfs` | | aliases `led0`, `led1`, `lora-tx-led` | Heartbeat, unread, TX blink | +| aliases `heartbeat-pwm-led`, `lora-tx-pwm-led` | Same heartbeat/TX blink, brightness-adjustable (`leds.brightness`). Point both at one `pwm-leds` node to share a single physical LED. On ESP32, leave `led0`/`led1` out of the board's `main()` GPIO setup when either alias is present: configuring the same pin as plain GPIO after the PWM driver has claimed it takes the pin back from the LEDC peripheral (`CONFIG_ZEPHCORE_LED_PWM` follows the aliases: it selects `CONFIG_PWM` and makes `src/server_main_common.cpp` and `app/main_observer.cpp` skip this for you) | | alias `sw0` | User button; wake source from nRF System OFF and ESP32 light sleep | | chosen `zephyr,display` | Display UI | | node labelled `buzzer` + alias `buzzer` | PWM buzzer | @@ -206,7 +207,10 @@ Only `lora0` is required. Everything else enables its feature by being present. - **Manifest capabilities:** `wifi: true` gives the companion a WiFi transport and belongs only on boards with the RAM for WiFi and BLE together ([ADR 0009](../../../docs/adr/0009-wifi-companion-boards.md)). `light_sleep: true` makes repeaters light-sleep; the requirements are in `boards/common/pm_esp32.conf`, and - it is declared only after it was validated on the hardware. Either way, give NSS, RESET and every MCU-driven + it is declared only after it was validated on the hardware. `light_sleep_companion: true` gives the companion + build light sleep (off until the user sends `powersaving on`); it needs the same pin holds and wake pins and + about 4 KB of free DRAM, which a WiFi + BLE companion without PSRAM pays for in contacts. Being opt-in, it may + be declared before `light_sleep`. Either way, give NSS, RESET and every MCU-driven RF switch or FEM line `ESP32_GPIO_SLEEP_HOLD_EN` from the start. - **RAM, not flash, limits the companion.** Boards without PSRAM lower `MAX_CONTACTS`; the classic ESP32 also trims channels and the offline queue. diff --git a/zephcore/boards/example_board/board.overlay b/zephcore/boards/example_board/board.overlay index cf482d974..0c880480e 100644 --- a/zephcore/boards/example_board/board.overlay +++ b/zephcore/boards/example_board/board.overlay @@ -65,6 +65,9 @@ * led0 heartbeat LED * led1 second LED (unread messages on a companion) * lora-tx-led blinks while transmitting + * heartbeat-pwm-led, lora-tx-pwm-led + * same two roles, brightness-adjustable (leds.brightness); + * point both at one pwm-leds node to share a physical LED * sw0 user button; also the wake source from nRF System OFF * and from ESP32 light sleep * buzzer PWM buzzer (the node must also be LABELLED buzzer) @@ -604,6 +607,51 @@ zephyr_udc0: &usbd { }; #endif +#if 0 /* ---- Heartbeat/TX LED, brightness-adjustable (heltec_t096, heltec_wifi_lora32_v43) ---- + * One physical LED standing in for both led0 and lora-tx-led, but wired + * through a pwm-leds node instead of gpio-leds so leds.brightness can + * dim it. Point heartbeat-pwm-led and lora-tx-pwm-led at the same node + * to share it, same as the plain-GPIO led0/lora-tx-led pair above. + * + * ESP32 only: also declare a plain led0 (gpio-leds) for this pin if the + * board wants one, but do NOT add GPIO setup for it in the board's + * main() -- src/server_main_common.cpp and app/main_observer.cpp skip + * that already (CONFIG_ZEPHCORE_LED_PWM) when either PWM alias is + * present, because on ESP32 configuring the pin as plain GPIO after + * the PWM driver has claimed it takes the pin back from the LEDC + * peripheral: the GPIO matrix only routes one signal to a pin at a + * time. nRF52 PWM and GPIO don't share hardware that way, so this does + * not apply there. */ +/ { + aliases { + heartbeat-pwm-led = &pwm_led_white; + lora-tx-pwm-led = &pwm_led_white; + }; + + pwmleds { + compatible = "pwm-leds"; + + pwm_led_white: pwm_led_0 { + pwms = <&ledc0 0 PWM_MSEC(10) PWM_POLARITY_NORMAL>; + label = "White PWM LED"; + }; + }; +}; + +&ledc0 { + status = "okay"; + pinctrl-0 = <&ledc0_default>; + pinctrl-names = "default"; + #address-cells = <1>; + #size-cells = <0>; + + channel0@0 { + reg = <0x0>; + timer = <0>; + }; +}; +#endif + #if 0 /* ---- Battery voltage (rak4631) ---- * * mV = raw * vbat-mv-multiplier / 4096, so the multiplier is the ADC's diff --git a/zephcore/boards/example_board/zephcore.yml b/zephcore/boards/example_board/zephcore.yml index 166a934fd..6623ed2e9 100644 --- a/zephcore/boards/example_board/zephcore.yml +++ b/zephcore/boards/example_board/zephcore.yml @@ -27,6 +27,10 @@ target: my_board # light_sleep: true # repeater light-sleeps between events. Declare it only # # after boards/common/pm_esp32.conf's checks pass on # # hardware; qualifying wiring alone is not enough +# light_sleep_companion: true # the companion can light-sleep (off until +# # `powersaving on`). Needs the same pin holds and +# # wake pins, and about 4 KB of DRAM. It does not need +# # light_sleep: opt-in, so it may come first # Leave `release:` out while the board is in bring-up: it then builds locally # and is listed as "source-only", but no release carries firmware for it. diff --git a/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840-pinctrl.dtsi b/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840-pinctrl.dtsi index 530c59899..7deeabff6 100644 --- a/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840-pinctrl.dtsi +++ b/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840-pinctrl.dtsi @@ -80,4 +80,20 @@ low-power-enable; }; }; + + /* Heartbeat/TX LED, PWM path (see heltec_t096_nrf52840.dts, pwm_led_white). + * Same P0.28 net as the plain gpio-leds node — free in pinctrl, no + * other peripheral claims it. */ + pwm0_default: pwm0_default { + group1 { + psels = ; + }; + }; + + pwm0_sleep: pwm0_sleep { + group1 { + psels = ; + low-power-enable; + }; + }; }; diff --git a/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840.dts b/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840.dts index 9be10802c..a7ae48725 100644 --- a/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840.dts +++ b/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840.dts @@ -15,6 +15,7 @@ #include #include #include +#include #include "heltec_t096_nrf52840-pinctrl.dtsi" / { @@ -34,6 +35,16 @@ aliases { led0 = &led_white; lora-tx-led = &led_white; + /* Same physical LED (P0.28) as led0/lora-tx-led above, reached + * through the PWM peripheral instead of a raw GPIO write. Both + * ui_common.c (heartbeat/message/shutdown) and ZephyrBoard.cpp + * (LoRa TX flash) prefer this alias over the plain GPIO one when + * it exists, so every event that used to hit led_white now goes + * through the shared brightness in helpers/led_gate.h instead — + * deliberately one dimmer for the whole LED, not a separate + * heartbeat-only control (see helpers/led_gate.h). */ + heartbeat-pwm-led = &pwm_led_white; + lora-tx-pwm-led = &pwm_led_white; sw0 = &user_button; lora0 = &lora; watchdog0 = &wdt0; @@ -51,6 +62,20 @@ }; }; + pwmleds { + compatible = "pwm-leds"; + + /* Same P0.28 net as led_white above, driven by PWM0 channel 0 + * instead. Declaring both DT nodes for one pin is an established + * pattern in this project (Heltec V4.3 does the same for its own + * LED) — only one of the two code paths is ever compiled in for a + * given purpose, selected by which alias exists. */ + pwm_led_white: pwm_led_gpio28 { + label = "White PWM LED"; + pwms = <&pwm0 0 PWM_MSEC(10) PWM_POLARITY_NORMAL>; + }; + }; + buttons: buttons { compatible = "gpio-keys"; @@ -276,6 +301,14 @@ status = "okay"; }; +/* Drives the shared heartbeat/TX LED (P0.28) via pwm_led_white above. */ +&pwm0 { + status = "okay"; + pinctrl-0 = <&pwm0_default>; + pinctrl-1 = <&pwm0_sleep>; + pinctrl-names = "default", "sleep"; +}; + &i2c0 { compatible = "nordic,nrf-twim"; status = "okay"; diff --git a/zephcore/boards/nrf52840/rak4631/board.overlay b/zephcore/boards/nrf52840/rak4631/board.overlay index 02ef9ed06..450f41f23 100644 --- a/zephcore/boards/nrf52840/rak4631/board.overlay +++ b/zephcore/boards/nrf52840/rak4631/board.overlay @@ -228,6 +228,7 @@ date-index = <4>; status-reg = <0x0e>; /* status */ status-mask = <0x01>; /* PORF — power-on reset */ + twelve-hour-bit = [10 02]; /* Control 2 12_24, p. 24 */ /* RV-3028-C7 Application Manual Rev. 1.4, 3.2, p. 12 */ zero-mask = [80 80 c0 f8 c0 e0 00]; /* The RAK12002's backup is a supercapacitor. As the RV-3028-C7 diff --git a/zephcore/cmake/zephcore_board.cmake b/zephcore/cmake/zephcore_board.cmake index 141bd95e5..d314158c9 100644 --- a/zephcore/cmake/zephcore_board.cmake +++ b/zephcore/cmake/zephcore_board.cmake @@ -10,6 +10,7 @@ # ZEPHCORE_BOARD_DIR boards//, or "" if none # ZEPHCORE_PLATFORM nrf52 | nrf54l | esp32 | mg24 | stm32wl | linux | "" # ZEPHCORE_BOARD_LIGHT_SLEEP TRUE if the board manifest declares it +# ZEPHCORE_BOARD_LIGHT_SLEEP_COMPANION TRUE if the board manifest declares it # ZEPHCORE_BOARD_WIFI TRUE if the board manifest declares it # zephcore_board_file( ) # absolute path of inside the board dir, or "". @@ -83,6 +84,7 @@ endif() # this runs before Zephyr has set up Python; `board_manifest.py check` enforces # the exact two-space-indented ` : true|false` form this relies on. set(ZEPHCORE_BOARD_LIGHT_SLEEP FALSE) +set(ZEPHCORE_BOARD_LIGHT_SLEEP_COMPANION FALSE) set(ZEPHCORE_BOARD_WIFI FALSE) zephcore_board_file(_zb_manifest zephcore.yml) if(_zb_manifest) @@ -90,6 +92,10 @@ if(_zb_manifest) if(_zb_ls) set(ZEPHCORE_BOARD_LIGHT_SLEEP TRUE) endif() + file(STRINGS "${_zb_manifest}" _zb_lsc REGEX "^ light_sleep_companion: true[ \t]*$") + if(_zb_lsc) + set(ZEPHCORE_BOARD_LIGHT_SLEEP_COMPANION TRUE) + endif() file(STRINGS "${_zb_manifest}" _zb_wifi REGEX "^ wifi: true[ \t]*$") if(_zb_wifi) set(ZEPHCORE_BOARD_WIFI TRUE) diff --git a/zephcore/dts/bindings/rtc/zephcore,rtc-i2c.yaml b/zephcore/dts/bindings/rtc/zephcore,rtc-i2c.yaml index 505784c50..bcd205459 100644 --- a/zephcore/dts/bindings/rtc/zephcore,rtc-i2c.yaml +++ b/zephcore/dts/bindings/rtc/zephcore,rtc-i2c.yaml @@ -62,6 +62,16 @@ properties: bit 6) rather than a number 0-6. Set it for a chip whose data sheet says so, such as the RX8130CE. + twelve-hour-bit: + type: uint8-array + description: > + [register mask]: a bit outside the time block that reads set in + 12-hour mode, where clearing it makes the chip convert its own + hours to 24-hour, such as the RV3028's Control 2 12_24. Once the + chip is cleanly identified, a set bit is cleared before a time is + taken from it. No time is taken if the register cannot be read or + reads FFh, or if the write or the re-read fails. + rv3028-eeprom-config: type: uint8-array description: > @@ -80,7 +90,8 @@ properties: it, off once the store has disabled it, until a retry or that refresh. The store is retried up to 3 times, 10 minutes apart, identifying the chip again first; a boot whose read could not - identify it cleanly waits for those retries. The time is read at + identify it cleanly, or whose 12-hour clear failed, waits for those + retries. The time is read at boot and written on each sync within 2000-2099 with the chip's backup switch flag cleared before and checked after. A write sets year 00h first and the real year last, so a write cut by a power diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index 47db72f37..90b745555 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -4,6 +4,10 @@ */ #include "CommonCLI.h" +#include "HardwareReport.h" +#if IS_ENABLED(CONFIG_ZEPHCORE_ROLE_COMPANION) +#include "../adapters/transport/companion_framing.h" +#endif #include "battery_curve.h" #include "led_gate.h" #include "buzzer_gate.h" @@ -68,9 +72,11 @@ static const NodePrefs* cliDefaults() { /* ---- leds.radio / leds.hb mode names ----------------------------------- */ /* * Which LEDs this board has, so the CLI can say when a setting it stored - * does nothing here. Mirrors the aliases the drivers use. + * does nothing here. Mirrors the aliases the drivers use: lora-tx-led or + * lora-tx-pwm-led, led0 with an led1 fallback. */ -#define CLI_HAS_RADIO_LED DT_NODE_EXISTS(DT_ALIAS(lora_tx_led)) +#define CLI_HAS_RADIO_LED (DT_NODE_EXISTS(DT_ALIAS(lora_tx_led)) || \ + DT_NODE_EXISTS(DT_ALIAS(lora_tx_pwm_led))) #define CLI_HAS_HB_LED (DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) || \ DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios)) @@ -496,6 +502,19 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch } else { snprintf(reply, CLI_REPLY_SIZE, "File system erase: Err"); } + } else if (memcmp(command, "hw", 2) == 0 && (command[2] == '\0' || command[2] == ' ')) { + /* A companion's local reply may be the app's CLI frame. */ + size_t cap = replyCap(sender_timestamp); +#if IS_ENABLED(CONFIG_ZEPHCORE_ROLE_COMPANION) + if (cap > MAX_FRAME_SIZE - 1) { + cap = MAX_FRAME_SIZE - 1; + } +#endif + /* reply already starts past a local request's "xx|" prefix. */ + if (sender_timestamp == 0) { + cap -= _reply_hdr_used; + } + zephcore_hw::handle(command, reply, cap, _board, _callbacks); } else if (memcmp(command, "ver", 3) == 0) { snprintf(reply, CLI_REPLY_SIZE, "%s (Build: %s)", _callbacks->getFirmwareVer(), _callbacks->getBuildDate()); } else if (memcmp(command, "board", 5) == 0) { @@ -982,8 +1001,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, const char* command, cha 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. */ + * four characters, so "leds.radio"/"leds.hb"/"leds.brightness" all + * match it and would otherwise return the master switch instead. */ if (memcmp(config, "leds.radio", 10) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %s%s", cliModeName(LEDS_RADIO_NAMES, 4, _prefs->leds_radio_mode), @@ -992,6 +1011,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, const char* command, cha snprintf(reply, CLI_REPLY_SIZE, "> %s%s", cliModeName(LEDS_HB_NAMES, 4, _prefs->leds_hb_mode), CLI_HAS_HB_LED ? "" : " (no heartbeat LED on this board)"); + } else if (memcmp(config, "leds.brightness", 15) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %u%%", (unsigned)_prefs->led_brightness); } else if (memcmp(config, "leds", 4) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->leds_disabled ? "off" : "on"); #ifndef ZEPHCORE_REPEATER @@ -1263,10 +1284,24 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, const char* command, cha snprintf(reply, CLI_REPLY_SIZE, "OK%s", CLI_HAS_HB_LED ? "" : " (no heartbeat LED on this board)"); } + } else if (memcmp(config, "leds.brightness ", 16) == 0) { + const char *val = &config[16]; + char *endptr = (char *)val; + long pct = strtol(val, &endptr, 10); + bool trailing_pct = (*endptr == '%' && *(endptr + 1) == '\0'); + if (endptr == val || (*endptr != '\0' && !trailing_pct) || + pct < 0 || pct > 100) { + strcpy(reply, "Error: must be 0-100"); + } else { + _prefs->led_brightness = (uint8_t)pct; + zephcore_led_set_brightness_pct((uint8_t)pct); + savePrefs(); + snprintf(reply, CLI_REPLY_SIZE, "OK - leds.brightness=%ld%%", pct); + } } else if (memcmp(config, "leds ", 5) == 0) { /* Master switch for every LED on the node: heartbeat, unread-message * and LoRa TX activity, plus the message and shutdown flashes. Not - * the display backlight — that has its own UI brightness setting. */ + * the display backlight (that has its own UI brightness setting). */ int on = cliOnOff(&config[5], cliDefaults()->leds_disabled ? 0 : 1); if (on < 0) { strcpy(reply, "Error: must be on, off or default"); diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp new file mode 100644 index 000000000..c3dae2851 --- /dev/null +++ b/zephcore/helpers/HardwareReport.cpp @@ -0,0 +1,618 @@ +/* + * SPDX-License-Identifier: MIT + * `hw` hardware report. See HardwareReport.h. + */ + +#include "HardwareReport.h" + +#include "CommonCLI.h" +#include "boot_info.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +extern "C" { +#include "../adapters/clock/ZephyrRTCDiscover.h" +} + +#include "../adapters/gps/ZephyrGPSManager.h" +#include "../adapters/sensors/ZephyrEnvSensors.h" + +#define HW_GNSS_NODE DT_NODELABEL(gnss) + +/* Candidates declared but compiled out: the discovery stubs report none. */ +#define HW_RTC_DISABLED (!IS_ENABLED(CONFIG_ZEPHCORE_RTC_AUTODISCOVER) && \ + DT_NUM_INST_STATUS_OKAY(zephcore_rtc_i2c) > 0) + +namespace zephcore_hw { +namespace { + +/* Room kept free for the "\n... next:NNNNN" resume marker. */ +#define HW_NEXT_RESERVE 16 +/* Largest resume index the marker holds; the parser clamps to it. */ +#define HW_PAGE_INDEX_MAX 99999U + +/* Paged line sink: every section writes through it, so all page alike. */ +struct Sink { + char *buf; + size_t cap; /* write limit, excluding HW_NEXT_RESERVE */ + size_t len; + unsigned first; /* first line index to emit */ + unsigned line; /* index of the line being considered */ + unsigned next; /* line index that did not fit */ + bool full; +}; + +void sink_init(Sink *s, char *buf, size_t cap, unsigned first) +{ + s->buf = buf; + s->cap = cap - HW_NEXT_RESERVE; + s->len = 0; + s->first = first; + s->line = 0; + s->next = 0; + s->full = false; + buf[0] = '\0'; +} + +/* True if the next line will not be emitted; it is counted as skipped. */ +bool sink_skip(Sink *s) +{ + if (s->full || s->line < s->first) { + s->line++; + return true; + } + return false; +} + +void sink_line(Sink *s, const char *fmt, ...) +{ + if (sink_skip(s)) { + return; + } + unsigned idx = s->line++; + + char line[160]; + va_list ap; + va_start(ap, fmt); + int w = vsnprintf(line, sizeof(line), fmt, ap); + va_end(ap); + if ((size_t)w >= sizeof(line)) { + memcpy(line + sizeof(line) - 4, "...", 3); + } + + size_t need = strlen(line) + (s->len ? 1 : 0); + if (s->len == 0 && need >= s->cap) { + /* A line longer than a page is cut, so a resume moves past it. */ + size_t keep = s->cap - 1; + line[keep] = '\0'; + memcpy(line + keep - 3, "...", 3); + need = keep; + } + if (s->len + need >= s->cap) { + s->full = true; + s->next = idx; + return; + } + + if (s->len) { + s->buf[s->len++] = '\n'; + } + memcpy(s->buf + s->len, line, strlen(line)); + s->len += strlen(line); + s->buf[s->len] = '\0'; +} + +void sink_finish(Sink *s) +{ + if (s->len == 0 && !s->full) { + /* An empty reply reads as an unknown command on a companion. */ + snprintf(s->buf, s->cap + HW_NEXT_RESERVE, "no line %u, the report has %u", + s->first, s->line); + } else if (s->full) { + snprintf(s->buf + s->len, HW_NEXT_RESERVE, "\n... next:%u", s->next); + } +} + +/* ================= devicetree I2C inventory ================= */ + +struct I2cDecl { + const char *bus; + const char *node; + const char *compat; + uint16_t addr; +}; + +/* The bus is named as Zephyr names the device (DEVICE_DT_NAME), so it matches + * the dev->name the scan reports. */ +#define HW_I2C_ENTRY(node_id) \ + { \ + DEVICE_DT_NAME(DT_BUS(node_id)), \ + DT_NODE_FULL_NAME(node_id), \ + DT_PROP_BY_IDX(node_id, compatible, 0), \ + (uint16_t)DT_REG_ADDR(node_id), \ + }, + +/* An enabled, addressable node on an I2C bus. */ +#define HW_I2C_CHIP(node_id) \ + UTIL_AND(DT_ON_BUS(node_id, i2c), \ + UTIL_AND(DT_NODE_HAS_PROP(node_id, reg), \ + DT_NODE_HAS_PROP(node_id, compatible))) + +#define HW_I2C_NODE(node_id) IF_ENABLED(HW_I2C_CHIP(node_id), (HW_I2C_ENTRY(node_id))) + +const I2cDecl i2c_decls[] = { + DT_FOREACH_STATUS_OKAY_NODE(HW_I2C_NODE) + { nullptr, nullptr, nullptr, 0xFFFF }, /* sentinel: never reported */ +}; + +constexpr size_t i2c_decl_count() { return ARRAY_SIZE(i2c_decls) - 1; } + +#if IS_ENABLED(CONFIG_I2C) +/* The controller of each declared device, one entry per device. A bus with no + * declared device is not listed, so it is not scanned. */ +#define HW_I2C_BUS_ENTRY(node_id) DEVICE_DT_GET(DT_BUS(node_id)), + +#define HW_I2C_BUS_NODE(node_id) IF_ENABLED(HW_I2C_CHIP(node_id), (HW_I2C_BUS_ENTRY(node_id))) + +const struct device *const i2c_bus_refs[] = { + DT_FOREACH_STATUS_OKAY_NODE(HW_I2C_BUS_NODE) + nullptr, /* sentinel */ +}; + +constexpr size_t i2c_bus_ref_count() { return ARRAY_SIZE(i2c_bus_refs) - 1; } + +/* The compatibles declared at addr on bus, '|'-separated. False if none, or + * if they do not fit. */ +bool declared_names_at(const char *bus, uint16_t addr, char *out, size_t cap) +{ + size_t used = 0; + + out[0] = '\0'; + for (size_t i = 0; i < i2c_decl_count(); i++) { + if (i2c_decls[i].addr != addr || strcmp(i2c_decls[i].bus, bus) != 0) { + continue; + } + int w = snprintf(out + used, cap - used, "%s%s", used ? "|" : "", + i2c_decls[i].compat); + if ((size_t)w >= cap - used) { + out[0] = '\0'; + return false; + } + used += (size_t)w; + } + return used > 0; +} +#endif /* CONFIG_I2C */ + +/* One snapshot of discovery's outcome, so every line agrees with the others. */ +constexpr size_t kRtcMax = DT_NUM_INST_STATUS_OKAY(zephcore_rtc_i2c) > 0 + ? DT_NUM_INST_STATUS_OKAY(zephcore_rtc_i2c) : 1; + +struct RtcView { + struct zephcore_rtc_entry e[kRtcMax]; + size_t n; + int active; /* index into e, or -1 */ + unsigned unsettled; /* unprobed or all 0xFF */ +}; + +void rtc_view(RtcView *v) +{ + v->n = zephcore_rtc_snapshot(v->e, kRtcMax); + v->active = -1; + v->unsettled = 0; + for (size_t i = 0; i < v->n; i++) { + if (v->e[i].active) { + v->active = (int)i; + } + if (v->e[i].state == ZEPHCORE_RTC_UNPROBED || + v->e[i].state == ZEPHCORE_RTC_ALL_FF) { + v->unsettled++; + } + } +} + +const char *rtc_state_str(enum zephcore_rtc_state st) +{ + switch (st) { + case ZEPHCORE_RTC_PRESENT: return "present"; + case ZEPHCORE_RTC_ABSENT: return "absent"; + case ZEPHCORE_RTC_ALL_FF: return "all 0xff"; + default: return "unprobed"; + } +} + +/* The boot reset cause labels, without hints. False if the platform cannot + * report a cause. */ +bool reset_causes(uint32_t *cause, char *out, size_t cap) +{ + if (!zephcore_boot_reset_cause(cause)) { + return false; + } + zephcore_boot_reset_cause_str(out, cap, false); + return true; +} + +/* ================= sections ================= */ + +void section_board(Sink *s, mesh::MainBoard *board, CommonCLICallbacks *cb) +{ + sink_line(s, "board: %s", CONFIG_BOARD_TARGET); + sink_line(s, "name: %s", board->getManufacturerName()); + sink_line(s, "soc: %s", CONFIG_SOC); + sink_line(s, "zephyr: %s", KERNEL_VERSION_STRING); + sink_line(s, "fw: %s (%s)", cb->getFirmwareVer(), cb->getBuildDate()); + sink_line(s, "role: %s", cb->getRole()); + + char bl[48]; + if (board->getBootloaderVersion(bl, sizeof(bl))) { + sink_line(s, "bootloader: %s", bl); + } + + uint32_t cause; + char causes[128]; /* every label without hints, per boot_info.h */ + if (reset_causes(&cause, causes, sizeof(causes))) { + sink_line(s, "reset: 0x%08x%s", cause, + causes[0] ? causes : " (none reported)"); + } else { + sink_line(s, "reset: not supported"); + } + + uint8_t devid[16]; + ssize_t n = hwinfo_get_device_id(devid, sizeof(devid)); + if (n > 0) { + char hex[sizeof(devid) * 2 + 1]; + mesh::Utils::toHex(hex, devid, (size_t)n); + sink_line(s, "devid: %s", hex); + } else { + sink_line(s, "devid: not supported"); + } +} + +void section_rtc(Sink *s) +{ + if (HW_RTC_DISABLED) { + sink_line(s, "rtc: %u declared, autodiscovery disabled", + (unsigned)DT_NUM_INST_STATUS_OKAY(zephcore_rtc_i2c)); + return; + } + + size_t declared = zephcore_rtc_declared(); + + if (declared == 0) { + sink_line(s, "rtc: none declared in devicetree"); + return; + } + if (!zephcore_rtc_probed()) { + sink_line(s, "rtc: %u declared, not yet probed", (unsigned)declared); + return; + } + + RtcView v; + + rtc_view(&v); + if (v.active >= 0) { + const struct zephcore_rtc_entry *a = &v.e[v.active]; + + sink_line(s, "rtc: %s at 0x%02x on %s", a->name, a->addr, a->bus); + } else if (v.unsettled > 0) { + sink_line(s, "rtc: none found, %u of %u declared unsettled", + v.unsettled, (unsigned)declared); + } else { + sink_line(s, "rtc: none present (%u declared)", (unsigned)declared); + } + + for (size_t i = 0; i < v.n; i++) { + const struct zephcore_rtc_entry *e = &v.e[i]; + + sink_line(s, " 0x%02x %s %s%s", e->addr, e->name, + rtc_state_str(e->state), e->active ? " *" : ""); + } +} + +void section_i2c(Sink *s) +{ + sink_line(s, "i2c: %u declared (devicetree, not a bus scan)", + (unsigned)i2c_decl_count()); + for (size_t i = 0; i < i2c_decl_count(); i++) { + sink_line(s, " %s 0x%02x %s", i2c_decls[i].bus, + i2c_decls[i].addr, i2c_decls[i].compat); + } +} + +void section_i2c_scan(Sink *s) +{ +#if !IS_ENABLED(CONFIG_I2C) + sink_line(s, "i2c: no I2C support compiled in"); +#else + unsigned buses = 0; + + for (size_t bi = 0; bi < i2c_bus_ref_count(); bi++) { + const struct device *bus = i2c_bus_refs[bi]; + + bool seen = false; + for (size_t j = 0; j < bi; j++) { + if (i2c_bus_refs[j] == bus) { + seen = true; + break; + } + } + if (seen) { + continue; + } + buses++; + + if (!device_is_ready(bus)) { + sink_line(s, "%s: not ready", bus->name); + continue; + } + + unsigned found = 0; + bool stuck = false; + char line[128]; + const int header = snprintf(line, sizeof(line), "%s:", bus->name); + int used = header; + + /* A one-byte read probes each address: nothing is written to a + * device that has not been identified. */ + for (uint16_t addr = 0x08; addr <= 0x77; addr++) { + uint8_t probe; + int rc = i2c_read(bus, &probe, 1, addr); + + if (rc == -ETIMEDOUT || rc == -EBUSY) { + /* A dead or held bus would take the driver's timeout + * on every address. */ + if (used > header) { + sink_line(s, "%s", line); + used = header; + } + sink_line(s, "%s: bus not responding at 0x%02x (err %d), scan stopped", + bus->name, addr, rc); + stuck = true; + break; + } + if (rc != 0) { + continue; + } + found++; + + char nm[64]; + char tok[72]; + int tw = declared_names_at(bus->name, addr, nm, sizeof(nm)) + ? snprintf(tok, sizeof(tok), " 0x%02x(%s)", addr, nm) + : snprintf(tok, sizeof(tok), " 0x%02x", addr); + + if ((size_t)(used + tw) >= sizeof(line) && used > header) { + sink_line(s, "%s", line); + used = header; + } + memcpy(line + used, tok, (size_t)tw + 1); + used += tw; + } + + if (found == 0) { + if (!stuck) { + sink_line(s, "%s: none found", bus->name); + } + } else if (used > header) { + sink_line(s, "%s", line); + } + } + + if (buses == 0) { + sink_line(s, "i2c: no bus carries a declared device, none scanned"); + } +#endif /* CONFIG_I2C */ +} + +void section_gps(Sink *s) +{ +#if DT_NODE_EXISTS(HW_GNSS_NODE) && !DT_NODE_HAS_STATUS(HW_GNSS_NODE, okay) + sink_line(s, "gnss: %s declared but disabled in devicetree", + DT_PROP_BY_IDX(HW_GNSS_NODE, compatible, 0)); +#elif DT_NODE_EXISTS(HW_GNSS_NODE) + sink_line(s, "gnss: %s", DT_PROP_BY_IDX(HW_GNSS_NODE, compatible, 0)); + sink_line(s, " on %s", DT_NODE_FULL_NAME(DT_PARENT(HW_GNSS_NODE))); +#if DT_NODE_HAS_PROP(DT_PARENT(HW_GNSS_NODE), current_speed) + sink_line(s, " baud %u", (unsigned)DT_PROP(DT_PARENT(HW_GNSS_NODE), current_speed)); +#endif + /* The generic NMEA driver drives any receiver and names no part. */ +#if DT_NODE_HAS_COMPAT(HW_GNSS_NODE, gnss_nmea_generic) + sink_line(s, " model: not identified (generic NMEA driver)"); +#endif +#else + sink_line(s, "gnss: none declared in devicetree"); +#endif + + sink_line(s, " available: %s", gps_is_available() ? "yes" : "no"); + sink_line(s, " enabled: %s", gps_is_enabled() ? "yes" : "no"); +} + +void section_sensors(Sink *s) +{ +#if IS_ENABLED(CONFIG_SENSOR) + int n = env_sensor_count(); + + if (n <= 0) { + sink_line(s, "sensors: none found"); + return; + } + sink_line(s, "sensors: %d found", n); + + for (int i = 0; i < n; i++) { + struct env_sensor_reading r; + + /* Read only a sensor whose line this page emits. */ + if (sink_skip(s)) { + continue; + } + if (env_sensor_read(i, &r) != 0) { + sink_line(s, " %d: read failed", i); + continue; + } + sink_line(s, " %d:%s%s%s%s%s%s%s%s%s", i, + (r.fields & ENV_F_TEMPERATURE) ? " temp" : "", + (r.fields & ENV_F_HUMIDITY) ? " hum" : "", + (r.fields & ENV_F_PRESSURE) ? " press" : "", + (r.fields & ENV_F_ALTITUDE) ? " alt" : "", + (r.fields & ENV_F_LUMINOSITY) ? " light" : "", + (r.fields & ENV_F_VOLTAGE) ? " volt" : "", + (r.fields & ENV_F_CURRENT) ? " curr" : "", + (r.fields & ENV_F_POWER) ? " power" : "", + env_sensor_is_board_local(i) ? " (board)" : ""); + } +#else + sink_line(s, "sensors: not compiled in"); +#endif +} + +void section_summary(Sink *s, CommonCLICallbacks *cb) +{ + sink_line(s, "%s (%s)", CONFIG_BOARD, CONFIG_SOC); + sink_line(s, "fw %s role %s", cb->getFirmwareVer(), cb->getRole()); + + RtcView v; + + rtc_view(&v); + if (v.active >= 0) { + /* The node's full name already ends in "@". */ + sink_line(s, "rtc %s", v.e[v.active].name); + } else if (HW_RTC_DISABLED) { + sink_line(s, "rtc autodiscovery disabled"); + } else if (zephcore_rtc_declared() == 0) { + sink_line(s, "rtc none declared"); + } else if (!zephcore_rtc_probed()) { + sink_line(s, "rtc not yet probed"); + } else if (v.unsettled > 0) { + sink_line(s, "rtc none found, %u unsettled", v.unsettled); + } else { + sink_line(s, "rtc none present"); + } + +#if DT_NODE_EXISTS(HW_GNSS_NODE) && !DT_NODE_HAS_STATUS(HW_GNSS_NODE, okay) + sink_line(s, "gnss %s disabled", DT_PROP_BY_IDX(HW_GNSS_NODE, compatible, 0)); +#elif DT_NODE_EXISTS(HW_GNSS_NODE) + sink_line(s, "gnss %s", DT_PROP_BY_IDX(HW_GNSS_NODE, compatible, 0)); +#else + sink_line(s, "gnss none"); +#endif + + sink_line(s, "i2c %u declared", (unsigned)i2c_decl_count()); + + uint32_t cause; + char causes[128]; + if (reset_causes(&cause, causes, sizeof(causes))) { + sink_line(s, "reset%s", causes[0] ? causes : " none"); + } else { + sink_line(s, "reset not supported"); + } +} + +/* The rest of arg after `name` as a whole word, or nullptr. */ +const char *match_word(const char *arg, const char *name) +{ + size_t n = strlen(name); + + if (strncmp(arg, name, n) != 0) { + return nullptr; + } + if (arg[n] != '\0' && arg[n] != ' ') { + return nullptr; + } + return arg + n; +} + +/* Parse the optional trailing page index. False unless the tail is empty or + * a plain number. */ +bool parse_tail(const char *rest, unsigned *start) +{ + *start = 0; + + while (*rest == ' ') { + rest++; + } + if (*rest == '\0') { + return true; + } + if (*rest < '0' || *rest > '9') { + return false; + } + + char *end = nullptr; + unsigned long v = strtoul(rest, &end, 10); + + while (*end == ' ') { + end++; + } + if (*end != '\0') { + return false; + } + + *start = (v > HW_PAGE_INDEX_MAX) ? HW_PAGE_INDEX_MAX : (unsigned)v; + return true; +} + +} /* namespace */ + +void handle(const char *command, char *reply, size_t cap, + mesh::MainBoard *board, CommonCLICallbacks *callbacks) +{ + const char *arg = command + 2; /* past "hw" */ + while (*arg == ' ') { + arg++; + } + + Sink s; + const char *rest; + unsigned start = 0; + + if (*arg == '\0' || ((*arg >= '0' && *arg <= '9') && parse_tail(arg, &start))) { + sink_init(&s, reply, cap, start); + section_summary(&s, callbacks); + } else if ((rest = match_word(arg, "board")) && parse_tail(rest, &start)) { + sink_init(&s, reply, cap, start); + section_board(&s, board, callbacks); + } else if ((rest = match_word(arg, "rtc")) && parse_tail(rest, &start)) { + sink_init(&s, reply, cap, start); + section_rtc(&s); + } else if ((rest = match_word(arg, "i2c scan")) && parse_tail(rest, &start)) { + sink_init(&s, reply, cap, start); + section_i2c_scan(&s); + } else if ((rest = match_word(arg, "i2c")) && parse_tail(rest, &start)) { + sink_init(&s, reply, cap, start); + section_i2c(&s); + } else if ((rest = match_word(arg, "gps")) && parse_tail(rest, &start)) { + sink_init(&s, reply, cap, start); + section_gps(&s); + } else if ((rest = match_word(arg, "sensors")) && parse_tail(rest, &start)) { + sink_init(&s, reply, cap, start); + section_sensors(&s); + } else if ((rest = match_word(arg, "all")) && parse_tail(rest, &start)) { + sink_init(&s, reply, cap, start); + section_board(&s, board, callbacks); + section_rtc(&s); + section_i2c(&s); + section_gps(&s); + section_sensors(&s); + } else { + snprintf(reply, cap, + "usage: hw [board|rtc|i2c [scan]|gps|sensors|all] [start]"); + return; + } + + sink_finish(&s); +} + +} /* namespace zephcore_hw */ diff --git a/zephcore/helpers/HardwareReport.h b/zephcore/helpers/HardwareReport.h new file mode 100644 index 000000000..20f02dd83 --- /dev/null +++ b/zephcore/helpers/HardwareReport.h @@ -0,0 +1,26 @@ +/* + * SPDX-License-Identifier: MIT + * + * `hw`: what this firmware is and what hardware it found. Each line reports + * what the code established: a driver names itself, and an unprobed part is + * reported as unprobed, not absent. + */ + +#pragma once + +#include + +namespace mesh { +class MainBoard; +} + +class CommonCLICallbacks; + +namespace zephcore_hw { + +/* Write the reply for `command`, the CLI line starting at "hw", into reply: + * at most cap bytes, terminator included. */ +void handle(const char *command, char *reply, size_t cap, + mesh::MainBoard *board, CommonCLICallbacks *callbacks); + +} /* namespace zephcore_hw */ diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index c5e2b289b..a9816f197 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -96,6 +96,7 @@ struct NodePrefs { uint8_t leds_disabled; // 1 = all LEDs off (heartbeat, unread, LoRa TX) uint8_t leds_radio_mode; // LEDS_RADIO_* — activity LED source (0 = TX, as before) uint8_t leds_hb_mode; // LEDS_HB_* — heartbeat LED behaviour (0 = all, as before) + uint8_t led_brightness; // 0-100, PWM-capable boards only; ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT on a new node // Power saving uint8_t powersaving_enabled; /* 1 = powersaving_enabled is a real choice. The field was stored but @@ -237,10 +238,12 @@ static inline void auto_shutdown_upgrade(NodePrefs *p) { } /* `powersaving` gates ESP32 light sleep (helpers/pm_esp32_wake.c). On a - * light-sleep build the default is on: those builds slept unconditionally - * before the switch existed, and the stored 0 carried no intent. Elsewhere - * it is upstream's default, off. */ -#if defined(CONFIG_PM) && defined(CONFIG_SOC_FAMILY_ESPRESSIF_ESP32) + * light-sleep repeater or room server the default is on: those builds slept + * unconditionally before the switch existed, and the stored 0 carried no + * intent. Elsewhere, light-sleep companions included, it is upstream's + * default, off. */ +#if defined(CONFIG_PM) && defined(CONFIG_SOC_FAMILY_ESPRESSIF_ESP32) && \ + !defined(CONFIG_ZEPHCORE_ROLE_COMPANION) #define POWERSAVING_DEFAULT 1 #else #define POWERSAVING_DEFAULT 0 @@ -306,6 +309,7 @@ static inline void sanitizeNodePrefs(NodePrefs* p) { p->leds_disabled = saneBool(p->leds_disabled, 0); p->leds_radio_mode = saneEnum(p->leds_radio_mode, LEDS_RADIO_MAX); p->leds_hb_mode = saneEnum(p->leds_hb_mode, LEDS_HB_MAX); + p->led_brightness = clampPref(p->led_brightness, 0, 100); p->meshtimesync = saneBool(p->meshtimesync, 0); /* Fallback must be the initNodePrefs() default (ON). It was 0, so a byte * that was neither 0 nor 1 silently switched adaptive CAD off instead of @@ -414,6 +418,7 @@ 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->led_brightness = ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT; // 100%, new node prefs->powersaving_enabled = POWERSAVING_DEFAULT; prefs->powersaving_set = 1; prefs->gps_enabled = 0; diff --git a/zephcore/helpers/PrefsJson.cpp b/zephcore/helpers/PrefsJson.cpp index b5bdfa30f..960d19c33 100644 --- a/zephcore/helpers/PrefsJson.cpp +++ b/zephcore/helpers/PrefsJson.cpp @@ -61,6 +61,7 @@ class ZcCommonJson : public ConfigSerializer { def("leds_off", _p->leds_disabled); def("leds_radio", _p->leds_radio_mode); def("leds_hb", _p->leds_hb_mode); + def("leds_brightness", _p->led_brightness); def("rx_dc", _p->rx_duty_cycle); def("mts", _p->meshtimesync); def("cad_auto", _p->cad_auto); diff --git a/zephcore/helpers/boot_prefs.h b/zephcore/helpers/boot_prefs.h index cb90e19f6..85ab415bf 100644 --- a/zephcore/helpers/boot_prefs.h +++ b/zephcore/helpers/boot_prefs.h @@ -26,6 +26,7 @@ static inline void apply_boot_prefs(const NodePrefs *p, bool gps_time_sync) zephcore_leds_set_disabled(leds_off); zephcore_leds_set_radio_mode(p->leds_radio_mode); zephcore_leds_set_hb_mode(p->leds_hb_mode); + zephcore_led_set_brightness_pct(p->led_brightness); LOG_INF("LEDs: %s (from prefs)", leds_off ? "disabled" : "enabled"); if (gps_time_sync && gps_is_available()) { diff --git a/zephcore/helpers/led_gate.c b/zephcore/helpers/led_gate.c index 610bbb68a..ce8e9b1f5 100644 --- a/zephcore/helpers/led_gate.c +++ b/zephcore/helpers/led_gate.c @@ -6,6 +6,7 @@ #include "led_gate.h" +#include #include #include @@ -74,3 +75,26 @@ void zephcore_led_radio_hold_pin(bool held) { atomic_set(&s_radio_holds_pin, held ? 1 : 0); } + +static atomic_t s_led_brightness_pct = ATOMIC_INIT(ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT); + +uint8_t zephcore_led_brightness_pct(void) +{ + return (uint8_t)atomic_get(&s_led_brightness_pct); +} + +void zephcore_led_set_brightness_pct(uint8_t pct) +{ + if (pct > 100) { + pct = 100; + } + atomic_set(&s_led_brightness_pct, pct); +} + +void zephcore_led_pwm_write(const struct pwm_dt_spec *led, bool on) +{ + uint32_t pulse = on ? (uint32_t)((uint64_t)led->period * + zephcore_led_brightness_pct() / 100) + : 0; + pwm_set_pulse_dt(led, pulse); +} diff --git a/zephcore/helpers/led_gate.h b/zephcore/helpers/led_gate.h index 726dfacfc..eeb8c9ee5 100644 --- a/zephcore/helpers/led_gate.h +++ b/zephcore/helpers/led_gate.h @@ -34,6 +34,18 @@ bool zephcore_leds_disabled(void); * progress. Safe to call from any role, with or without a UI. */ void zephcore_leds_set_disabled(bool disabled); +/* Shared PWM brightness (0-100) for PWM-capable heartbeat/TX LEDs. Persisted + * as prefs.led_brightness; the default only applies to a node with no saved + * value. Independent of the on/off gate above. */ +#define ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT 100 + +uint8_t zephcore_led_brightness_pct(void); +void zephcore_led_set_brightness_pct(uint8_t pct); + +struct pwm_dt_spec; +/* Drive a PWM LED at the shared brightness when on, dark when off. */ +void zephcore_led_pwm_write(const struct pwm_dt_spec *led, bool on); + /* Called by zephcore_leds_set_disabled() after the flag changes. Weak no-op in * led_gate.c; helpers/ui/ui_common.c overrides it to stop/restart the heartbeat * cycle and refresh the UI's LED page. Not meant to be called directly. */ diff --git a/zephcore/helpers/pm_esp32_wake.c b/zephcore/helpers/pm_esp32_wake.c index a186ec85c..88d8736ee 100644 --- a/zephcore/helpers/pm_esp32_wake.c +++ b/zephcore/helpers/pm_esp32_wake.c @@ -46,8 +46,15 @@ #include #include #include +#include #if defined(CONFIG_WIFI_ESP32) #include +#include +#endif + +/* The WiFi MAC keeps its own beacon timer (TSF) and wakes the SoC with it. */ +#if defined(CONFIG_WIFI_ESP32) && SOC_WIFI_HW_TSF +#define ZC_PM_WIFI_TSF 1 #endif #include @@ -96,6 +103,7 @@ static struct { uint32_t slept; /* ... and the SoC actually slept */ uint32_t wake_timer; uint32_t wake_gpio; + uint32_t wake_wifi; /* the WiFi MAC, for a beacon */ uint32_t wake_other; uint64_t asleep_us; } stats; @@ -146,6 +154,8 @@ static void wake_state_exit(enum pm_state state) stats.wake_gpio++; } else if (causes & BIT(ESP_SLEEP_WAKEUP_TIMER)) { stats.wake_timer++; + } else if (causes & BIT(ESP_SLEEP_WAKEUP_WIFI)) { + stats.wake_wifi++; } else { stats.wake_other++; } @@ -223,11 +233,11 @@ int zc_pm_format_stats(char *buf, size_t len) uint32_t pct10 = up_ms ? (uint32_t)(stats.asleep_us / up_ms) : 0; /* 0.1 % units */ uint32_t win_s = zc_pm_console_window_remaining_ms() / 1000U; int n = snprintf(buf, len, - "> %s asleep %u.%u%% sleeps %u/%u wake t%u g%u o%u", + "> %s asleep %u.%u%% sleeps %u/%u wake t%u g%u w%u o%u", atomic_get(&powersave_blocked) ? "off" : "on", pct10 / 10U, pct10 % 10U, stats.slept, stats.entries, - stats.wake_timer, stats.wake_gpio, stats.wake_other); + stats.wake_timer, stats.wake_gpio, stats.wake_wifi, stats.wake_other); ARRAY_FOR_EACH_PTR(wake_pins, p) { if (n > 0 && (size_t)n < len) { @@ -240,6 +250,19 @@ int zc_pm_format_stats(char *buf, size_t len) return (n > 0 && (size_t)n < len) ? n : (int)strlen(buf); } +/* ========== WiFi station ========== */ + +#if defined(ZC_PM_WIFI_TSF) +static atomic_t wifi_station_started; +#endif + +void zc_pm_wifi_station_started(void) +{ +#if defined(ZC_PM_WIFI_TSF) + atomic_set(&wifi_station_started, 1); +#endif +} + /* ========== esp_timer deadlines bound the sleep ========== */ #if defined(CONFIG_PM_CUSTOM_TICKS_HOOK) @@ -248,9 +271,20 @@ int zc_pm_format_stats(char *buf, size_t len) * would otherwise be slept through — the BLE controller's modem-sleep wake * timer ("btSlp") is the one that matters: miss it and the controller misses * its connection event. ESP-IDF's own tickless idle bounds the sleep the same - * way (esp_pm/pm_impl.c). */ + * way (esp_pm/pm_impl.c). + * + * A WiFi station in power save gives up the WiFi library's sleep lock between + * beacons a moment before the MAC's beacon timer (TSF) goes idle. No sleep in + * that moment, as in ESP-IDF: 0 ticks means "an event is due now" + * (devdocs/lld/13-power-management.md, section 12). */ int64_t pm_policy_next_custom_ticks(void) { +#if defined(ZC_PM_WIFI_TSF) + if (atomic_get(&wifi_station_started) && esp_wifi_internal_is_tsf_active()) { + return 0; + } +#endif + int64_t next = esp_timer_get_next_alarm_for_wake_up(); if (next == INT64_MAX) { @@ -287,7 +321,8 @@ static int pm_wake_init(void) #if defined(CONFIG_WIFI_ESP32) /* The ESP32 WiFi driver starts WiFi at boot, in NULL mode, "to enable - * coexistence". Started, the WiFi library holds its modem PM lock + * coexistence". In NULL mode the WiFi radio stays on, the WiFi library + * holds its modem PM lock for as long as it is * (pm_policy_state_all_lock_get via esp_pm) and the SoC never * light-sleeps: measured on the XIAO repeater, zero sleep entries with * WiFi compiled in, sleeps without it. Stop it here. Nothing is lost: diff --git a/zephcore/helpers/pm_sleep_guard.h b/zephcore/helpers/pm_sleep_guard.h index dcce2c1fb..85758901c 100644 --- a/zephcore/helpers/pm_sleep_guard.h +++ b/zephcore/helpers/pm_sleep_guard.h @@ -60,6 +60,10 @@ uint32_t zc_pm_console_window_remaining_ms(void); /* One `get pm` line into buf; returns its length. */ int zc_pm_format_stats(char *buf, size_t len); +/* A WiFi station is in use from now on: no light sleep while the WiFi MAC's + * beacon timer (TSF) is active. */ +void zc_pm_wifi_station_started(void); + #ifdef __cplusplus } #endif @@ -69,6 +73,7 @@ int zc_pm_format_stats(char *buf, size_t len); #else static inline void zc_pm_set_powersaving(bool on) { (void)on; } +static inline void zc_pm_wifi_station_started(void) { } static inline void zc_pm_console_window_open(void) { } static inline uint32_t zc_pm_console_window_remaining_ms(void) { return 0; } static inline int zc_pm_format_stats(char *buf, size_t len) { (void)buf; (void)len; return 0; } diff --git a/zephcore/helpers/ui/display.c b/zephcore/helpers/ui/display.c index 84eefe65e..c043d2f7e 100644 --- a/zephcore/helpers/ui/display.c +++ b/zephcore/helpers/ui/display.c @@ -16,6 +16,7 @@ #include "display.h" #include "doom_game.h" +#include "pm_sleep_guard.h" #include #include @@ -47,6 +48,21 @@ LOG_MODULE_REGISTER(zephcore_display, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL); static const struct device *disp_dev; static bool disp_on; + +/* The display goes on or off. No SoC light sleep while it is on: someone is + * using the UI (devdocs/lld/13-power-management.md, section 13). */ +static void disp_set_on(bool on) +{ + if (on == disp_on) { + return; + } + disp_on = on; + if (on) { + zc_pm_block_sleep(); + } else { + zc_pm_unblock_sleep(); + } +} static bool disp_initialized; static bool disp_rotated; /* panel flipped 180 degrees (upside-down mount) */ @@ -375,7 +391,7 @@ static void auto_off_handler(struct k_work *work) * mc_display_on() → backlight restore. */ if (is_epd) { backlight_set(false); - disp_on = false; + disp_set_on(false); return; } if (disp_on) { @@ -546,7 +562,7 @@ int mc_display_init(void) } display_blanking_off(disp_dev); backlight_set(true); - disp_on = true; + disp_set_on(true); disp_initialized = true; /* Set up auto-off timer and schedule initial timeout */ @@ -606,7 +622,7 @@ void mc_display_on(void) if (!is_epd) { display_blanking_off(disp_dev); } - disp_on = true; + disp_set_on(true); } backlight_set(true); @@ -622,7 +638,7 @@ void mc_display_off(void) if (disp_on) { display_blanking_on(disp_dev); backlight_set(false); - disp_on = false; + disp_set_on(false); } } @@ -908,7 +924,7 @@ void mc_display_epd_full_reset(void) /* After splash handoff, keep frontlight off; next user interaction * wakes it via mc_display_on(). */ backlight_set(false); - disp_on = false; + disp_set_on(false); } const struct device *mc_display_get_device(void) diff --git a/zephcore/helpers/ui/ui_common.c b/zephcore/helpers/ui/ui_common.c index ca7865d87..a54f90675 100644 --- a/zephcore/helpers/ui/ui_common.c +++ b/zephcore/helpers/ui/ui_common.c @@ -21,6 +21,7 @@ #include "led_gate.h" /* shared with the LoRa TX LED */ #include +#include #include #include #include @@ -76,21 +77,52 @@ void ui_play_startup_chime(void) * messages; with both LEDs present, companions light led1 for unread. */ -#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) +/* PWM takes priority over the plain led0/led1 fallback. */ +#if DT_NODE_EXISTS(DT_ALIAS(heartbeat_pwm_led)) +static const struct pwm_dt_spec s_heartbeat_led_pwm = + PWM_DT_SPEC_GET(DT_ALIAS(heartbeat_pwm_led)); +#define HAS_HEARTBEAT_LED_PWM 1 +#define HAS_HEARTBEAT_LED 0 +#elif DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) static const struct gpio_dt_spec s_heartbeat_led = GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios); +#define HAS_HEARTBEAT_LED_PWM 0 #define HAS_HEARTBEAT_LED 1 #elif DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios) static const struct gpio_dt_spec s_heartbeat_led = GPIO_DT_SPEC_GET(DT_ALIAS(led1), gpios); +#define HAS_HEARTBEAT_LED_PWM 0 #define HAS_HEARTBEAT_LED 1 #else +#define HAS_HEARTBEAT_LED_PWM 0 #define HAS_HEARTBEAT_LED 0 #endif +#define HAS_ANY_HEARTBEAT_LED (HAS_HEARTBEAT_LED_PWM || HAS_HEARTBEAT_LED) + +#if HAS_HEARTBEAT_LED_PWM +static inline bool heartbeat_led_is_ready(void) +{ + return pwm_is_ready_dt(&s_heartbeat_led_pwm); +} +static inline void heartbeat_led_write(bool on) +{ + zephcore_led_pwm_write(&s_heartbeat_led_pwm, on); +} +#elif HAS_HEARTBEAT_LED +static inline bool heartbeat_led_is_ready(void) +{ + return gpio_is_ready_dt(&s_heartbeat_led); +} +static inline void heartbeat_led_write(bool on) +{ + gpio_pin_set_dt(&s_heartbeat_led, on ? 1 : 0); +} +#endif + /* Second LED for unread-message indication. Repeaters use led1 for LoRa TX * (via lora-tx-led alias) — no offline queue, so this is companion-only. */ -#if HAS_HEARTBEAT_LED && DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) && \ +#if HAS_ANY_HEARTBEAT_LED && DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) && \ DT_NODE_HAS_PROP(DT_ALIAS(led1), gpios) && !defined(ZEPHCORE_REPEATER) static const struct gpio_dt_spec s_msg_led = GPIO_DT_SPEC_GET(DT_ALIAS(led1), gpios); @@ -103,7 +135,7 @@ static const struct gpio_dt_spec s_msg_led = #define LED_ON_MS 20 /* Normal pulse width */ #define LED_ON_MSG_MS 200 /* Pulse width when unread messages */ -#if HAS_HEARTBEAT_LED +#if HAS_ANY_HEARTBEAT_LED static struct k_work_delayable s_led_on_work; static struct k_work_delayable s_led_off_work; @@ -143,9 +175,11 @@ static void led_off_work_handler(struct k_work *work) ARG_UNUSED(work); /* Yield the pin if radio activity is holding it (shared-pin boards only; * everywhere else this always reads false). Clearing here would blank the - * LED in the middle of a transmit. */ + * LED in the middle of a transmit. Routed through heartbeat_led_write() + * rather than a direct gpio_pin_set_dt(), since s_heartbeat_led doesn't + * even exist on PWM-capable boards (see HAS_HEARTBEAT_LED_PWM above). */ if (!zephcore_led_radio_holds_pin()) { - gpio_pin_set_dt(&s_heartbeat_led, 0); + heartbeat_led_write(false); } #if HAS_MSG_LED gpio_pin_set_dt(&s_msg_led, 0); @@ -163,7 +197,7 @@ static void led_on_work_handler(struct k_work *work) if (!zephcore_leds_disabled()) { if (hb_should_light(mc) && !zephcore_led_radio_holds_pin()) { - gpio_pin_set_dt(&s_heartbeat_led, 1); + heartbeat_led_write(true); } #if HAS_MSG_LED /* The unread LED is a separate pin, so it is governed by the mode @@ -177,7 +211,7 @@ static void led_on_work_handler(struct k_work *work) } k_work_reschedule(&s_led_off_work, K_MSEC(on_ms)); } -#endif /* HAS_HEARTBEAT_LED */ +#endif /* HAS_ANY_HEARTBEAT_LED */ /* * Weak: called after s_leds_disabled changes so each UI variant can sync its @@ -188,9 +222,13 @@ __attribute__((weak)) void ui_led_on_disabled_changed(bool disabled) { ARG_UNUSE void ui_led_heartbeat_init(void) { +#if HAS_ANY_HEARTBEAT_LED + if (heartbeat_led_is_ready()) { #if HAS_HEARTBEAT_LED - if (gpio_is_ready_dt(&s_heartbeat_led)) { gpio_pin_configure_dt(&s_heartbeat_led, GPIO_OUTPUT_INACTIVE); +#else + heartbeat_led_write(false); +#endif k_work_init_delayable(&s_led_on_work, led_on_work_handler); k_work_init_delayable(&s_led_off_work, led_off_work_handler); k_work_reschedule(&s_led_on_work, K_NO_WAIT); @@ -207,15 +245,15 @@ void ui_led_heartbeat_init(void) void ui_set_heartbeat_led(bool enabled) { -#if HAS_HEARTBEAT_LED +#if HAS_ANY_HEARTBEAT_LED if (enabled && !zephcore_leds_disabled()) { - if (gpio_is_ready_dt(&s_heartbeat_led)) { + if (heartbeat_led_is_ready()) { k_work_reschedule(&s_led_on_work, K_NO_WAIT); } } else { k_work_cancel_delayable(&s_led_on_work); k_work_cancel_delayable(&s_led_off_work); - gpio_pin_set_dt(&s_heartbeat_led, 0); + heartbeat_led_write(false); #if HAS_MSG_LED gpio_pin_set_dt(&s_msg_led, 0); #endif @@ -233,18 +271,18 @@ void ui_set_heartbeat_led(bool enabled) */ void zephcore_leds_ui_sync(bool disabled) { -#if HAS_HEARTBEAT_LED +#if HAS_ANY_HEARTBEAT_LED if (disabled) { k_work_cancel_delayable(&s_led_on_work); k_work_cancel_delayable(&s_led_off_work); - gpio_pin_set_dt(&s_heartbeat_led, 0); + heartbeat_led_write(false); #if HAS_MSG_LED gpio_pin_set_dt(&s_msg_led, 0); #endif } else if (!k_work_delayable_is_pending(&s_led_on_work) && !k_work_delayable_is_pending(&s_led_off_work)) { /* Restart heartbeat only if it was stopped (avoids spurious pulse) */ - if (gpio_is_ready_dt(&s_heartbeat_led)) { + if (heartbeat_led_is_ready()) { k_work_reschedule(&s_led_on_work, K_NO_WAIT); } } @@ -268,11 +306,11 @@ void ui_set_leds_disabled(bool disabled) * No-op when LEDs are disabled or hardware is absent. */ void ui_led_flash_msg(void) { -#if HAS_HEARTBEAT_LED - if (!zephcore_leds_disabled() && gpio_is_ready_dt(&s_heartbeat_led)) { +#if HAS_ANY_HEARTBEAT_LED + if (!zephcore_leds_disabled() && heartbeat_led_is_ready()) { k_work_cancel_delayable(&s_led_on_work); k_work_cancel_delayable(&s_led_off_work); - gpio_pin_set_dt(&s_heartbeat_led, 1); + heartbeat_led_write(true); k_work_reschedule(&s_led_off_work, K_MSEC(LED_ON_MSG_MS)); } #endif @@ -284,12 +322,12 @@ void ui_led_flash_msg(void) * even at power-off. */ void ui_led_flash_shutdown(void) { -#if HAS_HEARTBEAT_LED - if (!zephcore_leds_disabled() && gpio_is_ready_dt(&s_heartbeat_led)) { +#if HAS_ANY_HEARTBEAT_LED + if (!zephcore_leds_disabled() && heartbeat_led_is_ready()) { for (int i = 0; i < 3; i++) { - gpio_pin_set_dt(&s_heartbeat_led, 1); + heartbeat_led_write(true); k_sleep(K_MSEC(100)); - gpio_pin_set_dt(&s_heartbeat_led, 0); + heartbeat_led_write(false); if (i < 2) { k_sleep(K_MSEC(100)); } diff --git a/zephcore/scripts/board_manifest.py b/zephcore/scripts/board_manifest.py index 87cd3396d..eb53b40eb 100644 --- a/zephcore/scripts/board_manifest.py +++ b/zephcore/scripts/board_manifest.py @@ -11,6 +11,10 @@ wifi: true # WiFi companion: RAM for WiFi + BLE together light_sleep: true # DIO1 on an RTC-wake-capable GPIO AND # validated on hardware (ESP32 repeaters) + light_sleep_companion: true # the companion build has light sleep + # (opt-in with `powersaving on`); needs + # the wake pins and pin holds, and the + # RAM. Independent of light_sleep release: # absent = not published (bring-up) roles: [companion, repeater] # REQUIRED in release variants: # extra published builds of this board @@ -51,7 +55,7 @@ "mg24": "mg24", "stm32wl": "stm32wl"} ROLES = ("companion", "repeater", "room_server", "observer") KEYS = {"target", "capabilities", "release", "catalog", "linux"} -CAPS = {"light_sleep", "wifi"} +CAPS = {"light_sleep", "light_sleep_companion", "wifi"} CATALOG_KEYS = {"device", "maker", "new", "img", "own_img", "subtitle"} CAP_LINE = re.compile(r"^ (\w+): (true|false)\s*$", re.M) diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index d86b4a747..c2ad25661 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -1092,6 +1092,7 @@ int main(void) zephcore_leds_set_disabled(leds_off); zephcore_leds_set_radio_mode(companion_mesh.prefs.leds_radio_mode); zephcore_leds_set_hb_mode(companion_mesh.prefs.leds_hb_mode); + zephcore_led_set_brightness_pct(companion_mesh.prefs.led_brightness); ui_set_heartbeat_led(!leds_off); LOG_INF("LEDs: %s (from prefs)", leds_off ? "disabled" : "enabled"); diff --git a/zephcore/src/server_main_common.cpp b/zephcore/src/server_main_common.cpp index 1ea9351e0..3c1e7034b 100644 --- a/zephcore/src/server_main_common.cpp +++ b/zephcore/src/server_main_common.cpp @@ -72,7 +72,8 @@ extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line) #endif /* LED configuration */ -#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) +/* With a PWM LED, led0 is the same pin and belongs to the PWM driver. */ +#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) && !IS_ENABLED(CONFIG_ZEPHCORE_LED_PWM) #define LED0_NODE DT_ALIAS(led0) static const struct gpio_dt_spec led0 = GPIO_DT_SPEC_GET(LED0_NODE, gpios); #endif @@ -610,7 +611,7 @@ int server_main(const ServerRole &role) #endif /* Configure LEDs */ -#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) +#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) && !IS_ENABLED(CONFIG_ZEPHCORE_LED_PWM) if (gpio_is_ready_dt(&led0)) { gpio_pin_configure_dt(&led0, GPIO_OUTPUT_INACTIVE); } diff --git a/zephcore/west.yml b/zephcore/west.yml index 443b0f7da..6a676ca87 100644 --- a/zephcore/west.yml +++ b/zephcore/west.yml @@ -6,7 +6,7 @@ manifest: projects: - name: zephyr remote: zephyrproject-rtos - revision: 74b7173e9c929cf8eed570fb3df099c5514720c0 + revision: 11808e9ea8abc132661fe18a21236e053ab04b93 import: true self: