From 94a4d16432992d8d00d69d5a024474e37924b945 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 19 Sep 2026 12:53:14 -0700 Subject: [PATCH 01/55] Rebase the hw CLI onto current dev, dropping what has landed upstream work/hw-cli carried four separate changes that grew together: the hw command, the boot reset-cause capture, the V4-R8 board, the RAK4631 RTC descriptors and the companion reply-truncation fix. Three of those are now upstream in their own right, so this branch keeps only the hw CLI and its one prerequisite. Kept: helpers/HardwareReport.{cpp,h} the command itself adapters/clock/ZephyrRTCDiscover.* accessors for what the boot probe found, which hw rtc reports helpers/CommonCLI.cpp the dispatch arm zephcore/CMakeLists.txt one source line docs/Repeater_CLI_commands.md the hw sections Dropped as already upstream: helpers/boot_info.* and the main_companion.cpp changes (#94), the heltec_wifi_lora32_v4_r8 board (#93). Dropped as separate concerns that deserve their own review: app/CompanionMesh.cpp (reply truncation) and boards/nrf52840/rak4631/board.overlay (RTC descriptors). One real conflict came out of the rebase rather than the file split. zephcore_boot_reset_cause_str() gained a `hints` argument after this branch was written, and its buffer requirement moved to 127 bytes without hints. Both call sites in HardwareReport.cpp passed two arguments into a three-argument function and sized their buffer at 96. Fixed, with hints off: these lines page over LoRa, where width costs packets. Built for rak4631 and heltec_wifi_lora32_v43. Co-Authored-By: Claude Opus 5 (1M context) --- docs/Repeater_CLI_commands.md | 81 +++ zephcore/CMakeLists.txt | 1 + zephcore/adapters/clock/ZephyrRTCDiscover.c | 92 +++ zephcore/adapters/clock/ZephyrRTCDiscover.h | 45 ++ zephcore/helpers/CommonCLI.cpp | 8 + zephcore/helpers/HardwareReport.cpp | 720 ++++++++++++++++++++ zephcore/helpers/HardwareReport.h | 55 ++ 7 files changed, 1002 insertions(+) create mode 100644 zephcore/helpers/HardwareReport.cpp create mode 100644 zephcore/helpers/HardwareReport.h diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index d24a3df27..f629f4547 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -35,6 +35,87 @@ All commands are sent over USB serial (CDC-ACM). Commands sent remotely over the --- +## Hardware Report + +`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 **all four roles** — companion, repeater, room server and observer. + +| 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 clock source), `gnss`, `i2c` count, `reset` | +| `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 boot probe'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 | +| `hw sensors [start]` | Environment and power sensor availability, and which channels report | +| `hw all [start]` | Every section above, in order | +| `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 zero-length write to each address. It finds + undeclared hardware, but a bare address is all the bus itself reveals; declared addresses + are annotated with their compatible. + +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 `not identified (generic NMEA)` 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. +- An RTC candidate the boot probe never reached reports `unprobed`, not `absent`. The probe + stops at the first chip holding a valid time. +- 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 `CLI_REMOTE_REPLY_SIZE` (161) over +remote admin, since a remote reply rides the caller's LoRa packet buffer. When a section does +not fit, the reply ends with ` next:N` and `hw
N` resumes from that 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. + +--- + ## Clock | Command | Description | diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index 183a493ec..4e84d6bc8 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -729,6 +729,7 @@ target_sources(app PRIVATE # Captures the reset cause before anything consumes it; every role needs it # at boot, so it is never conditional. helpers/boot_info.c + helpers/HardwareReport.cpp helpers/oled_power.c helpers/fatal_reboot.c # LED master gate ("set leds on|off"). Always compiled: helpers/ui/ui_common.c diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.c b/zephcore/adapters/clock/ZephyrRTCDiscover.c index db373056e..fea338058 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.c +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.c @@ -13,6 +13,7 @@ #include "ZephyrRTCDiscover.h" #include +#include #include #include #include @@ -55,6 +56,12 @@ static const struct rtc_desc rtc_descs[] = { static const struct rtc_desc *s_active; static bool s_probed; +/* Per-candidate probe outcome, for the reporting accessors. Stays UNPROBED for + * entries the loop never reached -- it stops at the first chip holding a valid + * time, so "not absent" and "present" are different answers and we keep them + * apart rather than inferring one from the other. */ +static uint8_t s_state[ARRAY_SIZE(rtc_descs)]; + #define BCD2BIN(x) ((((x) >> 4) & 0x0F) * 10 + ((x) & 0x0F)) #define BIN2BCD(x) ((((x) / 10) << 4) | ((x) % 10)) @@ -113,14 +120,26 @@ static bool rtc_time_unreliable(const struct rtc_desc *d, const uint8_t blk[7]) * chip holds a sane time, return it via epoch_out. */ static bool rtc_probe(uint32_t *epoch_out) { + /* Clear last run's outcomes. rtc_probe() can run more than once -- + * zephcore_rtc_save() probes if restore never ran -- and it returns + * early once a chip holds a valid time. Without this reset, candidates + * the later run never reached would keep the previous run's PRESENT or + * ABSENT, which is precisely the stale claim UNPROBED exists to avoid. */ + memset(s_state, ZEPHCORE_RTC_UNPROBED, sizeof(s_state)); + for (size_t i = 0; i < ARRAY_SIZE(rtc_descs); i++) { const struct rtc_desc *d = &rtc_descs[i]; uint8_t blk[7]; if (!device_is_ready(d->bus)) { + /* The probe could not run, which is not evidence the chip + * is missing. Leave the state UNPROBED: claiming ABSENT + * here would report "no RTC present" for a board whose I2C + * driver merely failed to initialise. */ continue; } if (i2c_burst_read(d->bus, d->addr, d->time_reg, blk, sizeof(blk)) != 0) { + s_state[i] = ZEPHCORE_RTC_ABSENT; continue; /* no ACK => chip absent */ } @@ -142,9 +161,15 @@ static bool rtc_probe(uint32_t *epoch_out) bool unreliable = rtc_time_unreliable(d, blk); if (!bcd_ok && !unreliable) { + /* Something ACKed here, but it does not behave like an RTC -- + * an IMU at 0x68, say. Absent means "no RTC here", which is + * the honest answer for reporting too. */ + s_state[i] = ZEPHCORE_RTC_ABSENT; continue; /* neither valid time nor a lost-power RTC => skip */ } + s_state[i] = ZEPHCORE_RTC_PRESENT; + if (s_active == NULL) { s_active = d; /* RTC => our write-back target */ } @@ -234,6 +259,50 @@ void zephcore_rtc_save(uint32_t epoch) LOG_DBG("RTC %s: persisted time", d->name); } +static void rtc_fill(size_t i, struct zephcore_rtc_entry *out) +{ + const struct rtc_desc *d = &rtc_descs[i]; + + out->name = d->name; + out->bus = d->bus->name; + out->addr = d->addr; + out->state = (enum zephcore_rtc_state)s_state[i]; + out->active = (s_active == d); +} + +size_t zephcore_rtc_declared(void) +{ + return ARRAY_SIZE(rtc_descs); +} + +bool zephcore_rtc_get(size_t i, struct zephcore_rtc_entry *out) +{ + if (out == NULL || i >= ARRAY_SIZE(rtc_descs)) { + return false; + } + rtc_fill(i, out); + return true; +} + +bool zephcore_rtc_active(struct zephcore_rtc_entry *out) +{ + if (out == NULL || s_active == NULL) { + return false; + } + for (size_t i = 0; i < ARRAY_SIZE(rtc_descs); i++) { + if (&rtc_descs[i] == s_active) { + rtc_fill(i, out); + return true; + } + } + return false; +} + +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) @@ -247,4 +316,27 @@ void zephcore_rtc_save(uint32_t epoch) ARG_UNUSED(epoch); } +size_t zephcore_rtc_declared(void) +{ + return 0; +} + +bool zephcore_rtc_get(size_t i, struct zephcore_rtc_entry *out) +{ + ARG_UNUSED(i); + ARG_UNUSED(out); + return false; +} + +bool zephcore_rtc_active(struct zephcore_rtc_entry *out) +{ + ARG_UNUSED(out); + return false; +} + +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 727ee0f8d..fc4552a9b 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.h +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.h @@ -16,6 +16,7 @@ #pragma once #include +#include #include #ifdef __cplusplus @@ -38,6 +39,50 @@ bool zephcore_rtc_restore(uint32_t *epoch_out); */ void zephcore_rtc_save(uint32_t epoch); +/* + * ---- Reporting accessors ------------------------------------------------- + * + * Discovery knows which chips the board declares and which one it adopted, but + * until now kept both to itself, so a release build could not say what RTC it + * is driving (every MESH_DEBUG_* call is compiled away). These expose that + * without changing any probing behaviour. + * + * They report only what discovery actually established. The probe loop stops + * at the first chip holding a valid time, so candidates after it are never + * reached -- those report UNPROBED rather than being reported as absent. A + * candidate whose I2C bus was not ready reports UNPROBED for the same reason: + * a probe that could not run is not evidence that the chip is missing. + */ + +enum zephcore_rtc_state { + ZEPHCORE_RTC_UNPROBED = 0, /* not probed: discovery stopped before + * reaching it, or its bus was not ready */ + ZEPHCORE_RTC_ABSENT, /* no ACK, or a non-RTC chip sharing the address */ + ZEPHCORE_RTC_PRESENT, /* an RTC answered and was accepted as one */ +}; + +/* 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 boot probe found */ + bool active; /* adopted as the write-back target */ +}; + +/* Number of RTC candidates this board declares in devicetree. 0 if none. */ +size_t zephcore_rtc_declared(void); + +/* Fill *out for declared candidate i. False if i is out of range. */ +bool zephcore_rtc_get(size_t i, struct zephcore_rtc_entry *out); + +/* Fill *out with the adopted chip. False if none was adopted, which includes + * the case where discovery has not run yet -- check zephcore_rtc_probed(). */ +bool zephcore_rtc_active(struct zephcore_rtc_entry *out); + +/* Has boot-time discovery run? If false, every state above is UNPROBED. */ +bool zephcore_rtc_probed(void); + #ifdef __cplusplus } #endif diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index c61a0fccd..aa400d341 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -4,6 +4,7 @@ */ #include "CommonCLI.h" +#include "HardwareReport.h" #include "battery_curve.h" #include "led_gate.h" #include "buzzer_gate.h" @@ -1528,6 +1529,13 @@ 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) { + /* Remote admin replies ride the caller's LoRa packet buffer, so the + * capacity differs -- same idiom as get cad.stats above. */ + size_t cap = (sender_timestamp == 0) ? CLI_REPLY_SIZE + : CLI_REMOTE_REPLY_SIZE; + zephcore_hw::handle(command, reply, cap, sender_timestamp == 0, + _board, _rtc, _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) { diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp new file mode 100644 index 000000000..560538999 --- /dev/null +++ b/zephcore/helpers/HardwareReport.cpp @@ -0,0 +1,720 @@ +/* + * 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 + +extern "C" { +#include "../adapters/clock/ZephyrRTCDiscover.h" +} + +#if __has_include("../adapters/sensors/ZephyrEnvSensors.h") +#include "../adapters/sensors/ZephyrEnvSensors.h" +#define HW_HAS_SENSOR_HDR 1 +#endif + +#if __has_include("../adapters/gps/ZephyrGPSManager.h") +#include "../adapters/gps/ZephyrGPSManager.h" +#define HW_HAS_GPS_HDR 1 +#endif + +namespace zephcore_hw { +namespace { + +/* Room reserved for the " next:NNNNN" resume marker, so a truncated page can + * always say where to resume. Without the reservation the marker is exactly + * what gets cut off, leaving an unresumable page. */ +#define HW_NEXT_RESERVE 12 + +/* + * Largest page index the marker can carry and the parser will accept. + * + * Set by what the marker format holds: " next:" is 6 characters, so + * HW_NEXT_RESERVE (12) leaves 5 digits plus the NUL -- " next:99999" is + * exactly 11 characters. The emitter clamps to this rather than letting + * snprintf cut digits, and the parser accepts up to the same value, because a + * page that advertises an index the parser then silently rewrites pages the + * same lines forever. One constant, so the two cannot drift apart. + */ +#define HW_PAGE_INDEX_MAX 99999U + +/* + * Paged line sink. + * + * Every section writes through this, so `hw all` and a single subcommand page + * identically and there is one place that can overflow. Line indices are + * unsigned end to end: a resume index parsed as unsigned and then rendered + * signed is how a page emits "next:-1" and becomes unresumable. + */ +struct Sink { + char *buf; + size_t buf_cap; /* the caller's actual buffer size */ + size_t cap; /* usable capacity, already 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; + /* Remember the caller's real capacity: cap below is the reduced write + * limit, and sink_finish() must not assume the difference exists. */ + s->buf_cap = cap; + s->cap = (cap > HW_NEXT_RESERVE) ? cap - HW_NEXT_RESERVE : 1; + s->len = 0; + s->first = first; + s->line = 0; + s->next = 0; + s->full = false; + buf[0] = '\0'; +} + +void sink_line(Sink *s, const char *fmt, ...) +{ + unsigned idx = s->line++; + + if (s->full || idx < s->first) { + return; + } + + char line[160]; + va_list ap; + va_start(ap, fmt); + int w = vsnprintf(line, sizeof(line), fmt, ap); + va_end(ap); + if (w < 0) { + return; + } + + size_t need = strlen(line) + (s->len ? 1 : 0); + if (s->len + need >= s->cap) { + s->full = true; + /* Clamp rather than let snprintf truncate the digits: a cut + * number is a plausible-looking index pointing somewhere else. + * No section emits anywhere near this many lines, which is why + * this is about provability rather than a reachable bug. */ + s->next = (idx > HW_PAGE_INDEX_MAX) ? HW_PAGE_INDEX_MAX : 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'; +} + +/* Append the resume marker if the page was cut short. */ +void sink_finish(Sink *s) +{ + if (!s->full) { + return; + } + + /* Normally cap excluded HW_NEXT_RESERVE so the marker fits, but a caller + * passing cap <= HW_NEXT_RESERVE gets no reserve at all -- sink_init() + * clamps cap to 1 there. Bound the write by what the buffer actually has + * rather than by the reserve we hoped for; no current caller is that + * small, and this is a reporting path that must not be the thing that + * overruns a reply buffer. */ + if (s->len >= s->buf_cap) { + return; + } + size_t room = s->buf_cap - s->len; + if (room > HW_NEXT_RESERVE) { + room = HW_NEXT_RESERVE; + } + snprintf(s->buf + s->len, room, " next:%u", s->next); +} + +/* ================= devicetree I2C inventory ================= + * + * Declared children only -- no bus traffic. Deliberately does not try to + * resolve a struct device for each node: DEVICE_DT_GET_OR_NULL still expands + * to DEVICE_DT_GET for any status-okay node, so a node with no driver bound + * (every zephcore,rtc-i2c descriptor is exactly that -- data-only, probed by + * raw I2C) would fail to link. Binding is reported by the adapters that + * actually know it: `hw rtc` and `hw sensors`. + */ + +struct I2cDecl { + const char *bus; + const char *node; + const char *compat; + uint16_t addr; +}; + +/* + * The bus string must be DEVICE_DT_NAME, not DT_NODE_FULL_NAME. + * + * Zephyr derives a device's runtime name as DT_PROP_OR(node, label, + * DT_NODE_FULL_NAME(node)), and the live scan and the RTC section both report + * `dev->name`. A bus node carrying a `label` would therefore print one string + * in `hw i2c` and a different one in `hw i2c scan` and `hw rtc`, and + * declared_name_at() -- which matches the two by string -- would silently stop + * annotating scanned addresses. + * + * No board in this tree labels an I2C node today, so the two derivations agree + * and the bug is invisible on current hardware. Using the same derivation + * Zephyr does keeps it that way by construction rather than by luck. + */ +#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), \ + }, + +/* + * Every enabled node that sits on an I2C bus and is an addressable chip. + * + * Walks the whole devicetree via DT_FOREACH_STATUS_OKAY_NODE and filters with + * DT_ON_BUS rather than naming bus nodelabels: this tree already has boards on + * i2c22 and i2c30 (nrf54l, seeed_lr2021_evk), so any hardcoded i2c0/1/2 list + * silently omits their devices. Silently omitting a declared chip is the one + * thing this report must never do. + * + * Nodes without both reg and compatible are skipped -- not addressable chips. + */ +#define HW_I2C_NODE(node_id) \ + IF_ENABLED(UTIL_AND(DT_ON_BUS(node_id, i2c), \ + UTIL_AND(DT_NODE_HAS_PROP(node_id, reg), \ + DT_NODE_HAS_PROP(node_id, compatible))), \ + (HW_I2C_ENTRY(node_id))) + +const I2cDecl i2c_decls[] = { + DT_FOREACH_STATUS_OKAY_NODE(HW_I2C_NODE) + /* Sentinel. A board may declare no I2C children at all, and a + * zero-length array is not valid C++. Never reported -- see + * i2c_decl_count(). */ + { nullptr, nullptr, nullptr, 0xFFFF }, +}; + +constexpr size_t i2c_decl_count() { return ARRAY_SIZE(i2c_decls) - 1; } + +#if IS_ENABLED(CONFIG_I2C) +/* + * The controller behind each declared device, for the live scan. Derived from + * the same devicetree walk, so it follows whatever nodelabels a board uses + * rather than a hardcoded i2c0/1/2 list. Entries repeat once per child and are + * de-duplicated at scan time. + * + * DEVICE_DT_GET is safe here in a way it is not for the child nodes: the parent + * of an I2C child is an I2C controller, and this table only exists when + * CONFIG_I2C is on, so a driver is instantiated for it. + * + * Limitation, stated rather than hidden: a bus with no declared device at all + * is not scanned, because nothing in the devicetree walk names it. + */ +#define HW_I2C_BUS_ENTRY(node_id) DEVICE_DT_GET(DT_BUS(node_id)), + +#define HW_I2C_BUS_NODE(node_id) \ + IF_ENABLED(UTIL_AND(DT_ON_BUS(node_id, i2c), \ + UTIL_AND(DT_NODE_HAS_PROP(node_id, reg), \ + DT_NODE_HAS_PROP(node_id, compatible))), \ + (HW_I2C_BUS_ENTRY(node_id))) + +const struct device *const i2c_bus_refs[] = { + DT_FOREACH_STATUS_OKAY_NODE(HW_I2C_BUS_NODE) + nullptr, /* sentinel, same reason as i2c_decls */ +}; + +constexpr size_t i2c_bus_ref_count() { return ARRAY_SIZE(i2c_bus_refs) - 1; } +#endif /* CONFIG_I2C */ + +/* Name a scanned address from the devicetree, when the board declared it. */ +const char *declared_name_at(const char *bus, uint16_t addr) +{ + for (size_t i = 0; i < i2c_decl_count(); i++) { + if (i2c_decls[i].addr == addr && + strcmp(i2c_decls[i].bus, bus) == 0) { + return i2c_decls[i].compat; + } + } + return nullptr; +} + +const char *rtc_state_str(enum zephcore_rtc_state st) +{ + switch (st) { + case ZEPHCORE_RTC_PRESENT: return "present"; + case ZEPHCORE_RTC_ABSENT: return "absent"; + default: return "unprobed"; + } +} + +/* ================= sections ================= */ + +void section_board(Sink *s, mesh::MainBoard *board, CommonCLICallbacks *cb) +{ + sink_line(s, "board: %s", CONFIG_BOARD_TARGET); + if (board != nullptr) { + sink_line(s, "name: %s", board->getManufacturerName()); + } + sink_line(s, "soc: %s", CONFIG_SOC); + sink_line(s, "zephyr: %s", KERNEL_VERSION_STRING); + + if (cb != nullptr) { + sink_line(s, "fw: %s (%s)", cb->getFirmwareVer(), cb->getBuildDate()); + sink_line(s, "role: %s", cb->getRole()); + } + + char bl[48]; + if (board != nullptr && board->getBootloaderVersion(bl, sizeof(bl))) { + sink_line(s, "bootloader: %s", bl); + } + + uint32_t cause; + if (zephcore_boot_reset_cause(&cause)) { + /* 128 holds every label without hints, per boot_info.h. Hints are + * off: these lines page over LoRa, where width costs packets. */ + char causes[128]; + zephcore_boot_reset_cause_str(causes, sizeof(causes), false); + /* A cause of 0 is a real answer -- the chip reported no known + * cause -- and is not the same as the platform being unable to + * tell us, which is the branch below. */ + 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]; + for (ssize_t i = 0; i < n && (size_t)i < sizeof(devid); i++) { + snprintf(hex + i * 2, 3, "%02x", devid[i]); + } + sink_line(s, "devid: %s", hex); + } else { + sink_line(s, "devid: not supported"); + } +} + +void section_rtc(Sink *s, mesh::RTCClock *rtc) +{ + (void)rtc; + + size_t declared = zephcore_rtc_declared(); + + if (declared == 0) { + /* Not a failure: rtc-i2c.dtsi is opt-in per board, because its + * fixed addresses collide with common parts (0x68 is both a + * DS3231 and an MPU-class IMU). Say what is true -- the board + * declares none -- rather than "no RTC fitted", which this + * firmware has no way to know. */ + 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; + } + + struct zephcore_rtc_entry active; + if (zephcore_rtc_active(&active)) { + sink_line(s, "rtc: %s at 0x%02x on %s", active.name, + active.addr, active.bus); + } else { + sink_line(s, "rtc: none present (%u declared)", (unsigned)declared); + } + + for (size_t i = 0; i < declared; i++) { + struct zephcore_rtc_entry e; + if (!zephcore_rtc_get(i, &e)) { + continue; + } + 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) + /* Different from "scanned and found nothing": this build cannot scan. */ + 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]; + + /* Skip a controller already scanned: the table carries one + * entry per declared child, so a bus with four devices on it + * appears four times. */ + 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; + char line[128]; + int used = snprintf(line, sizeof(line), "%s:", bus->name); + + /* A bus name long enough to fill the buffer would make every + * `line + used` below out of bounds. Not reachable with today's + * devicetree names, which is exactly why it needs checking here + * rather than being assumed. */ + if (used < 0 || (size_t)used >= sizeof(line)) { + sink_line(s, "%s: name too long to scan", bus->name); + continue; + } + + /* 0x08-0x77: the 7-bit range excluding the reserved low and + * high blocks. A zero-length write is the standard probe -- it + * addresses the device and stops, so a chip that would react to + * a read of register 0 is not disturbed. */ + /* Zero-length write is the standard probe, but hand it a real + * pointer: i2c_write() puts buf straight into i2c_msg.buf and + * some controller drivers assert on NULL before looking at len. */ + uint8_t probe = 0; + + for (uint16_t addr = 0x08; addr <= 0x77; addr++) { + if (i2c_write(bus, &probe, 0, addr) != 0) { + continue; + } + found++; + + const char *nm = declared_name_at(bus->name, addr); + + /* Render the token on its own first, so the decision to + * wrap is made against a known length and an address can + * never fall between the two branches. The previous shape + * -- format into the line, retry once on a fresh line, + * and do nothing if that also failed -- dropped the + * address while found++ had already counted it, so the + * total and the listing disagreed. */ + char tok[72]; + int tw = nm ? snprintf(tok, sizeof(tok), " 0x%02x(%s)", addr, nm) + : snprintf(tok, sizeof(tok), " 0x%02x", addr); + + if (tw < 0 || (size_t)tw >= sizeof(tok)) { + /* Annotation too long for the token buffer. The + * address is the part that matters; keep it and + * lose the name. */ + tw = snprintf(tok, sizeof(tok), " 0x%02x", addr); + if (tw < 0) { + continue; + } + } + + size_t toklen = (size_t)tw; + + if ((size_t)used + toklen >= sizeof(line)) { + /* Wrap: flush what we have and restart the line. */ + line[used] = '\0'; + sink_line(s, "%s", line); + used = snprintf(line, sizeof(line), "%s:", bus->name); + if (used < 0 || (size_t)used >= sizeof(line)) { + break; + } + } + + if ((size_t)used + toklen < sizeof(line)) { + memcpy(line + used, tok, toklen + 1); + used += (int)toklen; + } else { + /* Even a fresh line cannot hold it, which needs a + * bus name nearly as long as the buffer. Give the + * address a line of its own rather than dropping a + * chip that is physically present -- the one thing + * a bus scan must never do. */ + sink_line(s, "%s", tok); + } + } + + if (found == 0) { + sink_line(s, "%s: none found", bus->name); + } else { + sink_line(s, "%s", line); + } + } + + if (buses == 0) { + /* Not "no bus enabled": buses are discovered by walking declared + * devices, so the SoC may well have an enabled controller with + * nothing declared on it. Say only what that walk established. */ + sink_line(s, "i2c: no bus carries a declared device, none scanned"); + } +#endif /* CONFIG_I2C */ +} + +void section_gps(Sink *s, CommonCLICallbacks *cb) +{ +#if DT_NODE_EXISTS(DT_NODELABEL(gnss)) + sink_line(s, "gnss: %s", DT_PROP_BY_IDX(DT_NODELABEL(gnss), compatible, 0)); + sink_line(s, " on %s", DT_NODE_FULL_NAME(DT_PARENT(DT_NODELABEL(gnss)))); +#if DT_NODE_HAS_PROP(DT_PARENT(DT_NODELABEL(gnss)), current_speed) + sink_line(s, " baud %u", + (unsigned)DT_PROP(DT_PARENT(DT_NODELABEL(gnss)), current_speed)); +#endif + /* The bound driver's compatible, printed above, is the only honest model + * statement. A board overlay comment naming a part is documentation, not + * detection: the rak4631 overlay documents a u-blox MAX-7Q, binds + * gnss-nmea-generic, and drives whatever NMEA receiver is fitted. + * + * Only the generic NMEA driver leaves the model genuinely unknown -- it + * parses any NMEA talker and names no part. A specific driver IS the + * model, so adding an "unidentified" line there would contradict the + * compatible printed directly above it. */ +#if DT_NODE_HAS_COMPAT(DT_NODELABEL(gnss), gnss_nmea_generic) + sink_line(s, " model: not identified (generic NMEA driver)"); +#endif +#else + sink_line(s, "gnss: none declared in devicetree"); +#endif + +#ifdef HW_HAS_GPS_HDR + sink_line(s, " available: %s", gps_is_available() ? "yes" : "no"); + sink_line(s, " enabled: %s", gps_is_enabled() ? "yes" : "no"); +#else + if (cb != nullptr) { + sink_line(s, " enabled: %s", cb->isGpsEnabled() ? "yes" : "no"); + } +#endif + (void)cb; +} + +void section_sensors(Sink *s) +{ +#ifdef HW_HAS_SENSOR_HDR + bool env = env_sensors_available(); + bool pwr = power_sensors_available(); + + sink_line(s, "sensors: env %s, power %s", + env ? "yes" : "no", pwr ? "yes" : "no"); + + if (env) { + struct env_data d; + if (env_sensors_read(&d) == 0) { + sink_line(s, " channels:%s%s%s%s%s", + d.has_temperature ? " temp" : "", + d.has_humidity ? " hum" : "", + d.has_pressure ? " press" : "", + d.has_mcu_temperature ? " mcutemp" : "", + d.has_luminosity ? " lux" : ""); + } + } +#else + /* No sensor support compiled in -- which is a different statement from + * a sensor manager that looked and found nothing. */ + sink_line(s, "sensors: not compiled in"); +#endif +} + +void section_summary(Sink *s, mesh::MainBoard *board, CommonCLICallbacks *cb) +{ + /* CONFIG_BOARD, not CONFIG_BOARD_TARGET: the target already embeds the + * SoC ("rak4631/nrf52840"), which read as "rak4631/nrf52840 (nrf52840)". + * `hw board` still reports the full target. */ + sink_line(s, "%s (%s)", CONFIG_BOARD, CONFIG_SOC); + if (cb != nullptr) { + sink_line(s, "fw %s role %s", cb->getFirmwareVer(), cb->getRole()); + } + + struct zephcore_rtc_entry active; + if (zephcore_rtc_active(&active)) { + /* A devicetree node's full name already ends in "@", so the + * address is not appended here -- doing so rendered + * "rtc-rv3028@52@0x52" on real hardware. */ + sink_line(s, "rtc %s", active.name); + } else if (zephcore_rtc_declared() == 0) { + sink_line(s, "rtc none declared"); + } else if (!zephcore_rtc_probed()) { + /* zephcore_rtc_active() is false both for "probed, none found" + * and "not probed yet". Collapsing those into "none present" + * states a fact discovery never established -- the distinction + * `hw rtc` keeps, and a summary has no licence to be looser + * about it than the section it summarises. */ + sink_line(s, "rtc not yet probed"); + } else { + sink_line(s, "rtc none present"); + } + +#if DT_NODE_EXISTS(DT_NODELABEL(gnss)) + sink_line(s, "gnss %s", DT_PROP_BY_IDX(DT_NODELABEL(gnss), compatible, 0)); +#else + sink_line(s, "gnss none"); +#endif + + sink_line(s, "i2c %u declared", (unsigned)i2c_decl_count()); + + /* Always emitted, including when unsupported: the docs pin the summary's + * field list, and a line that silently disappears on some platforms makes + * the schema depend on the board. */ + uint32_t cause; + if (zephcore_boot_reset_cause(&cause)) { + /* 128 holds every label without hints, per boot_info.h. Hints are + * off: these lines page over LoRa, where width costs packets. */ + char causes[128]; + zephcore_boot_reset_cause_str(causes, sizeof(causes), false); + sink_line(s, "reset%s", causes[0] ? causes : " none"); + } else { + sink_line(s, "reset not supported"); + } + (void)board; +} + +/* + * Match `name` as a whole word at the start of arg, returning the remainder or + * nullptr. + * + * A bare prefix test is the surrounding CommonCLI house style, but this command + * documents a usage string for anything it does not recognise, and a prefix + * test breaks that promise: `hw boardwalk` would run `hw board` and quietly + * discard the rest. + */ +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 when the tail is neither empty + * nor a plain number, so `hw board xyz` reaches the usage string rather than + * being silently treated as `hw board`. + * + * Unsigned end to end, and clamped rather than wrapped: an index parsed + * unsigned and then rendered signed is what emits an unresumable "next:-1". + */ +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 != nullptr && *end == ' ') { + end++; + } + if (end != nullptr && *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, bool local, + mesh::MainBoard *board, mesh::RTCClock *rtc, + CommonCLICallbacks *callbacks) +{ + /* command starts at "hw"; step past it and any spaces. */ + const char *arg = command + 2; + while (*arg == ' ') { + arg++; + } + + Sink s; + const char *rest; + unsigned start = 0; + + /* Each arm must match a whole word AND carry a valid tail; a branch that + * matches the word but not the tail falls through to the usage string. */ + if (*arg == '\0') { + sink_init(&s, reply, cap, 0); + section_summary(&s, board, 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, rtc); + } 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, callbacks); + } 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, rtc); + section_i2c(&s); + section_gps(&s, callbacks); + section_sensors(&s); + } else { + snprintf(reply, cap, + "usage: hw [board|rtc|i2c [scan]|gps|sensors|all] [start]"); + return; + } + + sink_finish(&s); + (void)local; +} + +} /* namespace zephcore_hw */ diff --git a/zephcore/helpers/HardwareReport.h b/zephcore/helpers/HardwareReport.h new file mode 100644 index 000000000..ad7767d28 --- /dev/null +++ b/zephcore/helpers/HardwareReport.h @@ -0,0 +1,55 @@ +/* + * SPDX-License-Identifier: MIT + * + * `hw` — what this firmware is, and what hardware it actually found. + * + * A release build compiles every MESH_DEBUG_* call away, so the firmware knows + * its board, MCU, clock source, GNSS wiring and sensor inventory and has no + * way to tell anyone. This formats those facts for the CLI. + * + * Radio parameters are deliberately NOT reported here: `get freq`, `get sf` + * and the stats-radio family already cover them, and duplicating a live + * setting in a second place is how the two drift apart. + * + * The rule every formatter here follows: report what the code KNOWS, never + * what could be inferred. A devicetree comment naming a GNSS part is not + * evidence the part is fitted (the rak4631 overlay documents a u-blox MAX-7Q + * while binding gnss-nmea-generic, which happily drives the CASIC receiver + * actually present). So a driver names itself, an unprobed address reports as + * unprobed rather than absent, and an enable pin of -1 prints as -1. + * + * Named `hw`, not `hwinfo`, because is a Zephyr + * subsystem this tree already uses; a CLI command sharing that name would read + * ambiguously in the source. + */ + +#pragma once + +#include +#include + +namespace mesh { +class MainBoard; +class RTCClock; +} + +class CommonCLICallbacks; + +namespace zephcore_hw { + +/* + * Write the reply for `command`, which is the full CLI line starting at "hw". + * `cap` is the caller's reply capacity -- CLI_REPLY_SIZE locally, or + * CLI_REMOTE_REPLY_SIZE when the request came from remote mesh admin, since a + * remote reply rides the caller's LoRa packet buffer. + * + * `local` is true only when the request did not arrive over the air. Note it + * is NOT a test for "this is a serial console": on companion firmware the USB + * sideband, the app's CMD_RUN_CLI_COMMAND and the V-Contact loopback all reach + * the CLI indistinguishably. It means "not remote mesh admin" and nothing more. + */ +void handle(const char *command, char *reply, size_t cap, bool local, + mesh::MainBoard *board, mesh::RTCClock *rtc, + CommonCLICallbacks *callbacks); + +} /* namespace zephcore_hw */ From c7b77d65087aff0f2d2f0db300926421dd641e65 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 1 Oct 2026 20:27:33 -0700 Subject: [PATCH 02/55] hw: build against current dev, and drop a header-only scan line dev consolidated the mesh headers into mesh/MeshCore.h and replaced the merged sensor view with per-device enumeration, so the hw CLI no longer built. HardwareReport now includes MeshCore.h, and `hw sensors` lists each part the boot probe found, environment and power monitors alike, with the fields its reading carried. The GPS fallback that called the removed isGpsEnabled() callback is gone: the GPS header is always present, so it was dead code. declared_name_at() is compiled only with CONFIG_I2C, the one configuration that uses it, which silences native builds. `hw i2c scan` printed a line holding only the bus name when an address could not share a line with it: the wrap flushed a line with no addresses on it, and the final flush ran even when every address had gone out on its own line. Both flushes now skip a line that carries no address. Builds for rak4631 companion and repeater, heltec_wifi_lora32_v4_r8 repeater, and the rak6421 native preset. Co-Authored-By: Claude Opus 5.5 --- docs/Repeater_CLI_commands.md | 2 +- zephcore/helpers/HardwareReport.cpp | 64 +++++++++++++++++------------ 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index ea244ecf5..cf859b00e 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -52,7 +52,7 @@ it is available on **all four roles** — companion, repeater, room server and o | `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 | -| `hw sensors [start]` | Environment and power sensor availability, and which channels report | +| `hw sensors [start]` | Each sensor the boot probe found, environment and power monitors alike, and the fields it reports | | `hw all [start]` | Every section above, in order | | `hw ` | Usage string | diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index 560538999..60388ebfe 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -15,7 +15,7 @@ #include #include -#include +#include #include #include @@ -245,6 +245,7 @@ const struct device *const i2c_bus_refs[] = { constexpr size_t i2c_bus_ref_count() { return ARRAY_SIZE(i2c_bus_refs) - 1; } #endif /* CONFIG_I2C */ +#if IS_ENABLED(CONFIG_I2C) /* Name a scanned address from the devicetree, when the board declared it. */ const char *declared_name_at(const char *bus, uint16_t addr) { @@ -256,6 +257,7 @@ const char *declared_name_at(const char *bus, uint16_t addr) } return nullptr; } +#endif /* CONFIG_I2C */ const char *rtc_state_str(enum zephcore_rtc_state st) { @@ -407,6 +409,7 @@ void section_i2c_scan(Sink *s) sink_line(s, "%s: name too long to scan", bus->name); continue; } + const int header = used; /* 0x08-0x77: the 7-bit range excluding the reserved low and * high blocks. A zero-length write is the standard probe -- it @@ -449,12 +452,12 @@ void section_i2c_scan(Sink *s) size_t toklen = (size_t)tw; if ((size_t)used + toklen >= sizeof(line)) { - /* Wrap: flush what we have and restart the line. */ - line[used] = '\0'; - sink_line(s, "%s", line); - used = snprintf(line, sizeof(line), "%s:", bus->name); - if (used < 0 || (size_t)used >= sizeof(line)) { - break; + /* Wrap: flush what we have and restart the line, + * unless it holds only the bus name. */ + if (used > header) { + sink_line(s, "%s", line); + used = header; + line[used] = '\0'; } } @@ -473,7 +476,7 @@ void section_i2c_scan(Sink *s) if (found == 0) { sink_line(s, "%s: none found", bus->name); - } else { + } else if (used > header) { sink_line(s, "%s", line); } } @@ -515,10 +518,6 @@ void section_gps(Sink *s, CommonCLICallbacks *cb) #ifdef HW_HAS_GPS_HDR sink_line(s, " available: %s", gps_is_available() ? "yes" : "no"); sink_line(s, " enabled: %s", gps_is_enabled() ? "yes" : "no"); -#else - if (cb != nullptr) { - sink_line(s, " enabled: %s", cb->isGpsEnabled() ? "yes" : "no"); - } #endif (void)cb; } @@ -526,22 +525,33 @@ void section_gps(Sink *s, CommonCLICallbacks *cb) void section_sensors(Sink *s) { #ifdef HW_HAS_SENSOR_HDR - bool env = env_sensors_available(); - bool pwr = power_sensors_available(); - - sink_line(s, "sensors: env %s, power %s", - env ? "yes" : "no", pwr ? "yes" : "no"); - - if (env) { - struct env_data d; - if (env_sensors_read(&d) == 0) { - sink_line(s, " channels:%s%s%s%s%s", - d.has_temperature ? " temp" : "", - d.has_humidity ? " hum" : "", - d.has_pressure ? " press" : "", - d.has_mcu_temperature ? " mcutemp" : "", - d.has_luminosity ? " lux" : ""); + /* One line per part the boot probe found, environment and power + * monitors alike, with the fields its last reading carried. */ + 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; + + 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 /* No sensor support compiled in -- which is a different statement from From 7165091fcd91ad6790b0c88939363e14ad142cad Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 1 Oct 2026 20:40:05 -0700 Subject: [PATCH 03/55] hw: page to what the transport carries, and report only what is known Review findings on the merged branch: - The hw arm sized remote replies with CLI_REMOTE_REPLY_SIZE rather than dev's replyCap(), so behind an "xx|" prefix the sink was told it had three more bytes than it did. It uses replyCap() now. On a companion a local reply may be the app's CLI frame, which carries MAX_FRAME_SIZE - 1 bytes, so hw pages to that and the resume marker is no longer the part the frame cuts off. - A truncated summary ended in " next:N" that nothing accepted: "hw N" matched no arm and printed the usage. It now resumes the summary. - `hw sensors` claimed "not compiled in" from a header that always exists. It now asks CONFIG_SENSOR, so a build without sensor support says so instead of "none found". - With RTC autodiscovery disabled the stubs report nothing declared, which contradicted `hw i2c` listing the descriptors. hw now says autodiscovery is disabled. - The docs claimed hw on the observer, which has its own CLI; said `hw all` includes every section; quoted the GNSS line wrongly; and called the summary's rtc field the clock source, when it is the chip adopted for write-back. They also now say that a resumed page is computed afresh. Co-Authored-By: Claude Opus 5.5 --- docs/Repeater_CLI_commands.md | 22 +++++++++++++++------- zephcore/helpers/CommonCLI.cpp | 18 ++++++++++++++---- zephcore/helpers/HardwareReport.cpp | 16 +++++++++++++--- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index cf859b00e..98982d038 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -42,18 +42,19 @@ All commands are sent over USB serial (CDC-ACM). Commands sent remotely over the `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 **all four roles** — companion, repeater, room server and observer. +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`. | 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 clock source), `gnss`, `i2c` count, `reset` | +| `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 boot probe 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 boot probe'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 | | `hw sensors [start]` | Each sensor the boot probe found, environment and power monitors alike, and the fields it reports | -| `hw all [start]` | Every section above, in order | +| `hw all [start]` | `board`, `rtc`, `i2c`, `gps` and `sensors`, in that order. Not the summary, and not `hw i2c scan`, which puts traffic on the bus | | `hw ` | Usage string | `start` is an optional line index for resuming a truncated reply (see **Paging** below). @@ -97,7 +98,7 @@ 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 `not identified (generic NMEA)` rather than repeating the comment. + 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 @@ -109,9 +110,16 @@ Every field is something the firmware *knows*. It never infers: ### Paging -A reply is bounded by `CLI_REPLY_SIZE` (256) locally and `CLI_REMOTE_REPLY_SIZE` (161) over -remote admin, since a remote reply rides the caller's LoRa packet buffer. When a section does -not fit, the reply ends with ` next:N` and `hw
N` resumes from that line. +A reply is bounded by `CLI_REPLY_SIZE` (256) locally and by `CLI_REMOTE_REPLY_SIZE` (161), less +any `xx|` reply prefix, 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. When a section does not fit, +the reply ends with ` next:N` and `hw
N` (or `hw N` for the summary) resumes from that +line. + +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. diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index dae949dea..daabcb18c 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -5,6 +5,9 @@ #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" @@ -548,10 +551,17 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch snprintf(reply, CLI_REPLY_SIZE, "File system erase: Err"); } } else if (memcmp(command, "hw", 2) == 0) { - /* Remote admin replies ride the caller's LoRa packet buffer, so the - * capacity differs -- same idiom as get cad.stats. */ - size_t cap = (sender_timestamp == 0) ? CLI_REPLY_SIZE - : CLI_REMOTE_REPLY_SIZE; + /* replyCap() allows for a remote reply's "xx|" prefix. On a + * companion a local reply may be the app's CLI frame, which carries + * MAX_FRAME_SIZE - 1 bytes and cannot be told apart from the USB + * console here, so page to the frame rather than have it cut the + * resume marker off. */ + 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 zephcore_hw::handle(command, reply, cap, sender_timestamp == 0, _board, _rtc, _callbacks); } else if (memcmp(command, "ver", 3) == 0) { diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index 60388ebfe..7208cc816 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -321,6 +321,13 @@ void section_rtc(Sink *s, mesh::RTCClock *rtc) { (void)rtc; + if (!IS_ENABLED(CONFIG_ZEPHCORE_RTC_AUTODISCOVER)) { + /* The discovery stubs report nothing declared, which would + * contradict `hw i2c` listing the descriptors. */ + sink_line(s, "rtc: autodiscovery disabled"); + return; + } + size_t declared = zephcore_rtc_declared(); if (declared == 0) { @@ -524,7 +531,7 @@ void section_gps(Sink *s, CommonCLICallbacks *cb) void section_sensors(Sink *s) { -#ifdef HW_HAS_SENSOR_HDR +#if defined(HW_HAS_SENSOR_HDR) && IS_ENABLED(CONFIG_SENSOR) /* One line per part the boot probe found, environment and power * monitors alike, with the fields its last reading carried. */ int n = env_sensor_count(); @@ -576,6 +583,8 @@ void section_summary(Sink *s, mesh::MainBoard *board, CommonCLICallbacks *cb) * address is not appended here -- doing so rendered * "rtc-rv3028@52@0x52" on real hardware. */ sink_line(s, "rtc %s", active.name); + } else if (!IS_ENABLED(CONFIG_ZEPHCORE_RTC_AUTODISCOVER)) { + sink_line(s, "rtc autodiscovery disabled"); } else if (zephcore_rtc_declared() == 0) { sink_line(s, "rtc none declared"); } else if (!zephcore_rtc_probed()) { @@ -689,8 +698,9 @@ void handle(const char *command, char *reply, size_t cap, bool local, /* Each arm must match a whole word AND carry a valid tail; a branch that * matches the word but not the tail falls through to the usage string. */ - if (*arg == '\0') { - sink_init(&s, reply, cap, 0); + if (*arg == '\0' || ((*arg >= '0' && *arg <= '9') && parse_tail(arg, &start))) { + /* "hw N" resumes the summary, which pages like any section. */ + sink_init(&s, reply, cap, start); section_summary(&s, board, callbacks); } else if ((rest = match_word(arg, "board")) && parse_tail(rest, &start)) { sink_init(&s, reply, cap, start); From 78b486a3e48543901f21fe2031caae404a8b0118 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 1 Oct 2026 20:45:21 -0700 Subject: [PATCH 04/55] hw: say autodiscovery is disabled only when something is declared The disabled-autodiscovery reply ran before the declared check, and the option depends on I2C, so every build without I2C reported autodiscovery disabled where nothing is declared at all. It now applies only when the devicetree declares candidates, says how many, and is documented. HardwareReport.h described the reply capacity as the two fixed sizes; it now describes what CommonCLI passes. Co-Authored-By: Claude Opus 5.5 --- docs/Repeater_CLI_commands.md | 2 ++ zephcore/helpers/HardwareReport.cpp | 9 ++++++--- zephcore/helpers/HardwareReport.h | 6 +++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index 98982d038..453a8e885 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -103,6 +103,8 @@ Every field is something the firmware *knows*. It never infers: `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 boot probe never reached reports `unprobed`, not `absent`. The probe stops at the first chip holding a valid time. - A build with no sensor support reports `not compiled in`, which is a different statement diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index 7208cc816..a781e9145 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -321,10 +321,12 @@ void section_rtc(Sink *s, mesh::RTCClock *rtc) { (void)rtc; - if (!IS_ENABLED(CONFIG_ZEPHCORE_RTC_AUTODISCOVER)) { + if (!IS_ENABLED(CONFIG_ZEPHCORE_RTC_AUTODISCOVER) && + DT_NUM_INST_STATUS_OKAY(zephcore_rtc_i2c) > 0) { /* The discovery stubs report nothing declared, which would * contradict `hw i2c` listing the descriptors. */ - sink_line(s, "rtc: autodiscovery disabled"); + sink_line(s, "rtc: %u declared, autodiscovery disabled", + (unsigned)DT_NUM_INST_STATUS_OKAY(zephcore_rtc_i2c)); return; } @@ -583,7 +585,8 @@ void section_summary(Sink *s, mesh::MainBoard *board, CommonCLICallbacks *cb) * address is not appended here -- doing so rendered * "rtc-rv3028@52@0x52" on real hardware. */ sink_line(s, "rtc %s", active.name); - } else if (!IS_ENABLED(CONFIG_ZEPHCORE_RTC_AUTODISCOVER)) { + } else if (!IS_ENABLED(CONFIG_ZEPHCORE_RTC_AUTODISCOVER) && + DT_NUM_INST_STATUS_OKAY(zephcore_rtc_i2c) > 0) { sink_line(s, "rtc autodiscovery disabled"); } else if (zephcore_rtc_declared() == 0) { sink_line(s, "rtc none declared"); diff --git a/zephcore/helpers/HardwareReport.h b/zephcore/helpers/HardwareReport.h index ad7767d28..8b136ae7e 100644 --- a/zephcore/helpers/HardwareReport.h +++ b/zephcore/helpers/HardwareReport.h @@ -39,9 +39,9 @@ namespace zephcore_hw { /* * Write the reply for `command`, which is the full CLI line starting at "hw". - * `cap` is the caller's reply capacity -- CLI_REPLY_SIZE locally, or - * CLI_REMOTE_REPLY_SIZE when the request came from remote mesh admin, since a - * remote reply rides the caller's LoRa packet buffer. + * `cap` is the caller's reply capacity: CommonCLI::replyCap(), which allows for + * a remote reply's "xx|" prefix, and on a companion at most one app CLI frame + * (MAX_FRAME_SIZE - 1). * * `local` is true only when the request did not arrive over the air. Note it * is NOT a test for "this is a serial console": on companion firmware the USB From 7dbe59d1a2667033ca981898813ca1f12b69b114 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 1 Oct 2026 21:45:28 -0700 Subject: [PATCH 05/55] hw: move past a line too long for a whole page sink_line() refused a line that did not fit and marked the page full, which is right once a page holds something. On an empty page it meant the line was never emitted and the reply was only " next:N" pointing back at that same line, so resuming looped on it forever. That is reachable over remote admin: behind an "xx|" prefix a page holds 146 bytes, and a formatted line can be up to 159. Such a line is now cut to fit, ending in "...", and paging moves on. Found by CodeRabbit. Checked by compiling the sink on the host: a 158-byte line on a 146-byte page now pages 0, 1, 2, 3 instead of repeating index 0. Co-Authored-By: Claude Opus 5.5 --- docs/Repeater_CLI_commands.md | 3 ++- zephcore/helpers/HardwareReport.cpp | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index 453a8e885..f3b6740ed 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -117,7 +117,8 @@ any `xx|` reply prefix, over remote admin, since a remote reply rides the caller 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. When a section does not fit, the reply ends with ` next:N` and `hw
N` (or `hw N` for the summary) resumes from that -line. +line. A single line too long for a whole page is cut to fit and ends in `...`, so a resume +always moves past it. 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 diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index a781e9145..af22e6c4c 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -108,6 +108,17 @@ void sink_line(Sink *s, const char *fmt, ...) } size_t need = strlen(line) + (s->len ? 1 : 0); + if (s->len == 0 && need >= s->cap) { + /* A line longer than a whole page would otherwise never be + * emitted, and every resume would point back at it. Emit what + * fits, marked as cut, so paging moves on. */ + size_t keep = s->cap - 1; + line[keep] = '\0'; + if (keep > 3) { + memcpy(line + keep - 3, "...", 3); + } + need = keep; + } if (s->len + need >= s->cap) { s->full = true; /* Clamp rather than let snprintf truncate the digits: a cut From 0057730150690578ccff45a32834e8ef2ab52380 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 00:08:49 -0700 Subject: [PATCH 06/55] hw rtc: absent only when nothing answered The probe marked a candidate ABSENT on any failed read, so a timeout or a busy bus on a fitted RTC was reported as "absent" and the summary said "none present", which the probe never established. The nRF and ESP32 I2C drivers report an address NACK as -EIO; only that now marks a candidate absent, and any other failure leaves it unprobed. `hw rtc` and the summary say how many candidates went unprobed rather than "none present". On nRF every failed transfer surfaces as -EIO, so there a bus error still reads as absent; the driver gives nothing finer to go on. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 --- docs/Repeater_CLI_commands.md | 3 ++- zephcore/adapters/clock/ZephyrRTCDiscover.c | 13 ++++++++--- zephcore/adapters/clock/ZephyrRTCDiscover.h | 3 ++- zephcore/helpers/HardwareReport.cpp | 25 ++++++++++++++++++++- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index f3b6740ed..b17403290 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -105,7 +105,8 @@ Every field is something the firmware *knows*. It never infers: 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 boot probe never reached reports `unprobed`, not `absent`. The probe +- An RTC candidate the boot probe never reached, or whose read failed for a reason other than + no reply (a timeout, a busy bus), reports `unprobed`, not `absent`. The probe stops at the first chip holding a valid time. - A build with no sensor support reports `not compiled in`, which is a different statement from a sensor manager that looked and found nothing. diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.c b/zephcore/adapters/clock/ZephyrRTCDiscover.c index 0c043d569..27eebac56 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.c +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.c @@ -138,9 +138,16 @@ static bool rtc_probe(uint32_t *epoch_out) * driver merely failed to initialise. */ continue; } - if (i2c_burst_read(d->bus, d->addr, d->time_reg, blk, sizeof(blk)) != 0) { - s_state[i] = ZEPHCORE_RTC_ABSENT; - continue; /* no ACK => chip absent */ + int rc = i2c_burst_read(d->bus, d->addr, d->time_reg, blk, sizeof(blk)); + + if (rc != 0) { + /* The nRF and ESP32 drivers report an address NACK as + * -EIO. A timeout or a busy bus is not evidence the chip + * is missing, so that leaves the candidate UNPROBED. */ + if (rc == -EIO) { + s_state[i] = ZEPHCORE_RTC_ABSENT; + } + continue; } /* Mask off flag/century bits. We trust this is a real RTC (vs. an diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.h b/zephcore/adapters/clock/ZephyrRTCDiscover.h index 9795a91e7..4d2a3358c 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.h +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.h @@ -56,7 +56,8 @@ void zephcore_rtc_save(uint32_t epoch); enum zephcore_rtc_state { ZEPHCORE_RTC_UNPROBED = 0, /* not probed: discovery stopped before - * reaching it, or its bus was not ready */ + * reaching it, its bus was not ready, or + * the read failed other than with -EIO */ ZEPHCORE_RTC_ABSENT, /* no ACK, or a non-RTC chip sharing the address */ ZEPHCORE_RTC_PRESENT, /* an RTC answered and was accepted as one */ }; diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index af22e6c4c..9a4439b38 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -270,6 +270,22 @@ const char *declared_name_at(const char *bus, uint16_t addr) } #endif /* CONFIG_I2C */ +/* Candidates the probe could not settle either way: a "none present" is only + * true when this is zero. */ +unsigned rtc_unprobed_count(void) +{ + unsigned n = 0; + + for (size_t i = 0; i < zephcore_rtc_declared(); i++) { + struct zephcore_rtc_entry e; + + if (zephcore_rtc_get(i, &e) && e.state == ZEPHCORE_RTC_UNPROBED) { + n++; + } + } + return n; +} + const char *rtc_state_str(enum zephcore_rtc_state st) { switch (st) { @@ -362,6 +378,9 @@ void section_rtc(Sink *s, mesh::RTCClock *rtc) if (zephcore_rtc_active(&active)) { sink_line(s, "rtc: %s at 0x%02x on %s", active.name, active.addr, active.bus); + } else if (rtc_unprobed_count() > 0) { + sink_line(s, "rtc: none found, %u of %u declared unprobed", + rtc_unprobed_count(), (unsigned)declared); } else { sink_line(s, "rtc: none present (%u declared)", (unsigned)declared); } @@ -609,7 +628,11 @@ void section_summary(Sink *s, mesh::MainBoard *board, CommonCLICallbacks *cb) * about it than the section it summarises. */ sink_line(s, "rtc not yet probed"); } else { - sink_line(s, "rtc none present"); + if (rtc_unprobed_count() > 0) { + sink_line(s, "rtc none found, %u unprobed", rtc_unprobed_count()); + } else { + sink_line(s, "rtc none present"); + } } #if DT_NODE_EXISTS(DT_NODELABEL(gnss)) From 1b95502c1477036fb5f08f83e09823d49582627f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 01:11:21 -0700 Subject: [PATCH 07/55] docs: say what hw rtc's absent and present mean on each driver CodeRabbit pointed out that the nRF I2C drivers report every failed transfer as -EIO, so a bus error during the probe is labelled absent, and the docs implied only a missing chip could be. They now say so. Treating -EIO as unprobed instead would label every unfitted candidate on every nRF board unprobed, which is the answer the report exists to give. They also now say that present means the probe accepted the chip, which includes the existing rule of adopting a chip whose status register cannot be read. Co-Authored-By: Claude Opus 5.5 --- docs/Repeater_CLI_commands.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index b17403290..4ad784c96 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -105,9 +105,15 @@ Every field is something the firmware *knows*. It never infers: 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 boot probe never reached, or whose read failed for a reason other than - no reply (a timeout, a busy bus), reports `unprobed`, not `absent`. The probe - stops at the first chip holding a valid time. +- An RTC candidate the boot probe never reached, or whose read failed with a timeout or a busy + bus, 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 something answered but did not behave like an RTC. The nRF drivers also + report any other failed transfer as `-EIO`, so on nRF a bus error during the probe reads as + `absent` too; the driver gives nothing finer. `present` means the probe accepted the chip as + an RTC, which includes a chip whose time is not valid BCD when its power-loss flag is set or + cannot be read, since that chip is then adopted for write-back. - A build with no sensor support reports `not compiled in`, which is a different statement from a sensor manager that looked and found nothing. From 5108d4dd4b702596c686fd3d435249ba8d0394b6 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 01:12:39 -0700 Subject: [PATCH 08/55] docs: correct where hw rtc's absent and present are ambiguous Timeouts are -ETIMEDOUT on the nRF drivers too, so the previous wording contradicted the bullet above it. What every driver folds into -EIO is a data NACK, plus, on nRF, any other bus error event. And only the first accepted chip is adopted for write-back, which `*` marks. Co-Authored-By: Claude Opus 5.5 --- docs/Repeater_CLI_commands.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index 4ad784c96..219b0b6f6 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -109,11 +109,12 @@ Every field is something the firmware *knows*. It never infers: bus, 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 something answered but did not behave like an RTC. The nRF drivers also - report any other failed transfer as `-EIO`, so on nRF a bus error during the probe reads as - `absent` too; the driver gives nothing finer. `present` means the probe accepted the chip as - an RTC, which includes a chip whose time is not valid BCD when its power-loss flag is set or - cannot be read, since that chip is then adopted for write-back. + NACK, or that something answered but did not behave like an 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 accepted the chip as an RTC, which includes a chip whose time is not valid BCD when its + power-loss flag is set or cannot be read, since the first such chip is adopted for write-back + (marked `*`). - A build with no sensor support reports `not compiled in`, which is a different statement from a sensor manager that looked and found nothing. From 75c5c523686fe41d9bc72cd9840ea72a83bbcbf3 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 02:17:41 -0700 Subject: [PATCH 09/55] rtc: reset the adopted chip with the rest of each probe's outcome rtc_probe() resets every candidate's state on entry, because it can run more than once, but left s_active from an earlier run. A later probe that found nothing would then report every candidate unprobed or absent while save kept writing to the old chip. s_active is now reset with the states. Not reachable today: restore runs once at boot, and save only probes when restore never ran, when s_active is still NULL. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 --- zephcore/adapters/clock/ZephyrRTCDiscover.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.c b/zephcore/adapters/clock/ZephyrRTCDiscover.c index 27eebac56..4acb38802 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.c +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.c @@ -124,8 +124,10 @@ static bool rtc_probe(uint32_t *epoch_out) * zephcore_rtc_save() probes if restore never ran -- and it returns * early once a chip holds a valid time. Without this reset, candidates * the later run never reached would keep the previous run's PRESENT or - * ABSENT, which is precisely the stale claim UNPROBED exists to avoid. */ + * ABSENT, which is precisely the stale claim UNPROBED exists to avoid. + * The adopted chip is part of the same outcome, so it is reset too. */ memset(s_state, ZEPHCORE_RTC_UNPROBED, sizeof(s_state)); + s_active = NULL; for (size_t i = 0; i < ARRAY_SIZE(rtc_descs); i++) { const struct rtc_desc *d = &rtc_descs[i]; From 523162716d28e7bd33864837da463f27415c3e42 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 03:33:05 -0700 Subject: [PATCH 10/55] hw: leave room for a local reply prefix in the companion frame On a companion, hw pages to the app CLI frame, but a local request carrying an "xx|" prefix shares that frame with the prefix, so the page was sized three bytes too large. The cap now subtracts the prefix for local requests; remote requests already have it taken off by replyCap(). Only a five-digit resume index could have reached the frame edge. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 --- zephcore/helpers/CommonCLI.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index daabcb18c..0b64da41e 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -561,6 +561,10 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch if (cap > MAX_FRAME_SIZE - 1) { cap = MAX_FRAME_SIZE - 1; } + /* A local request's "xx|" prefix shares that frame too. */ + if (sender_timestamp == 0 && _reply_hdr_used < cap) { + cap -= _reply_hdr_used; + } #endif zephcore_hw::handle(command, reply, cap, sender_timestamp == 0, _board, _rtc, _callbacks); From 0212c7afd5141b811816de12916b97de5093afbf Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 06:27:44 -0700 Subject: [PATCH 11/55] rtc: do not adopt a chip on a status read that failed A failed status-register read counted as a set power-loss flag, so a chip whose time block was not valid BCD, an IMU at an RTC address for one, was accepted as an RTC, adopted for write-back, and had the time written into its registers on every sync. A power-loss flag now counts only when it was actually read as set; a chip with invalid time and an unreadable flag is skipped and reported absent. A chip with valid time and an unreadable flag is still adopted with its time untrusted, as before, and the log now says which of the two applies. Fixes #23. Raised repeatedly by CodeRabbit on #1 and #16. Co-Authored-By: Claude Opus 5.5 --- docs/Repeater_CLI_commands.md | 6 ++--- zephcore/adapters/clock/ZephyrRTCDiscover.c | 28 +++++++++++++-------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index 219b0b6f6..ff0717e99 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -112,9 +112,9 @@ Every field is something the firmware *knows*. It never infers: NACK, or that something answered but did not behave like an 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 accepted the chip as an RTC, which includes a chip whose time is not valid BCD when its - power-loss flag is set or cannot be read, since the first such chip is adopted for write-back - (marked `*`). + probe accepted the chip as an RTC: its time is valid BCD, or its power-loss flag was read as + set. A chip with invalid time whose flag cannot be read is `absent`, so it is never adopted + and written to. The first accepted chip is adopted for write-back (marked `*`). - A build with no sensor support reports `not compiled in`, which is a different statement from a sensor manager that looked and found nothing. diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.c b/zephcore/adapters/clock/ZephyrRTCDiscover.c index 4acb38802..032a24d0d 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.c +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.c @@ -102,8 +102,8 @@ static void civil_from_days(int64_t z, int *y, unsigned *m, unsigned *d) *y = yy + (*m <= 2); } -/* Read the chip's power-loss flag. true => held time is unreliable. */ -static bool rtc_time_unreliable(const struct rtc_desc *d, const uint8_t blk[7]) +/* Read the chip's power-loss flag: 1 set, 0 clear, -1 the status read failed. */ +static int rtc_power_lost(const struct rtc_desc *d, const uint8_t blk[7]) { if (d->status_reg == RTC_STATUS_IN_SECONDS) { return (blk[0] & d->status_mask) != 0; @@ -111,7 +111,7 @@ static bool rtc_time_unreliable(const struct rtc_desc *d, const uint8_t blk[7]) uint8_t st; if (i2c_reg_read_byte(d->bus, d->addr, d->status_reg, &st) != 0) { - return true; /* can't confirm => don't trust it */ + return -1; } return (st & d->status_mask) != 0; } @@ -167,16 +167,23 @@ static bool rtc_probe(uint32_t *epoch_out) bcd_field_ok(hb, 23) && bcd_field_ok(db, 31) && bcd_field_ok(ob, 12) && bcd_field_ok(yb, 99) && BCD2BIN(db) >= 1 && BCD2BIN(ob) >= 1; - bool unreliable = rtc_time_unreliable(d, blk); + int lost = rtc_power_lost(d, blk); - if (!bcd_ok && !unreliable) { + if (!bcd_ok && lost != 1) { /* Something ACKed here, but it does not behave like an RTC -- - * an IMU at 0x68, say. Absent means "no RTC here", which is - * the honest answer for reporting too. */ + * an IMU at 0x68, say. A failed status read is no evidence + * of a lost-power RTC, so it is not adopted on that either: + * adopting it would write the time into whatever chip it is. + * Absent means "no RTC here", the honest answer for + * reporting too. */ s_state[i] = ZEPHCORE_RTC_ABSENT; - continue; /* neither valid time nor a lost-power RTC => skip */ + continue; } + /* Valid time but an unreadable flag: adopt, but do not trust + * the time, as before. */ + bool unreliable = (lost != 0); + s_state[i] = ZEPHCORE_RTC_PRESENT; if (s_active == NULL) { @@ -184,8 +191,9 @@ static bool rtc_probe(uint32_t *epoch_out) } if (unreliable) { - LOG_WRN("%s present, power-loss flag set — clock will be set " - "on the next GPS/app/CLI sync", d->name); + LOG_WRN("%s present, %s — clock will be set on the next " + "GPS/app/CLI sync", d->name, + (lost > 0) ? "power-loss flag set" : "status unreadable"); continue; } From 4c2c112b62c473a052a88308623eaed9cb1ffee0 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 12:43:21 -0700 Subject: [PATCH 12/55] hw i2c scan: stop a bus that times out instead of probing every address A failed probe was treated as an empty address, so a dead or held bus was probed at all 112 addresses. An nRF I2C transfer on a dead bus can take the driver's full timeout each time (the t1000_e devicetree notes 500 ms), which would block the CLI thread for close to a minute per bus. A probe that returns -ETIMEDOUT or -EBUSY now stops that bus with "bus not responding"; -EIO, how every driver here reports no answer, is still an ordinary miss. Found by Copilot. Co-Authored-By: Claude Opus 5.5 --- docs/Repeater_CLI_commands.md | 4 +++- zephcore/helpers/HardwareReport.cpp | 19 +++++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index ff0717e99..eb2bbcffe 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -72,7 +72,9 @@ This is deliberate and the replies say which is which. does not declare.** - **`hw i2c scan`** probes the wire with a zero-length write to each address. It finds undeclared hardware, but a bare address is all the bus itself reveals; declared addresses - are annotated with their compatible. + are annotated with their compatible. 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. diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index 9a4439b38..49bea0a56 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -459,8 +459,21 @@ void section_i2c_scan(Sink *s) * some controller drivers assert on NULL before looking at len. */ uint8_t probe = 0; + bool stuck = false; + for (uint16_t addr = 0x08; addr <= 0x77; addr++) { - if (i2c_write(bus, &probe, 0, addr) != 0) { + int rc = i2c_write(bus, &probe, 0, addr); + + if (rc == -ETIMEDOUT || rc == -EBUSY) { + /* A dead or held bus can take the driver's whole + * timeout on every address, blocking the CLI thread + * for most of a minute. -EIO is an ordinary miss. */ + 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++; @@ -514,7 +527,9 @@ void section_i2c_scan(Sink *s) } if (found == 0) { - sink_line(s, "%s: none found", bus->name); + if (!stuck) { + sink_line(s, "%s: none found", bus->name); + } } else if (used > header) { sink_line(s, "%s", line); } From f2badf2589819577de9bce422854e712ab37b75c Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 12:47:32 -0700 Subject: [PATCH 13/55] hw i2c scan: print what a stopped bus found before saying it stopped When a bus stopped after some addresses were already found, the stop line printed first and the pending address line after it, so the addresses read as found after the scan stopped. The pending line is now flushed before the stop line. Co-Authored-By: Claude Opus 5.5 --- zephcore/helpers/HardwareReport.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index 49bea0a56..d10788839 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -467,7 +467,13 @@ void section_i2c_scan(Sink *s) if (rc == -ETIMEDOUT || rc == -EBUSY) { /* A dead or held bus can take the driver's whole * timeout on every address, blocking the CLI thread - * for most of a minute. -EIO is an ordinary miss. */ + * for most of a minute. -EIO is an ordinary miss. + * Flush what was found first, so it reads as found + * before the stop. */ + 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; From 0f413ed06ab346695d03612bfb9e1b96a37baea8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 13:04:40 -0700 Subject: [PATCH 14/55] hw: dispatch only on the whole word, and say why a cut line sets no marker The CLI dispatch matched any command beginning with "hw", so "hwboard" reached the report as "hw board" instead of falling through to "Unknown command". It now requires end of line or a space after "hw". A cut line deliberately leaves the page unmarked: the page then holds cap - 1 bytes, so the next line fails the fit test and sets next:N itself, and with no next line there is nothing to resume. The comment says so, since reviewers kept reading it as a lost resume index. Both found by Copilot. Co-Authored-By: Claude Opus 5.5 --- zephcore/helpers/CommonCLI.cpp | 2 +- zephcore/helpers/HardwareReport.cpp | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index 0b64da41e..772bff84d 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -550,7 +550,7 @@ 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) { + } else if (memcmp(command, "hw", 2) == 0 && (command[2] == '\0' || command[2] == ' ')) { /* replyCap() allows for a remote reply's "xx|" prefix. On a * companion a local reply may be the app's CLI frame, which carries * MAX_FRAME_SIZE - 1 bytes and cannot be told apart from the USB diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index d10788839..55d68372c 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -111,7 +111,10 @@ void sink_line(Sink *s, const char *fmt, ...) if (s->len == 0 && need >= s->cap) { /* A line longer than a whole page would otherwise never be * emitted, and every resume would point back at it. Emit what - * fits, marked as cut, so paging moves on. */ + * fits, marked as cut, so paging moves on. The page is not + * marked full here: it now holds cap - 1 bytes, so any next + * line fails the fit test below and sets ` next:N` itself, and + * with no next line there is nothing left to resume. */ size_t keep = s->cap - 1; line[keep] = '\0'; if (keep > 3) { From 898ebaaf45ec982c32623b9d55d52d3884d34ef6 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 13:19:03 -0700 Subject: [PATCH 15/55] hw: compile the report only where CommonCLI is, and say it is local on a companion HardwareReport.cpp sat in the unconditional source list, so observer builds, which leave CommonCLI out and offer no `hw`, still compiled it. It now builds next to CommonCLI in the two role blocks that have it. An observer build no longer has the object. On a companion, remote admin commands reach the CLI only from upstream's allowlist, and ZephCore-only commands stay local by design, so `hw` answers locally only there. The docs now say so instead of leaving it implied. Both found by Copilot. Co-Authored-By: Claude Opus 5.5 --- docs/Repeater_CLI_commands.md | 4 +++- zephcore/CMakeLists.txt | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index eb2bbcffe..818a77452 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -43,7 +43,9 @@ All commands are sent over USB serial (CDC-ACM). Commands sent remotely over the 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`. +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 | |---------|-------------| diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index c64c25062..9e668056d 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -368,7 +368,6 @@ target_sources(app PRIVATE # Captures the reset cause before anything consumes it; every role needs it # at boot, so it is never conditional. helpers/boot_info.c - helpers/HardwareReport.cpp helpers/oled_power.c helpers/fatal_reboot.c # LED master gate ("set leds on|off"). Always compiled: helpers/ui/ui_common.c @@ -535,6 +534,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 @@ -611,6 +611,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 From 3f810fe2315d4e5c0dd1e7c7d2b3f4716b5daec8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 13:31:34 -0700 Subject: [PATCH 16/55] hw gps: say a disabled GNSS node is disabled, not configured `hw gps` and the summary reported any `gnss` node that existed, but the GPS manager uses it only when the node and its UART are both okay (HAS_GPS_UART in gps_internal.h). A disabled one was reported with a driver and transport it does not have. It now reads "declared but disabled" (summary: "disabled"), using the same condition. Checked by building rak4631 with &gnss disabled. Found by Copilot. Co-Authored-By: Claude Opus 5.5 --- docs/Repeater_CLI_commands.md | 2 +- zephcore/helpers/HardwareReport.cpp | 20 ++++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index 818a77452..7f5a35284 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -54,7 +54,7 @@ commands only from upstream's set, and ZephCore-only commands stay local by desi | `hw rtc [start]` | Declared I2C RTC candidates and the boot probe'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 | +| `hw gps [start]` | GNSS driver compatible, transport, baud, and enable state. A GNSS node, or its UART, disabled in devicetree reports `declared but disabled` (the summary says `disabled`), since the GPS manager does not use it | | `hw sensors [start]` | Each sensor the boot probe found, environment and power monitors alike, and the fields it reports | | `hw all [start]` | `board`, `rtc`, `i2c`, `gps` and `sensors`, in that order. Not the summary, and not `hw i2c scan`, which puts traffic on the bus | | `hw ` | Usage string | diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index 55d68372c..8de9771eb 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -36,6 +36,15 @@ extern "C" { #define HW_HAS_GPS_HDR 1 #endif +/* The GNSS node and its UART both okay: the GPS manager's own condition + * (HAS_GPS_UART, adapters/gps/gps_internal.h). */ +#if DT_NODE_EXISTS(DT_NODELABEL(gnss)) +#define HW_GNSS_OKAY (DT_NODE_HAS_STATUS(DT_NODELABEL(gnss), okay) && \ + DT_NODE_HAS_STATUS(DT_BUS(DT_NODELABEL(gnss)), okay)) +#else +#define HW_GNSS_OKAY 0 +#endif + namespace zephcore_hw { namespace { @@ -555,7 +564,12 @@ void section_i2c_scan(Sink *s) void section_gps(Sink *s, CommonCLICallbacks *cb) { -#if DT_NODE_EXISTS(DT_NODELABEL(gnss)) +#if DT_NODE_EXISTS(DT_NODELABEL(gnss)) && !HW_GNSS_OKAY + /* The GPS manager needs the node and its UART both okay + * (HAS_GPS_UART in gps_internal.h); a disabled one is declared, not used. */ + sink_line(s, "gnss: %s declared but disabled in devicetree", + DT_PROP_BY_IDX(DT_NODELABEL(gnss), compatible, 0)); +#elif DT_NODE_EXISTS(DT_NODELABEL(gnss)) sink_line(s, "gnss: %s", DT_PROP_BY_IDX(DT_NODELABEL(gnss), compatible, 0)); sink_line(s, " on %s", DT_NODE_FULL_NAME(DT_PARENT(DT_NODELABEL(gnss)))); #if DT_NODE_HAS_PROP(DT_PARENT(DT_NODELABEL(gnss)), current_speed) @@ -659,7 +673,9 @@ void section_summary(Sink *s, mesh::MainBoard *board, CommonCLICallbacks *cb) } } -#if DT_NODE_EXISTS(DT_NODELABEL(gnss)) +#if DT_NODE_EXISTS(DT_NODELABEL(gnss)) && !HW_GNSS_OKAY + sink_line(s, "gnss %s disabled", DT_PROP_BY_IDX(DT_NODELABEL(gnss), compatible, 0)); +#elif DT_NODE_EXISTS(DT_NODELABEL(gnss)) sink_line(s, "gnss %s", DT_PROP_BY_IDX(DT_NODELABEL(gnss), compatible, 0)); #else sink_line(s, "gnss none"); From 755e28a21259cb64254da24e444aadfe344fd535 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 13:32:45 -0700 Subject: [PATCH 17/55] hw gps: say the disabled check is the devicetree half of HAS_GPS_UART The comment called it the GPS manager's own condition, but HAS_GPS_UART also needs a supported compatible, which the available line reports. Co-Authored-By: Claude Opus 5.5 --- zephcore/helpers/HardwareReport.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index 8de9771eb..9abda1957 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -36,8 +36,9 @@ extern "C" { #define HW_HAS_GPS_HDR 1 #endif -/* The GNSS node and its UART both okay: the GPS manager's own condition - * (HAS_GPS_UART, adapters/gps/gps_internal.h). */ +/* The GNSS node and its UART both okay: the devicetree half of the GPS + * manager's HAS_GPS_UART (adapters/gps/gps_internal.h), which also needs a + * supported compatible; `available` below reports that part. */ #if DT_NODE_EXISTS(DT_NODELABEL(gnss)) #define HW_GNSS_OKAY (DT_NODE_HAS_STATUS(DT_NODELABEL(gnss), okay) && \ DT_NODE_HAS_STATUS(DT_BUS(DT_NODELABEL(gnss)), okay)) From 4385a205289b9680a18c637b85a183caf3e465b7 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 13:46:35 -0700 Subject: [PATCH 18/55] hw: mark a line longer than the render buffer as cut sink_line() renders into a 160-byte buffer, and vsnprintf silently cut a longer line there, so a long compatible or node name read as the complete value. A line that did not fit now ends in "...", as a line cut to fit the page already does. Checked by compiling the sink on the host: a 200-character line ends in "..." on a 256-byte and a 158-byte page, and paging continues. Found by Copilot. Co-Authored-By: Claude Opus 5.5 --- docs/Repeater_CLI_commands.md | 2 +- zephcore/helpers/HardwareReport.cpp | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index 7f5a35284..c4d85b047 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -129,7 +129,7 @@ any `xx|` reply prefix, over remote admin, since a remote reply rides the caller 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. When a section does not fit, the reply ends with ` next:N` and `hw
N` (or `hw N` for the summary) resumes from that -line. A single line too long for a whole page is cut to fit and ends in `...`, so a resume +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. Each page is computed afresh, so a resumed `hw i2c scan` re-scans the bus. If a chip appears or diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index 9abda1957..bcdc70e9d 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -116,6 +116,10 @@ void sink_line(Sink *s, const char *fmt, ...) if (w < 0) { return; } + if ((size_t)w >= sizeof(line)) { + /* Longer than the render buffer: mark the cut, as below. */ + memcpy(line + sizeof(line) - 4, "...", 3); + } size_t need = strlen(line) + (s->len ? 1 : 0); if (s->len == 0 && need >= s->cap) { From 018b73cacb335ddbff989c99eb805d0665963fc1 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 13:58:28 -0700 Subject: [PATCH 19/55] hw: leave room for a local reply prefix on every role, not only companions RepeaterMesh and RoomServerMesh strip a local "xx|" prefix and move reply three bytes into their 256-byte cli_reply_buf, but replyCap() returns the full CLI_REPLY_SIZE for a local request. The prefix was taken off the hw page only on companions, so a tagged local `hw` on a repeater or room server could write ` next:N` up to three bytes past the buffer. The subtraction now applies to every role. Found by Copilot. Co-Authored-By: Claude Opus 5.5 --- zephcore/helpers/CommonCLI.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index 772bff84d..d9a5af9a2 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -561,11 +561,12 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch if (cap > MAX_FRAME_SIZE - 1) { cap = MAX_FRAME_SIZE - 1; } - /* A local request's "xx|" prefix shares that frame too. */ +#endif + /* replyCap() does not take a local request's "xx|" prefix off, but + * every role's handleCommand() has already moved reply past it. */ if (sender_timestamp == 0 && _reply_hdr_used < cap) { cap -= _reply_hdr_used; } -#endif zephcore_hw::handle(command, reply, cap, sender_timestamp == 0, _board, _rtc, _callbacks); } else if (memcmp(command, "ver", 3) == 0) { From 0fed09c16a4ab3b696f5507fb23b0f86084ed398 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 15:23:46 -0700 Subject: [PATCH 20/55] docs: list a bus that was not ready as an unprobed RTC case The probe skips a candidate whose I2C bus failed device_is_ready() and leaves it UNPROBED, which the hw rtc notes did not list. Co-Authored-By: Claude Opus 5.5 --- docs/Repeater_CLI_commands.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index c4d85b047..d17e002f9 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -109,9 +109,9 @@ Every field is something the firmware *knows*. It never infers: 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 boot probe never reached, or whose read failed with a timeout or a busy - bus, reports `unprobed`, not `absent`. The probe stops at the first chip holding a valid - time. +- An RTC candidate the boot probe never reached, whose I2C bus was not ready, or whose read + failed with a timeout or a busy bus, 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 something answered but did not behave like an 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 From 04c5459eb1d159e1fdde8d5ccd55026a03a6a550 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 15:33:30 -0700 Subject: [PATCH 21/55] hw: answer a start index past the last line instead of an empty reply `hw rtc 99` emitted no line, and CompanionMesh::handleCommand treats an empty reply as unhandled, so the app answered "Unknown command" for a valid command. A page holding no lines now says which line was asked for and how many the report has. Co-Authored-By: Claude Opus 5.5 --- docs/Repeater_CLI_commands.md | 2 +- zephcore/helpers/HardwareReport.cpp | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index d17e002f9..8ff630910 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -130,7 +130,7 @@ buffer. On a companion a local reply is bounded by the app's CLI frame (175 byte because the app path cannot be told apart from the USB console. When a section does not fit, the reply ends with ` next:N` 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. +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 diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index bcdc70e9d..520009006 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -157,6 +157,12 @@ void sink_line(Sink *s, const char *fmt, ...) /* Append the resume marker if the page was cut short. */ void sink_finish(Sink *s) { + if (s->len == 0 && !s->full) { + /* A start past the last line: an empty reply reads as an unknown + * command on a companion. */ + snprintf(s->buf, s->buf_cap, "no line %u, the report has %u", s->first, s->line); + return; + } if (!s->full) { return; } From 47c064942a6da1f7989107cd2947fb71752ba135 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 15:54:51 -0700 Subject: [PATCH 22/55] hw i2c scan: name every declaration at a shared address The common sensor list declares bme280_alt and bmp388 both at 0x77, and the scan labelled the address with the first match, so a fitted BMP388 read as bosch,bme280. A scan cannot tell which part answered, so it now lists every compatible declared there, '|'-separated, falling back to the bare address when they do not fit. Co-Authored-By: Claude Opus 5.5 --- docs/Repeater_CLI_commands.md | 4 +++- zephcore/helpers/HardwareReport.cpp | 34 ++++++++++++++++++++--------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index 52d4860d8..c59fdce21 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -74,7 +74,9 @@ This is deliberate and the replies say which is which. does not declare.** - **`hw i2c scan`** probes the wire with a zero-length write to each address. It finds undeclared hardware, but a bare address is all the bus itself reveals; declared addresses - are annotated with their compatible. A probe that times out or finds the bus busy stops + 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. diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index 520009006..8615912a1 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -207,7 +207,7 @@ struct I2cDecl { * DT_NODE_FULL_NAME(node)), and the live scan and the RTC section both report * `dev->name`. A bus node carrying a `label` would therefore print one string * in `hw i2c` and a different one in `hw i2c scan` and `hw rtc`, and - * declared_name_at() -- which matches the two by string -- would silently stop + * declared_names_at() -- which matches the two by string -- would silently stop * annotating scanned addresses. * * No board in this tree labels an I2C node today, so the two derivations agree @@ -280,16 +280,29 @@ constexpr size_t i2c_bus_ref_count() { return ARRAY_SIZE(i2c_bus_refs) - 1; } #endif /* CONFIG_I2C */ #if IS_ENABLED(CONFIG_I2C) -/* Name a scanned address from the devicetree, when the board declared it. */ -const char *declared_name_at(const char *bus, uint16_t addr) +/* Name a scanned address from the devicetree, when the board declared it. + * Every declaration at that address is listed, '|'-separated: the scan cannot + * tell which of two parts sharing an address (BME280 and BMP388 at 0x77) is + * the one that answered. Returns false when nothing is declared there. */ +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) { - return i2c_decls[i].compat; + 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 (w < 0 || (size_t)w >= cap - used) { + /* Too long to name: the caller keeps the address alone. */ + out[0] = '\0'; + return false; } + used += (size_t)w; } - return nullptr; + return used > 0; } #endif /* CONFIG_I2C */ @@ -507,7 +520,8 @@ void section_i2c_scan(Sink *s) } found++; - const char *nm = declared_name_at(bus->name, addr); + char nm[64]; + bool named = declared_names_at(bus->name, addr, nm, sizeof(nm)); /* Render the token on its own first, so the decision to * wrap is made against a known length and an address can @@ -517,8 +531,8 @@ void section_i2c_scan(Sink *s) * address while found++ had already counted it, so the * total and the listing disagreed. */ char tok[72]; - int tw = nm ? snprintf(tok, sizeof(tok), " 0x%02x(%s)", addr, nm) - : snprintf(tok, sizeof(tok), " 0x%02x", addr); + int tw = named ? snprintf(tok, sizeof(tok), " 0x%02x(%s)", addr, nm) + : snprintf(tok, sizeof(tok), " 0x%02x", addr); if (tw < 0 || (size_t)tw >= sizeof(tok)) { /* Annotation too long for the token buffer. The From bf3e96a30b6795e7bc3b4701b4068c7046adf7ea Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 3 Oct 2026 10:58:11 -0700 Subject: [PATCH 23/55] Move the RTC status-read fix out of the hw CLI branch Revert 0212c7a. The fix for fork issue #23 is an upstream dev defect, not part of the hw command, and now has its own iteration branch and PR (work/rtc-probe-failed-status-read, #30). This branch carries the hw CLI alone. The probe's adoption rule and the hw rtc docs describe dev's behaviour again; the hw accessors and the absent/present/unprobed states are unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/Repeater_CLI_commands.md | 6 ++--- zephcore/adapters/clock/ZephyrRTCDiscover.c | 28 ++++++++------------- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index c59fdce21..1e61944aa 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -118,9 +118,9 @@ Every field is something the firmware *knows*. It never infers: NACK, or that something answered but did not behave like an 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 accepted the chip as an RTC: its time is valid BCD, or its power-loss flag was read as - set. A chip with invalid time whose flag cannot be read is `absent`, so it is never adopted - and written to. The first accepted chip is adopted for write-back (marked `*`). + probe accepted the chip as an RTC, which includes a chip whose time is not valid BCD when its + power-loss flag is set or cannot be read, since the first such chip is adopted for write-back + (marked `*`). - A build with no sensor support reports `not compiled in`, which is a different statement from a sensor manager that looked and found nothing. diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.c b/zephcore/adapters/clock/ZephyrRTCDiscover.c index 032a24d0d..4acb38802 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.c +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.c @@ -102,8 +102,8 @@ static void civil_from_days(int64_t z, int *y, unsigned *m, unsigned *d) *y = yy + (*m <= 2); } -/* Read the chip's power-loss flag: 1 set, 0 clear, -1 the status read failed. */ -static int rtc_power_lost(const struct rtc_desc *d, const uint8_t blk[7]) +/* Read the chip's power-loss flag. true => held time is unreliable. */ +static bool rtc_time_unreliable(const struct rtc_desc *d, const uint8_t blk[7]) { if (d->status_reg == RTC_STATUS_IN_SECONDS) { return (blk[0] & d->status_mask) != 0; @@ -111,7 +111,7 @@ static int rtc_power_lost(const struct rtc_desc *d, const uint8_t blk[7]) uint8_t st; if (i2c_reg_read_byte(d->bus, d->addr, d->status_reg, &st) != 0) { - return -1; + return true; /* can't confirm => don't trust it */ } return (st & d->status_mask) != 0; } @@ -167,23 +167,16 @@ static bool rtc_probe(uint32_t *epoch_out) bcd_field_ok(hb, 23) && bcd_field_ok(db, 31) && bcd_field_ok(ob, 12) && bcd_field_ok(yb, 99) && BCD2BIN(db) >= 1 && BCD2BIN(ob) >= 1; - int lost = rtc_power_lost(d, blk); + bool unreliable = rtc_time_unreliable(d, blk); - if (!bcd_ok && lost != 1) { + if (!bcd_ok && !unreliable) { /* Something ACKed here, but it does not behave like an RTC -- - * an IMU at 0x68, say. A failed status read is no evidence - * of a lost-power RTC, so it is not adopted on that either: - * adopting it would write the time into whatever chip it is. - * Absent means "no RTC here", the honest answer for - * reporting too. */ + * an IMU at 0x68, say. Absent means "no RTC here", which is + * the honest answer for reporting too. */ s_state[i] = ZEPHCORE_RTC_ABSENT; - continue; + continue; /* neither valid time nor a lost-power RTC => skip */ } - /* Valid time but an unreadable flag: adopt, but do not trust - * the time, as before. */ - bool unreliable = (lost != 0); - s_state[i] = ZEPHCORE_RTC_PRESENT; if (s_active == NULL) { @@ -191,9 +184,8 @@ static bool rtc_probe(uint32_t *epoch_out) } if (unreliable) { - LOG_WRN("%s present, %s — clock will be set on the next " - "GPS/app/CLI sync", d->name, - (lost > 0) ? "power-loss flag set" : "status unreadable"); + LOG_WRN("%s present, power-loss flag set — clock will be set " + "on the next GPS/app/CLI sync", d->name); continue; } From 2612e667a74b0e8a7bf5130dd565050be9dcbbef Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 3 Oct 2026 10:58:29 -0700 Subject: [PATCH 24/55] docs: list the hw command in the architecture's CLI summary The iteration branch work/hw-cli (#1) carries this line and this branch did not; with it, the two branches hold the same hw CLI. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6c9f0b775..83b0813e6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -736,6 +736,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 `Repeater_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 ` From 311ed79959b20b60a4f4c1f1041ac095e148d9f2 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 3 Oct 2026 11:11:49 -0700 Subject: [PATCH 25/55] docs: say which RTC is adopted, and that hw sensors reads each sensor The RTC note said the first non-BCD chip with its flag set or unreadable is the one adopted for write-back. The probe adopts the first chip it accepts, of either kind. hw sensors and its comment described "the fields its last reading carried"; env_sensor_read() takes a fresh sample on every call, so hw sensors and hw all read each sensor as a telemetry request does. The hw all row now says so. Co-Authored-By: Claude Opus 5.5 --- docs/Repeater_CLI_commands.md | 6 +++--- zephcore/helpers/HardwareReport.cpp | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index 1e61944aa..5c7d38ab3 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -55,8 +55,8 @@ commands only from upstream's set, and ZephCore-only commands stay local by desi | `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, or its UART, disabled in devicetree reports `declared but disabled` (the summary says `disabled`), since the GPS manager does not use it | -| `hw sensors [start]` | Each sensor the boot probe found, environment and power monitors alike, and the fields it reports | -| `hw all [start]` | `board`, `rtc`, `i2c`, `gps` and `sensors`, in that order. Not the summary, and not `hw i2c scan`, which puts traffic on the bus | +| `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 is read on the spot, as for a telemetry request | +| `hw all [start]` | `board`, `rtc`, `i2c`, `gps` and `sensors`, in that order, so it reads each sensor. 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). @@ -119,7 +119,7 @@ Every field is something the firmware *knows*. It never infers: 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 accepted the chip as an RTC, which includes a chip whose time is not valid BCD when its - power-loss flag is set or cannot be read, since the first such chip is adopted for write-back + power-loss flag is set or cannot be read. The first accepted chip is adopted for write-back (marked `*`). - A build with no sensor support reports `not compiled in`, which is a different statement from a sensor manager that looked and found nothing. diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index 8615912a1..5e8c6f94d 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -628,7 +628,7 @@ void section_sensors(Sink *s) { #if defined(HW_HAS_SENSOR_HDR) && IS_ENABLED(CONFIG_SENSOR) /* One line per part the boot probe found, environment and power - * monitors alike, with the fields its last reading carried. */ + * monitors alike, with the fields a fresh read of it returns. */ int n = env_sensor_count(); if (n <= 0) { From e3bd329b16aa3c056db9f3a43a200862c0d73405 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 3 Oct 2026 11:18:18 -0700 Subject: [PATCH 26/55] hw: make three header comments true of the code HardwareReport.h named the rak4631's receiver as CASIC, which the firmware cannot know, and said an enable pin of -1 prints as -1, though no formatter prints a pin. ZephyrRTCDiscover.h said the accessors leave probing unchanged; each probe run now starts from no adopted chip, and the comment says so. Co-Authored-By: Claude Opus 5.5 --- zephcore/adapters/clock/ZephyrRTCDiscover.h | 5 +++-- zephcore/helpers/HardwareReport.h | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.h b/zephcore/adapters/clock/ZephyrRTCDiscover.h index 4d2a3358c..61559d569 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.h +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.h @@ -44,8 +44,9 @@ void zephcore_rtc_save(uint32_t epoch); * * Discovery knows which chips the board declares and which one it adopted, but * until now kept both to itself, so a release build could not say what RTC it - * is driving (every MESH_DEBUG_* call is compiled away). These expose that - * without changing any probing behaviour. + * is driving (every MESH_DEBUG_* call is compiled away). These expose that. + * The one change to probing: each probe run starts from no adopted chip, so a + * second run reports its own outcome rather than the first run's. * * They report only what discovery actually established. The probe loop stops * at the first chip holding a valid time, so candidates after it are never diff --git a/zephcore/helpers/HardwareReport.h b/zephcore/helpers/HardwareReport.h index 8b136ae7e..2f4d27fdf 100644 --- a/zephcore/helpers/HardwareReport.h +++ b/zephcore/helpers/HardwareReport.h @@ -14,9 +14,9 @@ * The rule every formatter here follows: report what the code KNOWS, never * what could be inferred. A devicetree comment naming a GNSS part is not * evidence the part is fitted (the rak4631 overlay documents a u-blox MAX-7Q - * while binding gnss-nmea-generic, which happily drives the CASIC receiver - * actually present). So a driver names itself, an unprobed address reports as - * unprobed rather than absent, and an enable pin of -1 prints as -1. + * while binding gnss-nmea-generic, which drives whatever NMEA receiver is + * fitted). So a driver names itself, and an unprobed address reports as + * unprobed rather than absent. * * Named `hw`, not `hwinfo`, because is a Zephyr * subsystem this tree already uses; a CLI command sharing that name would read From e821439f229d765d7f7c46bb7184f3ca8f6aa5d8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 3 Oct 2026 11:34:33 -0700 Subject: [PATCH 27/55] hw: state the reply cap a caller passes, and drop a stale scan note The header described cap as replyCap(), which is three bytes too many for a local request behind an "xx|" prefix: the one caller already takes the prefix off, and the contract now says a caller must. The I2C scan's comment described an earlier shape and a total the output no longer prints. Co-Authored-By: Claude Opus 5.5 --- zephcore/helpers/HardwareReport.cpp | 6 +----- zephcore/helpers/HardwareReport.h | 6 +++--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index 5e8c6f94d..ce04718f3 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -525,11 +525,7 @@ void section_i2c_scan(Sink *s) /* Render the token on its own first, so the decision to * wrap is made against a known length and an address can - * never fall between the two branches. The previous shape - * -- format into the line, retry once on a fresh line, - * and do nothing if that also failed -- dropped the - * address while found++ had already counted it, so the - * total and the listing disagreed. */ + * never be dropped. */ char tok[72]; int tw = named ? snprintf(tok, sizeof(tok), " 0x%02x(%s)", addr, nm) : snprintf(tok, sizeof(tok), " 0x%02x", addr); diff --git a/zephcore/helpers/HardwareReport.h b/zephcore/helpers/HardwareReport.h index 2f4d27fdf..6c54e6f97 100644 --- a/zephcore/helpers/HardwareReport.h +++ b/zephcore/helpers/HardwareReport.h @@ -39,9 +39,9 @@ namespace zephcore_hw { /* * Write the reply for `command`, which is the full CLI line starting at "hw". - * `cap` is the caller's reply capacity: CommonCLI::replyCap(), which allows for - * a remote reply's "xx|" prefix, and on a companion at most one app CLI frame - * (MAX_FRAME_SIZE - 1). + * `cap` is the bytes writable at `reply`, terminator included: replyCap(), on + * a companion at most one app CLI frame (MAX_FRAME_SIZE - 1), less any "xx|" + * prefix the role has already written ahead of `reply`, local or remote. * * `local` is true only when the request did not arrive over the air. Note it * is NOT a test for "this is a serial console": on companion firmware the USB From cc546707baae47ea2a39f3f299272836f372dad9 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 3 Oct 2026 11:48:52 -0700 Subject: [PATCH 28/55] docs: the RTC count with autodiscovery off, and the local prefix zephcore_rtc_declared() is documented as the board's declared count, but its stub returns 0 when autodiscovery is compiled out; the comment now says so (hw checks devicetree itself for that case). The paging note took the "xx|" prefix off remote replies only; the caller takes it off local ones too. Co-Authored-By: Claude Opus 5.5 --- docs/Repeater_CLI_commands.md | 9 +++++---- zephcore/adapters/clock/ZephyrRTCDiscover.h | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index 5c7d38ab3..2e106cb92 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -126,10 +126,11 @@ Every field is something the firmware *knows*. It never infers: ### Paging -A reply is bounded by `CLI_REPLY_SIZE` (256) locally and by `CLI_REMOTE_REPLY_SIZE` (161), less -any `xx|` reply prefix, 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. When a section does not fit, +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 ` next:N` 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`. diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.h b/zephcore/adapters/clock/ZephyrRTCDiscover.h index 61559d569..4c2a1269c 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.h +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.h @@ -72,7 +72,8 @@ struct zephcore_rtc_entry { bool active; /* adopted as the write-back target */ }; -/* Number of RTC candidates this board declares in devicetree. 0 if none. */ +/* 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); /* Fill *out for declared candidate i. False if i is out of range. */ From db69172c66cea391efda4c24b8dc00ce95acad9c Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 8 Oct 2026 06:37:37 -0700 Subject: [PATCH 29/55] rtc: clear the RV3028 12-hour bit at boot ZephCore assumed 24-hour mode. If other firmware had left an RV3028 in 12-hour mode, the boot read masked the hours with 0x3F and could restore a time up to twelve hours off, and each save's 24-hour hours byte meant a different hour to the chip. A new optional twelve-hour-bit = [register mask] names the bit, set on the two RV3028 nodes to Control 2 12_24. Once the chip is cleanly identified at boot, rtc_probe() clears a set bit and re-reads the time block; the chip converts its own hours, as MeshCore relies on. If the register cannot be read, reads FFh, or the clear or re-read fails, no time is taken from the chip. DS3231/DS1307 are unchanged: a save's hours byte already selects 24-hour mode there. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 2 +- zephcore/adapters/clock/ZephyrRTCDiscover.c | 35 +++++++++++++++++++ zephcore/adapters/clock/ZephyrRTCDiscover.h | 4 ++- zephcore/boards/common/rtc-i2c.dtsi | 1 + .../boards/nrf52840/rak4631/board.overlay | 1 + .../dts/bindings/rtc/zephcore,rtc-i2c.yaml | 13 ++++++- 6 files changed, 53 insertions(+), 3 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b9365f151..e79158651 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -860,7 +860,7 @@ Repeaters and room servers default to `CONFIG_ZEPHCORE_REPEATER_GPS_INTERVAL_SEC **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. -**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. 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 diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.c b/zephcore/adapters/clock/ZephyrRTCDiscover.c index d439811df..6de57586d 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), \ }, @@ -224,6 +232,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]) @@ -584,6 +616,9 @@ static bool rtc_probe(uint32_t *epoch_out) if (v == RTC_ABSENT) { continue; } + 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 diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.h b/zephcore/adapters/clock/ZephyrRTCDiscover.h index 7b745ec07..4af5b28ef 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.h +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.h @@ -28,7 +28,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. 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/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/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 From 2eb8a8e5b7bcf75e02bf2ba67bc678437cb59a4f Mon Sep 17 00:00:00 2001 From: ZephCore T096 Date: Wed, 26 Aug 2026 19:45:45 +0200 Subject: [PATCH 30/55] t096: shared PWM brightness for the heartbeat/TX LED (unpersisted) Single LED (P0.28) doubles as heartbeat, message flash, shutdown flash and LoRa TX indicator, all sharing the same physical pin. Adds one PWM channel on that pin (heartbeat-pwm-led/lora-tx-pwm-led aliases) and a single 0-100 brightness knob (get/set led) covering every one of those events at once, deliberately RAM-only: resets to a 10% default on every reboot regardless of CLI changes made in a previous session. The existing persisted on/off gate (set leds on|off) is untouched. Boards without heartbeat-pwm-led/lora-tx-pwm-led aliases keep their current plain-GPIO behavior unchanged. --- zephcore/adapters/board/ZephyrBoard.cpp | 47 ++++++++-- .../heltec_t096_nrf52840-pinctrl.dtsi | 16 ++++ .../heltec_t096/heltec_t096_nrf52840.dts | 33 +++++++ .../heltec_t096_nrf52840_defconfig | 3 + zephcore/helpers/CommonCLI.cpp | 16 ++++ zephcore/helpers/led_gate.c | 18 ++++ zephcore/helpers/led_gate.h | 13 +++ zephcore/helpers/ui/ui_common.c | 91 ++++++++++++++----- 8 files changed, 210 insertions(+), 27 deletions(-) diff --git a/zephcore/adapters/board/ZephyrBoard.cpp b/zephcore/adapters/board/ZephyrBoard.cpp index 7310826fe..d1eb11d43 100644 --- a/zephcore/adapters/board/ZephyrBoard.cpp +++ b/zephcore/adapters/board/ZephyrBoard.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -57,14 +58,34 @@ #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 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. + * + * PWM path (DT_ALIAS(lora_tx_pwm_led)) takes priority when it exists — used + * on boards where the TX LED is the same physical LED as the heartbeat + * (e.g. T096, see helpers/ui/ui_common.c), so both share the one brightness + * in helpers/led_gate.h instead of the plain GPIO path fighting over the + * pin. */ +#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 +static int tx_led_init(void) +{ + if (device_is_ready(tx_led_pwm.dev)) { + pwm_set_pulse_dt(&tx_led_pwm, 0); /* start dark, same as GPIO_OUTPUT_INACTIVE */ + } + return 0; +} +SYS_INIT(tx_led_init, APPLICATION, 90); +#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 @@ -364,12 +385,24 @@ 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 + /* PWM-shared boards (T096) don't yet have the RX-pulse/pin-hold interlock + * below -- that machinery (s_tx_lit/s_rx_pulse_off) is declared only under + * HAS_TX_LED. Mode-gated brightness write only for now; see the rebase + * notes for why this is a deliberate gap, not an oversight. */ + uint8_t mode = zephcore_leds_radio_mode(); + if (!zephcore_leds_disabled() && + (mode == LEDS_RADIO_TX || mode == LEDS_RADIO_ALL)) { + uint32_t pulse = (uint32_t)((uint64_t)tx_led_pwm.period * + zephcore_led_brightness_pct() / 100); + pwm_set_pulse_dt(&tx_led_pwm, pulse); + } +#elif HAS_TX_LED uint8_t mode = zephcore_leds_radio_mode(); if (!zephcore_leds_disabled() && (mode == LEDS_RADIO_TX || mode == LEDS_RADIO_ALL)) { @@ -387,7 +420,9 @@ void ZephyrBoard::onBeforeTransmit() void ZephyrBoard::onAfterTransmit() { -#if HAS_TX_LED +#if HAS_TX_LED_PWM + pwm_set_pulse_dt(&tx_led_pwm, 0); +#elif HAS_TX_LED atomic_set(&s_tx_lit, 0); gpio_pin_set_dt(&tx_led, 0); #if ZEPHCORE_LED_PIN_SHARED 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/heltec_t096/heltec_t096_nrf52840_defconfig b/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840_defconfig index 98c300e89..22dd9147b 100644 --- a/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840_defconfig +++ b/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840_defconfig @@ -10,6 +10,9 @@ CONFIG_HW_STACK_PROTECTION=y # Enable GPIO CONFIG_GPIO=y +# Enable PWM (shared heartbeat/TX LED brightness) +CONFIG_PWM=y + # Enable UART driver CONFIG_SERIAL=y diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index 47db72f37..be3f73113 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -994,6 +994,11 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, const char* command, cha CLI_HAS_HB_LED ? "" : " (no heartbeat LED on this board)"); } else if (memcmp(config, "leds", 4) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->leds_disabled ? "off" : "on"); + } else if (memcmp(config, "led", 3) == 0) { + /* Shared heartbeat/TX brightness, RAM-only — see led_gate.h. + * Checked after "leds" above so "leds" itself never falls + * through to this shorter prefix. */ + snprintf(reply, CLI_REPLY_SIZE, "> %u%%", (unsigned)zephcore_led_brightness_pct()); #ifndef ZEPHCORE_REPEATER } else if (memcmp(config, "buzzer", 6) == 0) { uint8_t mode = zephcore_buzzer_mode_from_prefs(_prefs->buzzer_quiet); @@ -1276,6 +1281,17 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, const char* command, cha savePrefs(); strcpy(reply, "OK"); } + } else if (memcmp(config, "led ", 4) == 0) { + /* Brightness only, on/off stays on "set leds" above. RAM-only — + * no savePrefs() call here on purpose, see led_gate.h. Checked + * after "leds " so that command never falls through here. */ + int pct = atoi(&config[4]); + if (pct < 0 || pct > 100) { + strcpy(reply, "Error: must be 0-100"); + } else { + zephcore_led_set_brightness_pct((uint8_t)pct); + snprintf(reply, CLI_REPLY_SIZE, "OK - led=%d%%", pct); + } #ifndef ZEPHCORE_REPEATER } else if (memcmp(config, "buzzer ", 7) == 0) { /* 0 = silent, 1 = sound + vibration, 2 = vibration only, diff --git a/zephcore/helpers/led_gate.c b/zephcore/helpers/led_gate.c index 610bbb68a..a9c06a3e0 100644 --- a/zephcore/helpers/led_gate.c +++ b/zephcore/helpers/led_gate.c @@ -74,3 +74,21 @@ void zephcore_led_radio_hold_pin(bool held) { atomic_set(&s_radio_holds_pin, held ? 1 : 0); } + +/* Same cross-thread readers/writers as the gate above (heartbeat work queue, + * TX path, CLI) — atomic for the same reason. RAM-only on purpose: no + * savePrefs() call anywhere near this, see led_gate.h. */ +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); +} diff --git a/zephcore/helpers/led_gate.h b/zephcore/helpers/led_gate.h index 726dfacfc..b75774290 100644 --- a/zephcore/helpers/led_gate.h +++ b/zephcore/helpers/led_gate.h @@ -34,6 +34,19 @@ 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 boards whose heartbeat/TX LED is + * PWM-capable (DT_ALIAS(heartbeat_pwm_led) / DT_ALIAS(lora_tx_pwm_led)). + * Deliberately RAM-only, not persisted: resets to + * ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT on every reboot regardless of any + * "set led " done during a previous session. Independent of the on/off + * gate above: brightness only matters while the gate is open. No-op on + * boards without a PWM-capable LED (the getter still returns the compiled + * default, simply unused). */ +#define ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT 10 + +uint8_t zephcore_led_brightness_pct(void); +void zephcore_led_set_brightness_pct(uint8_t pct); + /* 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/ui/ui_common.c b/zephcore/helpers/ui/ui_common.c index ca7865d87..3c5a9cbb0 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,63 @@ 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 path (brightness-capable, shared with the LoRa TX LED via the same + * DT_ALIAS(heartbeat_pwm_led)/DT_ALIAS(lora_tx_pwm_led) pair when a board's + * heartbeat and TX indicator are the same physical LED, e.g. T096) takes + * priority over the plain digital led0/led1 fallback. Brightness comes from + * helpers/led_gate.h — one dimmer for every event on this LED, not a + * heartbeat-only one. */ +#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 +/* "Some heartbeat LED exists, PWM or plain GPIO" — used where the on/off + * write differs but everything else (work queue plumbing, readiness check) + * is identical either way. */ +#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) +{ + uint32_t pulse = on ? (uint32_t)((uint64_t)s_heartbeat_led_pwm.period * + zephcore_led_brightness_pct() / 100) + : 0; + pwm_set_pulse_dt(&s_heartbeat_led_pwm, pulse); +} +#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 +146,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 +186,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 +208,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 +222,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 +233,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 +256,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 +282,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 +317,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 +333,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)); } From bb49aa185cd2833bed5505429c5817f303d8ef30 Mon Sep 17 00:00:00 2001 From: ZephCore T096 Date: Wed, 26 Aug 2026 19:55:16 +0200 Subject: [PATCH 31/55] t096: fold LED brightness into set/get leds instead of a new command Reuses the existing leds on|off switch for brightness too, same "one command, wording vs number" convention as led.tx/led.hb on _scratch_leds_test, rather than introducing a separate led command. Drops the legacy '1'/'0' single-char aliases from set leds while at it: same 1%/0% shadowing bug already found and fixed on led.tx/led.hb on 2026-08-16, latent here until a numeric value became meaningful. get leds now reports both: "on N%" / "off (N%)". --- zephcore/helpers/CommonCLI.cpp | 65 +++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index be3f73113..c1c092722 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -993,12 +993,16 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, const char* command, cha cliModeName(LEDS_HB_NAMES, 4, _prefs->leds_hb_mode), CLI_HAS_HB_LED ? "" : " (no heartbeat LED on this board)"); } else if (memcmp(config, "leds", 4) == 0) { - snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->leds_disabled ? "off" : "on"); - } else if (memcmp(config, "led", 3) == 0) { - /* Shared heartbeat/TX brightness, RAM-only — see led_gate.h. - * Checked after "leds" above so "leds" itself never falls - * through to this shorter prefix. */ - snprintf(reply, CLI_REPLY_SIZE, "> %u%%", (unsigned)zephcore_led_brightness_pct()); + /* Brightness (RAM-only, see led_gate.h) reported alongside the + * persisted on/off gate, same "state N%" convention as led.tx/ + * led.hb on the RAK3401 branch. */ + if (_prefs->leds_disabled) { + snprintf(reply, CLI_REPLY_SIZE, "> off (%u%%)", + (unsigned)zephcore_led_brightness_pct()); + } else { + snprintf(reply, CLI_REPLY_SIZE, "> on %u%%", + (unsigned)zephcore_led_brightness_pct()); + } #ifndef ZEPHCORE_REPEATER } else if (memcmp(config, "buzzer", 6) == 0) { uint8_t mode = zephcore_buzzer_mode_from_prefs(_prefs->buzzer_quiet); @@ -1269,28 +1273,39 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, const char* command, cha CLI_HAS_HB_LED ? "" : " (no heartbeat LED on this board)"); } } 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. */ - int on = cliOnOff(&config[5], cliDefaults()->leds_disabled ? 0 : 1); - if (on < 0) { - strcpy(reply, "Error: must be on, off or default"); - } else { - _prefs->leds_disabled = on ? 0 : 1; - zephcore_leds_set_disabled(_prefs->leds_disabled != 0); + /* Master switch (persisted) 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. + * + * Same value orthogonal to a shared brightness (RAM-only, see + * led_gate.h) as led.tx/led.hb on the RAK3401 branch: "on"/"off" + * words only, no "1"/"0" aliases (those would shadow the 1% and + * 0% brightness values — the exact ambiguity already found and + * removed from led.tx/led.hb on 2026-08-16). A bare 0-100 number + * sets the brightness alone and never touches the switch. */ + const char* val = &config[5]; + if (memcmp(val, "on", 2) == 0 && val[2] == '\0') { + _prefs->leds_disabled = 0; + zephcore_leds_set_disabled(false); + savePrefs(); + strcpy(reply, "OK"); + } else if (memcmp(val, "off", 3) == 0 && val[3] == '\0') { + _prefs->leds_disabled = 1; + zephcore_leds_set_disabled(true); savePrefs(); strcpy(reply, "OK"); - } - } else if (memcmp(config, "led ", 4) == 0) { - /* Brightness only, on/off stays on "set leds" above. RAM-only — - * no savePrefs() call here on purpose, see led_gate.h. Checked - * after "leds " so that command never falls through here. */ - int pct = atoi(&config[4]); - if (pct < 0 || pct > 100) { - strcpy(reply, "Error: must be 0-100"); } else { - zephcore_led_set_brightness_pct((uint8_t)pct); - snprintf(reply, CLI_REPLY_SIZE, "OK - led=%d%%", pct); + 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 on, off, or 0-100"); + } else { + zephcore_led_set_brightness_pct((uint8_t)pct); + snprintf(reply, CLI_REPLY_SIZE, "OK - leds=%ld%%", pct); + } } #ifndef ZEPHCORE_REPEATER } else if (memcmp(config, "buzzer ", 7) == 0) { From c8c456b64a5597cc58a5fc3245a7b582ed3694f4 Mon Sep 17 00:00:00 2001 From: ZephCore T096 Date: Thu, 27 Aug 2026 18:28:15 +0200 Subject: [PATCH 32/55] t096: split leds.brightness from leds on|off (final Option 1 syntax) Reverts the previous merge: "leds" now only ever parses on/off (a pure word command again, no numeric fallback), and brightness moves to its own "leds.brightness <0-100>" command instead of overloading "leds" with a value that could be either a word or a number. Checked before "leds " in the chain since it shares the same 4-byte prefix. Still RAM-only, no savePrefs() call in the brightness branch. This is the syntax settled on after comparing two options: reuse "leds" via a namespaced brightness command (chosen), vs a full per-LED led.hb/led.tx split like the RAK3401 branch (not chosen, decided to apply the "leds"/"leds.brightness" shape everywhere without exception, including any future RAK3401 rework). --- zephcore/helpers/CommonCLI.cpp | 62 ++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index c1c092722..47656f9bd 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -982,8 +982,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,17 +992,13 @@ 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) { + /* Separate from "leds" on purpose: keeps the on/off switch a + * pure word command and the dimmer a pure number command, + * instead of one command parsing both — see led_gate.h. */ + snprintf(reply, CLI_REPLY_SIZE, "> %u%%", (unsigned)zephcore_led_brightness_pct()); } else if (memcmp(config, "leds", 4) == 0) { - /* Brightness (RAM-only, see led_gate.h) reported alongside the - * persisted on/off gate, same "state N%" convention as led.tx/ - * led.hb on the RAK3401 branch. */ - if (_prefs->leds_disabled) { - snprintf(reply, CLI_REPLY_SIZE, "> off (%u%%)", - (unsigned)zephcore_led_brightness_pct()); - } else { - snprintf(reply, CLI_REPLY_SIZE, "> on %u%%", - (unsigned)zephcore_led_brightness_pct()); - } + snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->leds_disabled ? "off" : "on"); #ifndef ZEPHCORE_REPEATER } else if (memcmp(config, "buzzer", 6) == 0) { uint8_t mode = zephcore_buzzer_mode_from_prefs(_prefs->buzzer_quiet); @@ -1272,18 +1268,33 @@ 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) { + /* Separate from "leds" on purpose: keeps the on/off switch a pure + * word command and the dimmer a pure number command, instead of + * one command parsing both. RAM-only, see led_gate.h — no + * savePrefs() call here. Checked before "leds " below so this + * longer, more specific prefix is never shadowed by it. */ + 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 { + zephcore_led_set_brightness_pct((uint8_t)pct); + snprintf(reply, CLI_REPLY_SIZE, "OK - leds.brightness=%ld%%", pct); + } } else if (memcmp(config, "leds ", 5) == 0) { /* Master switch (persisted) 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. - * - * Same value orthogonal to a shared brightness (RAM-only, see - * led_gate.h) as led.tx/led.hb on the RAK3401 branch: "on"/"off" - * words only, no "1"/"0" aliases (those would shadow the 1% and - * 0% brightness values — the exact ambiguity already found and - * removed from led.tx/led.hb on 2026-08-16). A bare 0-100 number - * sets the brightness alone and never touches the switch. */ + * UI brightness setting. "on"/"off" words only, no "1"/"0" + * aliases (removed from led.tx/led.hb on 2026-08-16 for the same + * reason: they used to collide with the 1% and 0% brightness + * values on that branch — moot here since brightness now lives + * under "leds.brightness" instead, but kept out anyway for + * consistency and to reject typos cleanly). */ const char* val = &config[5]; if (memcmp(val, "on", 2) == 0 && val[2] == '\0') { _prefs->leds_disabled = 0; @@ -1296,16 +1307,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, const char* command, cha savePrefs(); strcpy(reply, "OK"); } else { - 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 on, off, or 0-100"); - } else { - zephcore_led_set_brightness_pct((uint8_t)pct); - snprintf(reply, CLI_REPLY_SIZE, "OK - leds=%ld%%", pct); - } + strcpy(reply, "Error: must be on or off"); } #ifndef ZEPHCORE_REPEATER } else if (memcmp(config, "buzzer ", 7) == 0) { From 4905191b1c863b86ae769eecf100eb24f80617bc Mon Sep 17 00:00:00 2001 From: ZephCore T096 Date: Thu, 27 Aug 2026 20:36:31 +0200 Subject: [PATCH 33/55] wt2: shared PWM brightness for the status LED (heartbeat/TX/flashes) Ports the T096 "Option 1" design (single LED, get/set leds on|off persisted + get/set leds.brightness 0-100 RAM-only) to the Wireless Tracker V2, which has the same single-LED-multi-purpose situation (status_led doubles as heartbeat/message-flash/shutdown-flash/TX indicator, same as T096's P0.28). Adds a new ledc0 PWM channel on GPIO18 (same physical pin as the existing plain-GPIO status_led) with heartbeat-pwm-led/lora-tx-pwm-led aliases -- the shared C++ logic (helpers/ui/ui_common.c, adapters/board/ZephyrBoard.cpp, helpers/led_gate.c/h, helpers/CommonCLI.cpp) already existed from the T096 commits and needed no changes, being board-agnostic behind DT_NODE_EXISTS checks. CONFIG_PWM=y deliberately NOT in the board defconfig: WT2 builds via --sysbuild (mcuboot + app as two images sharing that defconfig), and mcuboot's minimal kernel config has no k_sem_give()/k_sem_take(), which the ESP32 PWM LED driver needs -- CONFIG_PWM=y there links the app image fine but fails mcuboot's link. New boards/common/esp32s3_pwm_led.conf scopes it to the app image only via EXTRA_CONF_FILE, alongside the existing esp32s3_usb.conf. --- zephcore/boards/common/esp32s3_pwm_led.conf | 15 +++++++++ .../heltec_wireless_tracker_v2-pinctrl.dtsi | 7 ++++ .../heltec_wireless_tracker_v2_procpu.dts | 32 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 zephcore/boards/common/esp32s3_pwm_led.conf diff --git a/zephcore/boards/common/esp32s3_pwm_led.conf b/zephcore/boards/common/esp32s3_pwm_led.conf new file mode 100644 index 000000000..97f8f3c58 --- /dev/null +++ b/zephcore/boards/common/esp32s3_pwm_led.conf @@ -0,0 +1,15 @@ +# Enable PWM on ESP32-S3 sysbuild targets (shared heartbeat/TX LED brightness, +# see the heartbeat-pwm-led / lora-tx-pwm-led devicetree aliases consumed by +# helpers/ui/ui_common.c and adapters/board/ZephyrBoard.cpp). +# +# Deliberately NOT in the board *_defconfig: that file is shared by every +# sysbuild image for the board, including mcuboot, whose minimal kernel +# config has no k_sem_give()/k_sem_take() -- CONFIG_PWM=y there links fine for +# the app image but fails mcuboot's link with "undefined reference to +# z_impl_k_sem_give/take" (drivers/pwm/pwm_led_esp32.c uses a semaphore). +# EXTRA_CONF_FILE scopes this to the app ("zephcore") image only. +# +# Build with: +# -DEXTRA_CONF_FILE="boards/common/esp32s3_usb.conf;boards/common/esp32s3_pwm_led.conf" + +CONFIG_PWM=y 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..36bb17a34 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,22 @@ }; }; + /* 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 (same + * "Option 1" shared-brightness pattern as T096, see helpers/ui/ui_common.c + * and adapters/board/ZephyrBoard.cpp: heartbeat-pwm-led/lora-tx-pwm-led + * aliases give every one of those events a single 0-100 brightness knob, + * RAM-only, 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 +250,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 = ; From ec2a07dfa983fef6483d33310f1300e1597efef3 Mon Sep 17 00:00:00 2001 From: ZephCore T096 Date: Fri, 4 Sep 2026 19:50:20 +0200 Subject: [PATCH 34/55] leds: persist brightness across reboots and firmware updates leds.brightness (PWM-capable boards) was RAM-only since it was added: it always reset to ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT (10%) on every boot, regardless of what a user had last set it to. New field led_brightness on NodePrefs, following the exact same append-only pattern as leds_radio_mode/leds_hb_mode added just before it: offset 311 in RepeaterDataStore.cpp (repeater/room-server/observer, shared file), offset 174 in ZephyrDataStore.cpp (companion). Both are backward compatible by construction: a prefs file written before this field existed simply leaves it at the initNodePrefs() default (10%), which is exactly the behaviour every already-deployed node has today -- no special-casing needed to keep "10% on a new/not-yet-upgraded node" as the effective default. CommonCLI.cpp's "set leds.brightness" handler now writes the prefs field and calls savePrefs(), same as the leds.radio/leds.hb handlers right above it; "get leds.brightness" now reads from prefs instead of the RAM getter directly, for the same reason those two already do (always in sync, prefs is the source of truth). The four boot call sites (main_companion.cpp, main_repeater.cpp, main_room_server.cpp, main_observer.cpp) each gained a zephcore_led_set_brightness_pct() call seeding the RAM cache from the persisted value, mirroring the existing leds_radio_mode/leds_hb_mode lines right next to them. Verified building for heltec_t096/nrf52840 (companion) and rak3401_1watt (repeater), the two roles/datastores touched by this change. --- zephcore/helpers/CommonCLI.cpp | 17 +++++++++++++---- zephcore/helpers/NodePrefs.h | 3 +++ zephcore/helpers/PrefsCodec.cpp | 10 ++++++++++ zephcore/helpers/PrefsJson.cpp | 1 + zephcore/helpers/boot_prefs.h | 1 + zephcore/src/main_companion.cpp | 1 + 6 files changed, 29 insertions(+), 4 deletions(-) diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index 47656f9bd..37f761754 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -995,8 +995,11 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, const char* command, cha } else if (memcmp(config, "leds.brightness", 15) == 0) { /* Separate from "leds" on purpose: keeps the on/off switch a * pure word command and the dimmer a pure number command, - * instead of one command parsing both — see led_gate.h. */ - snprintf(reply, CLI_REPLY_SIZE, "> %u%%", (unsigned)zephcore_led_brightness_pct()); + * instead of one command parsing both — see led_gate.h. Read + * from _prefs, same as leds.radio/leds.hb above, not from the + * RAM getter directly -- the two are always in sync, the "set" + * handler below writes both together. */ + 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 @@ -1271,8 +1274,12 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, const char* command, cha } else if (memcmp(config, "leds.brightness ", 16) == 0) { /* Separate from "leds" on purpose: keeps the on/off switch a pure * word command and the dimmer a pure number command, instead of - * one command parsing both. RAM-only, see led_gate.h — no - * savePrefs() call here. Checked before "leds " below so this + * one command parsing both. Persisted (since 1.17.4): a fresh + * node still starts at ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT (the + * append-only prefs format leaves this byte at the + * initNodePrefs() default on any file that predates this field), + * but once set it survives reboots and firmware updates like + * leds.radio/leds.hb above. Checked before "leds " below so this * longer, more specific prefix is never shadowed by it. */ const char *val = &config[16]; char *endptr = (char *)val; @@ -1282,7 +1289,9 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, const char* command, cha 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) { diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index c5e2b289b..151f2c6da 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 @@ -306,6 +307,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 +416,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; // 10%, new node prefs->powersaving_enabled = POWERSAVING_DEFAULT; prefs->powersaving_set = 1; prefs->gps_enabled = 0; diff --git a/zephcore/helpers/PrefsCodec.cpp b/zephcore/helpers/PrefsCodec.cpp index a22984d27..aaa881f8b 100644 --- a/zephcore/helpers/PrefsCodec.cpp +++ b/zephcore/helpers/PrefsCodec.cpp @@ -187,6 +187,11 @@ bool companionPrefsDecode(NodePrefs &prefs, const uint8_t *buf, size_t len) r.take(&p.tz_offset, 1); r.take(&p.leds_radio_mode, 1); r.take(&p.leds_hb_mode, 1); + /* led_brightness (ZephCore extension, since 1.17.4). Absent in files + * written before this field existed; the no-op EOF read leaves the + * initNodePrefs() default ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT (10%), + * same value a fresh node already showed before this was persisted. */ + r.take(&p.led_brightness, 1); r.take(&p.wifi_enabled, 1); r.take(p.wifi_ssid, sizeof(p.wifi_ssid)); r.take(p.wifi_pwd, sizeof(p.wifi_pwd)); @@ -355,6 +360,11 @@ void serverPrefsDecode(NodePrefs &prefs, const uint8_t *buf, size_t len) r.get(&prefs.tz_offset, sizeof(prefs.tz_offset)); r.get(&prefs.leds_radio_mode, sizeof(prefs.leds_radio_mode)); r.get(&prefs.leds_hb_mode, sizeof(prefs.leds_hb_mode)); + /* led_brightness (ZephCore extension, since 1.17.4). Absent in files + * written before this field existed; the no-op EOF read leaves the + * initNodePrefs() default ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT (10%), + * same value a fresh node already showed before this was persisted. */ + r.get(&prefs.led_brightness, sizeof(prefs.led_brightness)); /* Only the explicit "off" magic disables LEDs; a legacy AGC interval or an * unwritten byte both mean "on". */ 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/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"); From 50ca1502433c170786e7515407187d7f192a166e Mon Sep 17 00:00:00 2001 From: ZephCore T096 Date: Fri, 4 Sep 2026 20:21:48 +0200 Subject: [PATCH 35/55] board: extend TX/RX LED pin-share arbitration to PWM-shared boards The RX-pulse/pin-hold interlock (s_tx_lit/s_rx_pulse_off/ zephcore_led_radio_hold_pin) was gated by HAS_TX_LED alone, so it never compiled in for any PWM-capable board (HAS_TX_LED_PWM). Left two real gaps: - T096 and Wireless Tracker V2 alias heartbeat and TX activity to the SAME physical PWM LED (ZEPHCORE_LED_PIN_SHARED's intent exactly), but had none of the arbitration that protects every plain-GPIO board with the same wiring -- a transmit and a heartbeat tick could still clip each other on the shared PWM channel. - All PWM boards, RAK3401 included, had no onPacketReceived() blink at all: "set leds.radio rx" (or "all") silently did nothing on the activity LED, since that whole code path only existed under HAS_TX_LED. Fixed by widening every "#if HAS_TX_LED" guard around this machinery to "#if HAS_TX_LED_PWM || HAS_TX_LED", and adding a small tx_led_write(bool) helper (mirroring heartbeat_led_write() in helpers/ui/ui_common.c) so the shared interlock logic doesn't need to know which path is compiled in -- only the final write differs. ZEPHCORE_LED_PIN_SHARED itself gained a PWM branch: true when heartbeat-pwm-led and lora-tx-pwm-led alias the same DT node (T096, WT2), same as it already was for the GPIO led0/lora-tx-led case. RAK3401's two PWM LEDs are on distinct nodes, so it correctly stays unshared -- no arbitration overhead there, but it does now get the RX blink like every other PWM board. The heartbeat side (helpers/ui/ui_common.c) needed no change: it already checks zephcore_led_radio_holds_pin() unconditionally through heartbeat_led_write(), from the LED brightness persistence commit earlier today. Verified building for heltec_t096/nrf52840 (shared PWM LED), heltec_wireless_tracker_v2/esp32s3 (shared PWM LED), and rak3401_1watt (two independent PWM LEDs, confirms ZEPHCORE_LED_PIN_SHARED stays 0 there). --- zephcore/adapters/board/ZephyrBoard.cpp | 85 +++++++++++++++---------- 1 file changed, 51 insertions(+), 34 deletions(-) diff --git a/zephcore/adapters/board/ZephyrBoard.cpp b/zephcore/adapters/board/ZephyrBoard.cpp index d1eb11d43..18ba6e1fa 100644 --- a/zephcore/adapters/board/ZephyrBoard.cpp +++ b/zephcore/adapters/board/ZephyrBoard.cpp @@ -71,14 +71,8 @@ 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 -static int tx_led_init(void) -{ - if (device_is_ready(tx_led_pwm.dev)) { - pwm_set_pulse_dt(&tx_led_pwm, 0); /* start dark, same as GPIO_OUTPUT_INACTIVE */ - } - return 0; -} -SYS_INIT(tx_led_init, APPLICATION, 90); +/* Init deferred to the combined tx_led_init() below (with the GPIO path), + * since both now also need to arm s_rx_pulse_off -- see that block. */ #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); @@ -90,7 +84,8 @@ static const struct gpio_dt_spec tx_led = #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 + * (8 of the supported boards do, counting only the plain-GPIO ones -- see the + * PWM branch below for the rest). 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. */ @@ -101,10 +96,36 @@ 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 +/* PWM equivalent: true when heartbeat and TX activity are aliased to the same + * PWM LED node (T096, Wireless Tracker V2). Not the case on every PWM board -- + * RAK3401 has two independent PWM LEDs (green/blue), so this stays 0 there and + * the hold below is correctly never taken. */ +#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 +/* PWM-or-GPIO write for the activity LED, mirroring heartbeat_led_write() in + * helpers/ui/ui_common.c -- same reasoning: the write itself must not care + * which path is compiled in, only the callers (onBeforeTransmit/ + * onAfterTransmit/onPacketReceived/rx_pulse_off_handler below) do. */ +#if HAS_TX_LED_PWM +static inline void tx_led_write(bool on) +{ + uint32_t pulse = on ? (uint32_t)((uint64_t)tx_led_pwm.period * + zephcore_led_brightness_pct() / 100) + : 0; + pwm_set_pulse_dt(&tx_led_pwm, pulse); +} +#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); @@ -160,8 +181,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 @@ -174,7 +195,9 @@ static const struct device *const pmic_charger_dev = * interlock that keeps an RX one-shot from clearing a pin that TX still owns: * the two are driven from different threads (radio TX path vs system work * queue), so without it a pulse landing mid-transmit would blank the LED for - * the rest of the packet. */ + * the rest of the packet. Applies equally to the PWM-shared boards (T096, + * Wireless Tracker V2): same race, same fix, just written through + * tx_led_write() instead of a bare gpio_pin_set_dt(). */ static atomic_t s_tx_lit; static struct k_work_delayable s_rx_pulse_off; @@ -186,7 +209,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 @@ -194,9 +217,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; } @@ -389,20 +418,10 @@ void ZephyrBoard::onBeforeTransmit() * ("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 - /* PWM-shared boards (T096) don't yet have the RX-pulse/pin-hold interlock - * below -- that machinery (s_tx_lit/s_rx_pulse_off) is declared only under - * HAS_TX_LED. Mode-gated brightness write only for now; see the rebase - * notes for why this is a deliberate gap, not an oversight. */ - uint8_t mode = zephcore_leds_radio_mode(); - if (!zephcore_leds_disabled() && - (mode == LEDS_RADIO_TX || mode == LEDS_RADIO_ALL)) { - uint32_t pulse = (uint32_t)((uint64_t)tx_led_pwm.period * - zephcore_led_brightness_pct() / 100); - pwm_set_pulse_dt(&tx_led_pwm, pulse); - } -#elif HAS_TX_LED + * flipped mid-transmit can't strand it lit. Same interlock on PWM-shared + * boards (T096, Wireless Tracker V2) as on plain-GPIO ones -- only the + * final write (tx_led_write()) differs. */ +#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)) { @@ -413,18 +432,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_PWM - pwm_set_pulse_dt(&tx_led_pwm, 0); -#elif 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 @@ -433,7 +450,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)) { @@ -449,7 +466,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)); From 0a15194eaa5bcfbbf060fa46756cfd183e258259 Mon Sep 17 00:00:00 2001 From: ZephCore T096 Date: Fri, 4 Sep 2026 21:11:29 +0200 Subject: [PATCH 36/55] board: wire V4.3's existing PWM LED node into the brightness/mode system heltec_wifi_lora32_v43_procpu.dts already declared a pwm-leds node for its single onboard LED (pwm_led_white, LEDC0/GPIO35, generic pwm-led0 alias) -- it just never had the two project-specific aliases (heartbeat-pwm-led / lora-tx-pwm-led) that helpers/ui/ui_common.c and adapters/board/ZephyrBoard.cpp actually look for. T096 and Wireless Tracker V2 have had this wired for a while; V4.3 was simply never included in that earlier rollout, not blocked by anything in its hardware -- the PWM path was sitting there unused this whole time, confirmed by grepping for any other consumer of pwm-led0/pwm_led_white before touching it (none). Aliased both roles to the same node, same as T096/WT2: this board has only one physical LED, shared between heartbeat and LoRa TX activity. ZEPHCORE_LED_PIN_SHARED and the RX-pulse/pin-hold arbitration (extended to PWM boards earlier today, commit a57d4c7) apply automatically since both aliases point at the same node. Gives V4.3 the full leds.radio/leds.hb/leds.brightness set for the first time: leds.radio previously had no effect at all on this board (no activity LED aliased whatsoever), leds.brightness was accepted but inert (no PWM-capable LED reachable). Verified building for heltec_wifi_lora32_v43/esp32s3 with boards/common/esp32s3_pwm_led.conf (same flag already used for WT2). --- .../heltec_wifi_lora32_v43_procpu.dts | 7 +++++++ 1 file changed, 7 insertions(+) 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; From c92c9ff0f549a6c10886b5dccfa53b6e2282b28e Mon Sep 17 00:00:00 2001 From: ZephCore T096 Date: Sat, 5 Sep 2026 00:26:50 +0200 Subject: [PATCH 37/55] leds: default brightness 100%, matching pre-existing hardware behavior ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT was 10 on the branch this was developed on (a local preference for that project's own compiles). For general use it should default to what every PWM LED already ran at before this setting existed: full brightness. Also fixes a stale led_gate.h comment that still said "RAM-only, not persisted" after brightness became a persisted NodePrefs field. --- zephcore/helpers/NodePrefs.h | 2 +- zephcore/helpers/led_gate.h | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index 151f2c6da..89139f926 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -416,7 +416,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; // 10%, new node + 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/led_gate.h b/zephcore/helpers/led_gate.h index b75774290..699186288 100644 --- a/zephcore/helpers/led_gate.h +++ b/zephcore/helpers/led_gate.h @@ -36,13 +36,13 @@ void zephcore_leds_set_disabled(bool disabled); /* Shared PWM brightness (0-100) for boards whose heartbeat/TX LED is * PWM-capable (DT_ALIAS(heartbeat_pwm_led) / DT_ALIAS(lora_tx_pwm_led)). - * Deliberately RAM-only, not persisted: resets to - * ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT on every reboot regardless of any - * "set led " done during a previous session. Independent of the on/off - * gate above: brightness only matters while the gate is open. No-op on - * boards without a PWM-capable LED (the getter still returns the compiled - * default, simply unused). */ -#define ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT 10 + * Persisted via NodePrefs (prefs.led_brightness); ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT + * only seeds the RAM cache on a fresh node with no saved value yet (100%, + * matching the brightness every PWM LED ran at before this setting existed). + * Independent of the on/off gate above: brightness only matters while the + * gate is open. No-op on boards without a PWM-capable LED (the getter still + * returns the compiled default, simply unused). */ +#define ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT 100 uint8_t zephcore_led_brightness_pct(void); void zephcore_led_set_brightness_pct(uint8_t pct); From f04546df5646b3803c5e264d726e5eb0db257936 Mon Sep 17 00:00:00 2001 From: ZephCore T096 Date: Wed, 30 Sep 2026 17:38:54 +0200 Subject: [PATCH 38/55] prefs: encode led_brightness too, not just decode; fix pinned test layout companionPrefsEncode()/serverPrefsEncode() never wrote the led_brightness byte that companionPrefsDecode()/serverPrefsDecode() already read, desyncing every field that follows it by one byte on the companion side (wifi_enabled/wifi_ssid/wifi_pwd) and silently truncating the server format by one byte. Caught by CI's ASan/UBSan run on the host regression suite: an out-of-bounds read/write in the pinned layout tests. Since led_brightness never existed upstream, nothing already accounted for it: fixing only the encoder still leaves the pinned byte tables and the documented 272/311-byte totals (COMPANION_PREFS_SIZE/ SERVER_PREFS_SIZE, used by the real prefs stores too, not just tests) one byte short of what the encoder now actually produces. - PrefsCodec.h: bump both size constants (272->273, 311->312). - PrefsCodec.cpp: add the missing w.put() in both encoders, mirroring the position already used by both decoders. - prefs_codec_tests.cpp: add led_brightness to both layout tables at its real file offset (shifts wifi_enabled/wifi_ssid/wifi_pwd by one byte on the companion side; server's is appended last, nothing else shifts), extend both historical-boundary length lists with the new full-format length, and give it a non-default value in sample() so the layout/roundtrip tests actually exercise it. Co-Authored-By: Claude Sonnet 5 --- tests/unit/prefs_codec_tests.cpp | 20 ++++++++++---------- zephcore/helpers/PrefsCodec.cpp | 3 +++ zephcore/helpers/PrefsCodec.h | 4 ++-- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/tests/unit/prefs_codec_tests.cpp b/tests/unit/prefs_codec_tests.cpp index c8f26d809..7b7864d16 100644 --- a/tests/unit/prefs_codec_tests.cpp +++ b/tests/unit/prefs_codec_tests.cpp @@ -13,7 +13,7 @@ namespace { struct Field { size_t file_off; size_t size; size_t struct_off; }; #define F(off, member) { off, sizeof(NodePrefs::member), offsetof(NodePrefs, member) } -// Companion /lfs/new_prefs, 272 bytes. Pads at 36-39 and 79. +// Companion /lfs/new_prefs, 273 bytes. Pads at 36-39 and 79. const Field kCompanion[] = { F(0, airtime_factor), F(4, node_name), F(40, node_lat), F(48, node_lon), F(56, freq), F(60, sf), F(61, cr), F(62, client_repeat), F(63, manual_add_contacts), F(64, bw), @@ -29,11 +29,11 @@ const Field kCompanion[] = { F(153, v_battery_alert_mv), F(155, cad_auto), F(156, cad_offset), F(157, probe_interval), F(158, cad_busycap), F(159, adc_multiplier), F(163, extra_sf), F(166, v_contact_flags), F(167, fem_rxgain), F(168, display_rotate), F(169, input_rotate), F(170, cad_base), - F(171, tz_offset), F(172, leds_radio_mode), F(173, leds_hb_mode), F(174, wifi_enabled), - F(175, wifi_ssid), F(208, wifi_pwd), + F(171, tz_offset), F(172, leds_radio_mode), F(173, leds_hb_mode), F(174, led_brightness), + F(175, wifi_enabled), F(176, wifi_ssid), F(209, wifi_pwd), }; -// Server /lfs/repeater/prefs, 311 bytes. Offset 120 is leds_disabled as a +// Server /lfs/repeater/prefs, 312 bytes. Offset 120 is leds_disabled as a // magic byte (checked separately); pads at 36-39, 79, 123, 127-151 (bridge), // 153-155. const Field kServer[] = { @@ -50,7 +50,7 @@ const Field kServer[] = { F(295, flood_max_advert), F(296, meshtimesync), F(297, cad_auto), F(298, cad_offset), F(299, probe_interval), F(300, cad_busycap), F(301, extra_sf), F(304, fem_rxgain), F(305, display_rotate), F(306, input_rotate), F(307, cad_base), F(308, tz_offset), - F(309, leds_radio_mode), F(310, leds_hb_mode), + F(309, leds_radio_mode), F(310, leds_hb_mode), F(311, led_brightness), }; #undef F @@ -80,7 +80,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; @@ -125,7 +125,7 @@ NodePrefs expectShort(const NodePrefs &file_p, size_t len, const Field (&table)[ } // namespace -TEST(prefs_companion_layout, "UNIT-PREFS-001", "Companion prefs encode to the documented 272-byte layout") { +TEST(prefs_companion_layout, "UNIT-PREFS-001", "Companion prefs encode to the documented 273-byte layout") { NodePrefs p = sample(); uint8_t buf[COMPANION_PREFS_SIZE + 8]; std::memset(buf, 0xEE, sizeof(buf)); @@ -154,7 +154,7 @@ TEST(prefs_companion_short, "UNIT-PREFS-003", "Short companion files keep the ca // Every field boundary: each length the format has had ends on one. const size_t lens[] = { 91, 92, 93, 94, 95, 96, 127, 143, 144, 145, 146, 148, 150, 151, 152, 153, 155, 156, 157, 158, 159, 163, 166, 167, 168, 169, 170, - 171, 172, 173, 174, 175, 208, 272 }; + 171, 172, 173, 174, 175, 176, 209, 273 }; for (size_t len : lens) { NodePrefs got = defaults(); CHECK(companionPrefsDecode(got, full, len)); @@ -215,7 +215,7 @@ TEST(prefs_companion_corrupt, "UNIT-PREFS-005", "Corrupt companion bytes fall ba CHECK(got.leds_radio_mode == 0); } -TEST(prefs_server_layout, "UNIT-PREFS-006", "Server prefs encode to the documented 311-byte layout") { +TEST(prefs_server_layout, "UNIT-PREFS-006", "Server prefs encode to the documented 312-byte layout") { NodePrefs p = sample(); uint8_t buf[SERVER_PREFS_SIZE + 8]; std::memset(buf, 0xEE, sizeof(buf)); @@ -248,7 +248,7 @@ TEST(prefs_server_short, "UNIT-PREFS-008", "Short server files keep the caller's uint8_t full[SERVER_PREFS_SIZE]; serverPrefsEncode(p, full, sizeof(full)); // Every length the format has had. - const size_t lens[] = { 290, 292, 294, 296, 297, 300, 301, 304, 305, 307, 308, 309, 311 }; + const size_t lens[] = { 290, 292, 294, 296, 297, 300, 301, 304, 305, 307, 308, 309, 311, 312 }; for (size_t len : lens) { NodePrefs got = defaults(); serverPrefsDecode(got, full, len); diff --git a/zephcore/helpers/PrefsCodec.cpp b/zephcore/helpers/PrefsCodec.cpp index aaa881f8b..8a02beebe 100644 --- a/zephcore/helpers/PrefsCodec.cpp +++ b/zephcore/helpers/PrefsCodec.cpp @@ -106,6 +106,7 @@ size_t companionPrefsEncode(const NodePrefs &p, uint8_t *buf, size_t cap) w.put(&p.tz_offset, 1); w.put(&p.leds_radio_mode, 1); w.put(&p.leds_hb_mode, 1); + w.put(&p.led_brightness, 1); w.put(&p.wifi_enabled, 1); w.put(p.wifi_ssid, sizeof(p.wifi_ssid)); w.put(p.wifi_pwd, sizeof(p.wifi_pwd)); @@ -285,6 +286,8 @@ size_t serverPrefsEncode(const NodePrefs &prefs, uint8_t *buf, size_t cap) /* LED activity/heartbeat modes (offsets 309-310) */ w.put(&prefs.leds_radio_mode, sizeof(prefs.leds_radio_mode)); w.put(&prefs.leds_hb_mode, sizeof(prefs.leds_hb_mode)); + /* led_brightness (ZephCore extension, since 1.17.4), offset 311. */ + w.put(&prefs.led_brightness, sizeof(prefs.led_brightness)); return w.off; } diff --git a/zephcore/helpers/PrefsCodec.h b/zephcore/helpers/PrefsCodec.h index f65745c21..a92953fe2 100644 --- a/zephcore/helpers/PrefsCodec.h +++ b/zephcore/helpers/PrefsCodec.h @@ -14,8 +14,8 @@ #include #include "NodePrefs.h" -#define COMPANION_PREFS_SIZE 272 -#define SERVER_PREFS_SIZE 311 +#define COMPANION_PREFS_SIZE 273 +#define SERVER_PREFS_SIZE 312 /* Companion file layout, including the fields shared with upstream's former * new_prefs up to offset 92. Returns the byte count (COMPANION_PREFS_SIZE), From 263171bdd4806dc8401b1055200d897ddb020b1f Mon Sep 17 00:00:00 2001 From: ZephCore T096 Date: Wed, 30 Sep 2026 20:13:29 +0200 Subject: [PATCH 39/55] tests: sync catalog.json labels with f8a4992 prefs size bump UNIT-PREFS-001 and UNIT-PREFS-006 still said 272/311-byte in catalog.json while prefs_codec_tests.cpp was bumped to 273/312 by f8a4992. Same ID sets on both sides (missing=[], unexpected=[]) but differing names made the executable/catalog dict comparison fail, which is exactly what CI's host regression job flagged. Co-Authored-By: Claude Sonnet 5 --- tests/catalog.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/catalog.json b/tests/catalog.json index 3f456a59d..4d5cd48a2 100644 --- a/tests/catalog.json +++ b/tests/catalog.json @@ -374,7 +374,7 @@ }, { "id": "UNIT-PREFS-001", - "name": "Companion prefs encode to the documented 272-byte layout", + "name": "Companion prefs encode to the documented 273-byte layout", "source": "tests/unit/prefs_codec_tests.cpp" }, { @@ -399,7 +399,7 @@ }, { "id": "UNIT-PREFS-006", - "name": "Server prefs encode to the documented 311-byte layout", + "name": "Server prefs encode to the documented 312-byte layout", "source": "tests/unit/prefs_codec_tests.cpp" }, { From ad2e17e5e66ce126c397b2b937d5fb28427802e6 Mon Sep 17 00:00:00 2001 From: ZephCore T096 Date: Tue, 6 Oct 2026 17:13:38 +0200 Subject: [PATCH 40/55] prefs: move led_brightness after wifi_pwd; enable PWM in board.conf led_brightness sat at companion offset 174, in front of the WiFi fields, so a 272-byte file written by upstream dev decoded with the wrong led_brightness and a shifted SSID. Append it after wifi_pwd (offset 272) and restore the original test offsets and the 208/272 historical lengths. WT2 and V4.3 only got CONFIG_PWM through esp32s3_pwm_led.conf, which the release build never passes, so release links would fail. Set CONFIG_PWM=y in each board.conf instead. board.conf is app-only and mcuboot does not inherit it, so the mcuboot link failure that rules out the defconfig does not apply. Co-Authored-By: Claude Sonnet 5 --- tests/unit/prefs_codec_tests.cpp | 6 +++--- zephcore/boards/esp32/heltec_wifi_lora32_v43/board.conf | 3 +++ .../boards/esp32/heltec_wireless_tracker_v2/board.conf | 3 +++ zephcore/helpers/PrefsCodec.cpp | 9 +++------ 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/unit/prefs_codec_tests.cpp b/tests/unit/prefs_codec_tests.cpp index 7b7864d16..2ae969902 100644 --- a/tests/unit/prefs_codec_tests.cpp +++ b/tests/unit/prefs_codec_tests.cpp @@ -29,8 +29,8 @@ const Field kCompanion[] = { F(153, v_battery_alert_mv), F(155, cad_auto), F(156, cad_offset), F(157, probe_interval), F(158, cad_busycap), F(159, adc_multiplier), F(163, extra_sf), F(166, v_contact_flags), F(167, fem_rxgain), F(168, display_rotate), F(169, input_rotate), F(170, cad_base), - F(171, tz_offset), F(172, leds_radio_mode), F(173, leds_hb_mode), F(174, led_brightness), - F(175, wifi_enabled), F(176, wifi_ssid), F(209, wifi_pwd), + F(171, tz_offset), F(172, leds_radio_mode), F(173, leds_hb_mode), F(174, wifi_enabled), + F(175, wifi_ssid), F(208, wifi_pwd), F(272, led_brightness), }; // Server /lfs/repeater/prefs, 312 bytes. Offset 120 is leds_disabled as a @@ -154,7 +154,7 @@ TEST(prefs_companion_short, "UNIT-PREFS-003", "Short companion files keep the ca // Every field boundary: each length the format has had ends on one. const size_t lens[] = { 91, 92, 93, 94, 95, 96, 127, 143, 144, 145, 146, 148, 150, 151, 152, 153, 155, 156, 157, 158, 159, 163, 166, 167, 168, 169, 170, - 171, 172, 173, 174, 175, 176, 209, 273 }; + 171, 172, 173, 174, 175, 208, 272, 273 }; for (size_t len : lens) { NodePrefs got = defaults(); CHECK(companionPrefsDecode(got, full, len)); diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v43/board.conf b/zephcore/boards/esp32/heltec_wifi_lora32_v43/board.conf index cc6540973..c8804a89c 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v43/board.conf +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v43/board.conf @@ -28,3 +28,6 @@ CONFIG_ZEPHCORE_DEFAULT_TX_POWER_DBM=12 # board.overlay): the default 500ms double-press confirm window (shutdown/ # DFU/offgrid) is too short for two ~1s holds, see Kconfig help text. CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS=3000 + +# PWM for LED brightness. Kept here, not in the defconfig: the defconfig is shared with mcuboot, which fails to link with it. +CONFIG_PWM=y diff --git a/zephcore/boards/esp32/heltec_wireless_tracker_v2/board.conf b/zephcore/boards/esp32/heltec_wireless_tracker_v2/board.conf index ce2e875d5..08634ffd8 100644 --- a/zephcore/boards/esp32/heltec_wireless_tracker_v2/board.conf +++ b/zephcore/boards/esp32/heltec_wireless_tracker_v2/board.conf @@ -28,3 +28,6 @@ CONFIG_ZEPHCORE_DEFAULT_TX_POWER_DBM=9 # board.overlay): the default 500ms double-press confirm window (shutdown/ # DFU/offgrid) is too short for two ~1s holds, see Kconfig help text. CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS=3000 + +# PWM for LED brightness. Kept here, not in the defconfig: the defconfig is shared with mcuboot, which fails to link with it. +CONFIG_PWM=y diff --git a/zephcore/helpers/PrefsCodec.cpp b/zephcore/helpers/PrefsCodec.cpp index 8a02beebe..df9a1d608 100644 --- a/zephcore/helpers/PrefsCodec.cpp +++ b/zephcore/helpers/PrefsCodec.cpp @@ -106,10 +106,10 @@ size_t companionPrefsEncode(const NodePrefs &p, uint8_t *buf, size_t cap) w.put(&p.tz_offset, 1); w.put(&p.leds_radio_mode, 1); w.put(&p.leds_hb_mode, 1); - w.put(&p.led_brightness, 1); w.put(&p.wifi_enabled, 1); w.put(p.wifi_ssid, sizeof(p.wifi_ssid)); w.put(p.wifi_pwd, sizeof(p.wifi_pwd)); + w.put(&p.led_brightness, 1); return w.off; } @@ -188,14 +188,11 @@ bool companionPrefsDecode(NodePrefs &prefs, const uint8_t *buf, size_t len) r.take(&p.tz_offset, 1); r.take(&p.leds_radio_mode, 1); r.take(&p.leds_hb_mode, 1); - /* led_brightness (ZephCore extension, since 1.17.4). Absent in files - * written before this field existed; the no-op EOF read leaves the - * initNodePrefs() default ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT (10%), - * same value a fresh node already showed before this was persisted. */ - r.take(&p.led_brightness, 1); r.take(&p.wifi_enabled, 1); r.take(p.wifi_ssid, sizeof(p.wifi_ssid)); r.take(p.wifi_pwd, sizeof(p.wifi_pwd)); + /* led_brightness at offset 272, after wifi_pwd: a 272-byte file leaves it at the initNodePrefs() default. */ + r.take(&p.led_brightness, 1); /* The one validator: bounds, NaNs, and the unterminated char fields */ sanitizeNodePrefs(&p); From 5425ce83aabd7f9f65ff70c707bd877a38405cd1 Mon Sep 17 00:00:00 2001 From: ZephCore T096 Date: Tue, 6 Oct 2026 17:19:56 +0200 Subject: [PATCH 41/55] cli: restore cliOnOff() for "set leds" The on/off-only parser dropped "default", "1" and "0" from set leds and reverted the shared cliOnOff() helper that the other toggles use. Only the leds.brightness handling needed to change; the master switch goes back to upstream's form. Co-Authored-By: Claude Sonnet 5 --- zephcore/helpers/CommonCLI.cpp | 29 +++++++++-------------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index 37f761754..e61234fef 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -1295,28 +1295,17 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, const char* command, cha snprintf(reply, CLI_REPLY_SIZE, "OK - leds.brightness=%ld%%", pct); } } else if (memcmp(config, "leds ", 5) == 0) { - /* Master switch (persisted) 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. "on"/"off" words only, no "1"/"0" - * aliases (removed from led.tx/led.hb on 2026-08-16 for the same - * reason: they used to collide with the 1% and 0% brightness - * values on that branch — moot here since brightness now lives - * under "leds.brightness" instead, but kept out anyway for - * consistency and to reject typos cleanly). */ - const char* val = &config[5]; - if (memcmp(val, "on", 2) == 0 && val[2] == '\0') { - _prefs->leds_disabled = 0; - zephcore_leds_set_disabled(false); - savePrefs(); - strcpy(reply, "OK"); - } else if (memcmp(val, "off", 3) == 0 && val[3] == '\0') { - _prefs->leds_disabled = 1; - zephcore_leds_set_disabled(true); + /* 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). */ + int on = cliOnOff(&config[5], cliDefaults()->leds_disabled ? 0 : 1); + if (on < 0) { + strcpy(reply, "Error: must be on, off or default"); + } else { + _prefs->leds_disabled = on ? 0 : 1; + zephcore_leds_set_disabled(_prefs->leds_disabled != 0); savePrefs(); strcpy(reply, "OK"); - } else { - strcpy(reply, "Error: must be on or off"); } #ifndef ZEPHCORE_REPEATER } else if (memcmp(config, "buzzer ", 7) == 0) { From fc0723a8ec39e054523c33b5417fd70d78965bea Mon Sep 17 00:00:00 2001 From: ZephCore T096 Date: Tue, 6 Oct 2026 17:27:55 +0200 Subject: [PATCH 42/55] led: one shared PWM write helper, shorter comments The heartbeat and activity LED each computed the PWM pulse inline. zephcore_led_pwm_write() in led_gate.c now does it once. Comments added for the LED work described branch history, dates and release notes; keep only what the code needs. led_gate.c still said the brightness was RAM-only, which is no longer true, and the server decode comment gave the old 10% default. Co-Authored-By: Claude Sonnet 5 --- zephcore/adapters/board/ZephyrBoard.cpp | 43 +++++-------------------- zephcore/helpers/CommonCLI.cpp | 15 --------- zephcore/helpers/PrefsCodec.cpp | 7 ++-- zephcore/helpers/led_gate.c | 12 +++++-- zephcore/helpers/led_gate.h | 15 ++++----- zephcore/helpers/ui/ui_common.c | 15 ++------- 6 files changed, 28 insertions(+), 79 deletions(-) diff --git a/zephcore/adapters/board/ZephyrBoard.cpp b/zephcore/adapters/board/ZephyrBoard.cpp index 18ba6e1fa..b00645a8a 100644 --- a/zephcore/adapters/board/ZephyrBoard.cpp +++ b/zephcore/adapters/board/ZephyrBoard.cpp @@ -58,21 +58,12 @@ #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. - * - * PWM path (DT_ALIAS(lora_tx_pwm_led)) takes priority when it exists — used - * on boards where the TX LED is the same physical LED as the heartbeat - * (e.g. T096, see helpers/ui/ui_common.c), so both share the one brightness - * in helpers/led_gate.h instead of the plain GPIO path fighting over the - * pin. */ +/* 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 -/* Init deferred to the combined tx_led_init() below (with the GPIO path), - * since both now also need to arm s_rx_pulse_off -- see that block. */ +/* 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); @@ -83,12 +74,8 @@ static const struct gpio_dt_spec tx_led = #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, counting only the plain-GPIO ones -- see the - * PWM branch below for the rest). 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 @@ -96,10 +83,7 @@ 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 -/* PWM equivalent: true when heartbeat and TX activity are aliased to the same - * PWM LED node (T096, Wireless Tracker V2). Not the case on every PWM board -- - * RAK3401 has two independent PWM LEDs (green/blue), so this stays 0 there and - * the hold below is correctly never taken. */ +/* 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 @@ -107,17 +91,10 @@ static const struct gpio_dt_spec tx_led = #define ZEPHCORE_LED_PIN_SHARED 0 #endif -/* PWM-or-GPIO write for the activity LED, mirroring heartbeat_led_write() in - * helpers/ui/ui_common.c -- same reasoning: the write itself must not care - * which path is compiled in, only the callers (onBeforeTransmit/ - * onAfterTransmit/onPacketReceived/rx_pulse_off_handler below) do. */ #if HAS_TX_LED_PWM static inline void tx_led_write(bool on) { - uint32_t pulse = on ? (uint32_t)((uint64_t)tx_led_pwm.period * - zephcore_led_brightness_pct() / 100) - : 0; - pwm_set_pulse_dt(&tx_led_pwm, pulse); + zephcore_led_pwm_write(&tx_led_pwm, on); } #elif HAS_TX_LED static inline void tx_led_write(bool on) @@ -195,9 +172,7 @@ static const struct device *const pmic_charger_dev = * interlock that keeps an RX one-shot from clearing a pin that TX still owns: * the two are driven from different threads (radio TX path vs system work * queue), so without it a pulse landing mid-transmit would blank the LED for - * the rest of the packet. Applies equally to the PWM-shared boards (T096, - * Wireless Tracker V2): same race, same fix, just written through - * tx_led_write() instead of a bare gpio_pin_set_dt(). */ + * the rest of the packet. */ static atomic_t s_tx_lit; static struct k_work_delayable s_rx_pulse_off; @@ -418,9 +393,7 @@ void ZephyrBoard::onBeforeTransmit() * ("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. Same interlock on PWM-shared - * boards (T096, Wireless Tracker V2) as on plain-GPIO ones -- only the - * final write (tx_led_write()) differs. */ + * 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() && diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index e61234fef..1d8f6218b 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -993,12 +993,6 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, const char* command, cha 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) { - /* Separate from "leds" on purpose: keeps the on/off switch a - * pure word command and the dimmer a pure number command, - * instead of one command parsing both — see led_gate.h. Read - * from _prefs, same as leds.radio/leds.hb above, not from the - * RAM getter directly -- the two are always in sync, the "set" - * handler below writes both together. */ 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"); @@ -1272,15 +1266,6 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, const char* command, cha CLI_HAS_HB_LED ? "" : " (no heartbeat LED on this board)"); } } else if (memcmp(config, "leds.brightness ", 16) == 0) { - /* Separate from "leds" on purpose: keeps the on/off switch a pure - * word command and the dimmer a pure number command, instead of - * one command parsing both. Persisted (since 1.17.4): a fresh - * node still starts at ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT (the - * append-only prefs format leaves this byte at the - * initNodePrefs() default on any file that predates this field), - * but once set it survives reboots and firmware updates like - * leds.radio/leds.hb above. Checked before "leds " below so this - * longer, more specific prefix is never shadowed by it. */ const char *val = &config[16]; char *endptr = (char *)val; long pct = strtol(val, &endptr, 10); diff --git a/zephcore/helpers/PrefsCodec.cpp b/zephcore/helpers/PrefsCodec.cpp index df9a1d608..9e8b74f58 100644 --- a/zephcore/helpers/PrefsCodec.cpp +++ b/zephcore/helpers/PrefsCodec.cpp @@ -283,7 +283,7 @@ size_t serverPrefsEncode(const NodePrefs &prefs, uint8_t *buf, size_t cap) /* LED activity/heartbeat modes (offsets 309-310) */ w.put(&prefs.leds_radio_mode, sizeof(prefs.leds_radio_mode)); w.put(&prefs.leds_hb_mode, sizeof(prefs.leds_hb_mode)); - /* led_brightness (ZephCore extension, since 1.17.4), offset 311. */ + /* led_brightness at offset 311. */ w.put(&prefs.led_brightness, sizeof(prefs.led_brightness)); return w.off; } @@ -360,10 +360,7 @@ void serverPrefsDecode(NodePrefs &prefs, const uint8_t *buf, size_t len) r.get(&prefs.tz_offset, sizeof(prefs.tz_offset)); r.get(&prefs.leds_radio_mode, sizeof(prefs.leds_radio_mode)); r.get(&prefs.leds_hb_mode, sizeof(prefs.leds_hb_mode)); - /* led_brightness (ZephCore extension, since 1.17.4). Absent in files - * written before this field existed; the no-op EOF read leaves the - * initNodePrefs() default ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT (10%), - * same value a fresh node already showed before this was persisted. */ + /* led_brightness at offset 311: a shorter file leaves the initNodePrefs() default. */ r.get(&prefs.led_brightness, sizeof(prefs.led_brightness)); /* Only the explicit "off" magic disables LEDs; a legacy AGC interval or an diff --git a/zephcore/helpers/led_gate.c b/zephcore/helpers/led_gate.c index a9c06a3e0..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 @@ -75,9 +76,6 @@ void zephcore_led_radio_hold_pin(bool held) atomic_set(&s_radio_holds_pin, held ? 1 : 0); } -/* Same cross-thread readers/writers as the gate above (heartbeat work queue, - * TX path, CLI) — atomic for the same reason. RAM-only on purpose: no - * savePrefs() call anywhere near this, see led_gate.h. */ static atomic_t s_led_brightness_pct = ATOMIC_INIT(ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT); uint8_t zephcore_led_brightness_pct(void) @@ -92,3 +90,11 @@ void zephcore_led_set_brightness_pct(uint8_t pct) } 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 699186288..eeb8c9ee5 100644 --- a/zephcore/helpers/led_gate.h +++ b/zephcore/helpers/led_gate.h @@ -34,19 +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 boards whose heartbeat/TX LED is - * PWM-capable (DT_ALIAS(heartbeat_pwm_led) / DT_ALIAS(lora_tx_pwm_led)). - * Persisted via NodePrefs (prefs.led_brightness); ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT - * only seeds the RAM cache on a fresh node with no saved value yet (100%, - * matching the brightness every PWM LED ran at before this setting existed). - * Independent of the on/off gate above: brightness only matters while the - * gate is open. No-op on boards without a PWM-capable LED (the getter still - * returns the compiled default, simply unused). */ +/* 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/ui/ui_common.c b/zephcore/helpers/ui/ui_common.c index 3c5a9cbb0..a54f90675 100644 --- a/zephcore/helpers/ui/ui_common.c +++ b/zephcore/helpers/ui/ui_common.c @@ -77,12 +77,7 @@ void ui_play_startup_chime(void) * messages; with both LEDs present, companions light led1 for unread. */ -/* PWM path (brightness-capable, shared with the LoRa TX LED via the same - * DT_ALIAS(heartbeat_pwm_led)/DT_ALIAS(lora_tx_pwm_led) pair when a board's - * heartbeat and TX indicator are the same physical LED, e.g. T096) takes - * priority over the plain digital led0/led1 fallback. Brightness comes from - * helpers/led_gate.h — one dimmer for every event on this LED, not a - * heartbeat-only one. */ +/* 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)); @@ -103,9 +98,6 @@ static const struct gpio_dt_spec s_heartbeat_led = #define HAS_HEARTBEAT_LED 0 #endif -/* "Some heartbeat LED exists, PWM or plain GPIO" — used where the on/off - * write differs but everything else (work queue plumbing, readiness check) - * is identical either way. */ #define HAS_ANY_HEARTBEAT_LED (HAS_HEARTBEAT_LED_PWM || HAS_HEARTBEAT_LED) #if HAS_HEARTBEAT_LED_PWM @@ -115,10 +107,7 @@ static inline bool heartbeat_led_is_ready(void) } static inline void heartbeat_led_write(bool on) { - uint32_t pulse = on ? (uint32_t)((uint64_t)s_heartbeat_led_pwm.period * - zephcore_led_brightness_pct() / 100) - : 0; - pwm_set_pulse_dt(&s_heartbeat_led_pwm, pulse); + zephcore_led_pwm_write(&s_heartbeat_led_pwm, on); } #elif HAS_HEARTBEAT_LED static inline bool heartbeat_led_is_ready(void) From e5e927e5302eb70f547321ea1fcadd0e4a9d6b95 Mon Sep 17 00:00:00 2001 From: ZephCore T096 Date: Wed, 7 Oct 2026 20:38:51 +0200 Subject: [PATCH 43/55] leds: drop unused esp32s3_pwm_led.conf, fix stale WT2 comment CONFIG_PWM now comes from each board.conf (see "prefs: move led_brightness after wifi_pwd; enable PWM in board.conf"), so the EXTRA_CONF_FILE fragment is no longer referenced anywhere and can go. The WT2 PWM LED node comment still said "Option 1" and "RAM-only", both stale: the design no longer needs that internal name, and the brightness has been persisted since the earlier commit in this branch. Co-Authored-By: Claude Sonnet 5 --- zephcore/boards/common/esp32s3_pwm_led.conf | 15 --------------- .../heltec_wireless_tracker_v2_procpu.dts | 9 ++++----- 2 files changed, 4 insertions(+), 20 deletions(-) delete mode 100644 zephcore/boards/common/esp32s3_pwm_led.conf diff --git a/zephcore/boards/common/esp32s3_pwm_led.conf b/zephcore/boards/common/esp32s3_pwm_led.conf deleted file mode 100644 index 97f8f3c58..000000000 --- a/zephcore/boards/common/esp32s3_pwm_led.conf +++ /dev/null @@ -1,15 +0,0 @@ -# Enable PWM on ESP32-S3 sysbuild targets (shared heartbeat/TX LED brightness, -# see the heartbeat-pwm-led / lora-tx-pwm-led devicetree aliases consumed by -# helpers/ui/ui_common.c and adapters/board/ZephyrBoard.cpp). -# -# Deliberately NOT in the board *_defconfig: that file is shared by every -# sysbuild image for the board, including mcuboot, whose minimal kernel -# config has no k_sem_give()/k_sem_take() -- CONFIG_PWM=y there links fine for -# the app image but fails mcuboot's link with "undefined reference to -# z_impl_k_sem_give/take" (drivers/pwm/pwm_led_esp32.c uses a semaphore). -# EXTRA_CONF_FILE scopes this to the app ("zephcore") image only. -# -# Build with: -# -DEXTRA_CONF_FILE="boards/common/esp32s3_usb.conf;boards/common/esp32s3_pwm_led.conf" - -CONFIG_PWM=y 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 36bb17a34..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 @@ -74,11 +74,10 @@ /* 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 (same - * "Option 1" shared-brightness pattern as T096, see helpers/ui/ui_common.c - * and adapters/board/ZephyrBoard.cpp: heartbeat-pwm-led/lora-tx-pwm-led - * aliases give every one of those events a single 0-100 brightness knob, - * RAM-only, on top of the existing persisted on/off gate. */ + * 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"; From 6b9d205b00ee84468baefdbe70f1e60707b4fdaa Mon Sep 17 00:00:00 2001 From: ZephCore T096 Date: Thu, 8 Oct 2026 17:48:15 +0200 Subject: [PATCH 44/55] leds: do not reclaim a PWM-shared pin as plain GPIO in main() server_main_common.cpp and main_observer.cpp both configured led0 as a plain GPIO output at boot, unconditionally. On ESP32 that calls esp_rom_gpio_matrix_out() under the hood, which routes the pin's output to the GPIO signal and takes it away from whatever peripheral had it before, including the LEDC channel a heartbeat-pwm-led or lora-tx-pwm-led alias had already claimed during the PWM driver's own init. The PWM write then succeeds (no error, no warning) but never reaches the pin: confirmed on V4.3 hardware, where the same pin lights up fine over plain GPIO, and over PWM in a different (non-Zephyr) firmware, but stays dark over Zephyr's PWM once this GPIO configure has run. Companion builds never hit this (they don't use server_main_common.cpp) and nRF52 isn't affected (no shared GPIO matrix), which is why T096 tested fine while V4.3 didn't. Fix is alias-based, not a board list, so any future board with either PWM alias is covered without extra wiring. Also: CLI_HAS_RADIO_LED only checked the plain lora-tx-led alias, so "set leds.radio" replied "(no radio LED on this board)" on boards whose radio LED exists only via lora-tx-pwm-led (V4.3). Check both. Documents the two PWM aliases in the board porting guide, and leds.brightness plus the corrected LED topology counts in docs/CLI_commands.md. Co-Authored-By: Claude Sonnet 5 --- docs/CLI_commands.md | 25 ++++++++--- zephcore/app/main_observer.cpp | 12 +++++- zephcore/boards/example_board/README.md | 1 + zephcore/boards/example_board/board.overlay | 48 +++++++++++++++++++++ zephcore/helpers/CommonCLI.cpp | 6 ++- zephcore/src/server_main_common.cpp | 12 +++++- 6 files changed, 91 insertions(+), 13 deletions(-) diff --git a/docs/CLI_commands.md b/docs/CLI_commands.md index 78498992a..f094afc29 100644 --- a/docs/CLI_commands.md +++ b/docs/CLI_commands.md @@ -358,8 +358,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`) | @@ -441,8 +442,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 +479,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 +489,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 (`ZEPHCORE_HAS_PWM_SHARED_LED`) 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/zephcore/app/main_observer.cpp b/zephcore/app/main_observer.cpp index f83e6d9d6..7f133aebc 100644 --- a/zephcore/app/main_observer.cpp +++ b/zephcore/app/main_observer.cpp @@ -44,7 +44,15 @@ LOG_MODULE_REGISTER(zephcore_observer_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); /* ========== LED (optional) ========== */ -#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) +/* True when a board aliases its heartbeat or TX-activity LED to a PWM node. + * On ESP32, configuring led0 as plain GPIO after the PWM driver has already + * routed the pin takes the pin back from the LEDC peripheral (the GPIO + * matrix only serves one signal at a time), so on those boards led0 must be + * left to the PWM driver instead of reconfigured here. */ +#define ZEPHCORE_HAS_PWM_SHARED_LED \ + (DT_NODE_EXISTS(DT_ALIAS(heartbeat_pwm_led)) || DT_NODE_EXISTS(DT_ALIAS(lora_tx_pwm_led))) + +#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) && !ZEPHCORE_HAS_PWM_SHARED_LED #include #define LED0_NODE DT_ALIAS(led0) static const struct gpio_dt_spec led0 = GPIO_DT_SPEC_GET(LED0_NODE, gpios); @@ -320,7 +328,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) && !ZEPHCORE_HAS_PWM_SHARED_LED if (gpio_is_ready_dt(&led0)) { gpio_pin_configure_dt(&led0, GPIO_OUTPUT_INACTIVE); } diff --git a/zephcore/boards/example_board/README.md b/zephcore/boards/example_board/README.md index 573f4e3c8..c5a572c5d 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 (`ZEPHCORE_HAS_PWM_SHARED_LED` in `src/server_main_common.cpp` and `app/main_observer.cpp` already skips 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 | diff --git a/zephcore/boards/example_board/board.overlay b/zephcore/boards/example_board/board.overlay index cf482d974..706e26dcf 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 (ZEPHCORE_HAS_PWM_SHARED_LED) 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/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index 1d8f6218b..af12f3ce7 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -68,9 +68,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)) diff --git a/zephcore/src/server_main_common.cpp b/zephcore/src/server_main_common.cpp index 1ea9351e0..81068f5e5 100644 --- a/zephcore/src/server_main_common.cpp +++ b/zephcore/src/server_main_common.cpp @@ -71,8 +71,16 @@ extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line) #include "wifi_ota.h" #endif +/* True when a board aliases its heartbeat or TX-activity LED to a PWM node. + * On ESP32, configuring led0 as plain GPIO after the PWM driver has already + * routed the pin takes the pin back from the LEDC peripheral (the GPIO + * matrix only serves one signal at a time), so on those boards led0 must be + * left to the PWM driver instead of reconfigured here. */ +#define ZEPHCORE_HAS_PWM_SHARED_LED \ + (DT_NODE_EXISTS(DT_ALIAS(heartbeat_pwm_led)) || DT_NODE_EXISTS(DT_ALIAS(lora_tx_pwm_led))) + /* LED configuration */ -#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) +#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) && !ZEPHCORE_HAS_PWM_SHARED_LED #define LED0_NODE DT_ALIAS(led0) static const struct gpio_dt_spec led0 = GPIO_DT_SPEC_GET(LED0_NODE, gpios); #endif @@ -610,7 +618,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) && !ZEPHCORE_HAS_PWM_SHARED_LED if (gpio_is_ready_dt(&led0)) { gpio_pin_configure_dt(&led0, GPIO_OUTPUT_INACTIVE); } From 6acefe609dd9ab5c2429315869af57e34ac8394a Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:19:58 +0200 Subject: [PATCH 45/55] md updates --- docs/ARCHITECTURE.md | 4 ++-- releasenotes/RELEASE_NOTES_1.17.7-zephcore.md | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e79158651..b6960e88a 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 @@ -850,7 +850,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 diff --git a/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md b/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md index 0b24b30d2..76b55392f 100644 --- a/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md +++ b/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md @@ -66,6 +66,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 @@ -161,6 +175,8 @@ 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. +- **`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 From c60b5e1bae0ca578b88b2db1a5f8c681bec9bb16 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:38:37 +0200 Subject: [PATCH 46/55] pwm cleanup --- docs/ARCHITECTURE.md | 6 ++++++ docs/CLI_commands.md | 2 +- zephcore/Kconfig | 8 ++++++++ zephcore/app/main_observer.cpp | 13 +++---------- .../boards/esp32/heltec_wifi_lora32_v43/board.conf | 3 --- .../esp32/heltec_wireless_tracker_v2/board.conf | 3 --- zephcore/boards/example_board/README.md | 2 +- zephcore/boards/example_board/board.overlay | 2 +- .../heltec_t096/heltec_t096_nrf52840_defconfig | 3 --- zephcore/src/server_main_common.cpp | 13 +++---------- 10 files changed, 23 insertions(+), 32 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b6960e88a..e3a58bc20 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -871,6 +871,12 @@ 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 `NodePrefs.led_brightness` (companion offset 272; repeater offset 311). + ### 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 diff --git a/docs/CLI_commands.md b/docs/CLI_commands.md index f094afc29..9a8ff203e 100644 --- a/docs/CLI_commands.md +++ b/docs/CLI_commands.md @@ -497,7 +497,7 @@ four radio parameters together, since they are one interop-critical set. 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 (`ZEPHCORE_HAS_PWM_SHARED_LED`) whenever a PWM LED alias + `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 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/app/main_observer.cpp b/zephcore/app/main_observer.cpp index 7f133aebc..337eefe35 100644 --- a/zephcore/app/main_observer.cpp +++ b/zephcore/app/main_observer.cpp @@ -44,15 +44,8 @@ LOG_MODULE_REGISTER(zephcore_observer_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); /* ========== LED (optional) ========== */ -/* True when a board aliases its heartbeat or TX-activity LED to a PWM node. - * On ESP32, configuring led0 as plain GPIO after the PWM driver has already - * routed the pin takes the pin back from the LEDC peripheral (the GPIO - * matrix only serves one signal at a time), so on those boards led0 must be - * left to the PWM driver instead of reconfigured here. */ -#define ZEPHCORE_HAS_PWM_SHARED_LED \ - (DT_NODE_EXISTS(DT_ALIAS(heartbeat_pwm_led)) || DT_NODE_EXISTS(DT_ALIAS(lora_tx_pwm_led))) - -#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) && !ZEPHCORE_HAS_PWM_SHARED_LED +/* 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); @@ -328,7 +321,7 @@ int main(void) LOG_INF("=== ZephCore Observer starting ==="); /* Configure LED */ -#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) && !ZEPHCORE_HAS_PWM_SHARED_LED +#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/boards/esp32/heltec_wifi_lora32_v43/board.conf b/zephcore/boards/esp32/heltec_wifi_lora32_v43/board.conf index c8804a89c..cc6540973 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v43/board.conf +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v43/board.conf @@ -28,6 +28,3 @@ CONFIG_ZEPHCORE_DEFAULT_TX_POWER_DBM=12 # board.overlay): the default 500ms double-press confirm window (shutdown/ # DFU/offgrid) is too short for two ~1s holds, see Kconfig help text. CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS=3000 - -# PWM for LED brightness. Kept here, not in the defconfig: the defconfig is shared with mcuboot, which fails to link with it. -CONFIG_PWM=y diff --git a/zephcore/boards/esp32/heltec_wireless_tracker_v2/board.conf b/zephcore/boards/esp32/heltec_wireless_tracker_v2/board.conf index 08634ffd8..ce2e875d5 100644 --- a/zephcore/boards/esp32/heltec_wireless_tracker_v2/board.conf +++ b/zephcore/boards/esp32/heltec_wireless_tracker_v2/board.conf @@ -28,6 +28,3 @@ CONFIG_ZEPHCORE_DEFAULT_TX_POWER_DBM=9 # board.overlay): the default 500ms double-press confirm window (shutdown/ # DFU/offgrid) is too short for two ~1s holds, see Kconfig help text. CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS=3000 - -# PWM for LED brightness. Kept here, not in the defconfig: the defconfig is shared with mcuboot, which fails to link with it. -CONFIG_PWM=y diff --git a/zephcore/boards/example_board/README.md b/zephcore/boards/example_board/README.md index c5a572c5d..61760ca99 100644 --- a/zephcore/boards/example_board/README.md +++ b/zephcore/boards/example_board/README.md @@ -142,7 +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 (`ZEPHCORE_HAS_PWM_SHARED_LED` in `src/server_main_common.cpp` and `app/main_observer.cpp` already skips this for you) | +| 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 | diff --git a/zephcore/boards/example_board/board.overlay b/zephcore/boards/example_board/board.overlay index 706e26dcf..0c880480e 100644 --- a/zephcore/boards/example_board/board.overlay +++ b/zephcore/boards/example_board/board.overlay @@ -616,7 +616,7 @@ zephyr_udc0: &usbd { * 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 (ZEPHCORE_HAS_PWM_SHARED_LED) when either PWM alias is + * 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 diff --git a/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840_defconfig b/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840_defconfig index 22dd9147b..98c300e89 100644 --- a/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840_defconfig +++ b/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840_defconfig @@ -10,9 +10,6 @@ CONFIG_HW_STACK_PROTECTION=y # Enable GPIO CONFIG_GPIO=y -# Enable PWM (shared heartbeat/TX LED brightness) -CONFIG_PWM=y - # Enable UART driver CONFIG_SERIAL=y diff --git a/zephcore/src/server_main_common.cpp b/zephcore/src/server_main_common.cpp index 81068f5e5..3c1e7034b 100644 --- a/zephcore/src/server_main_common.cpp +++ b/zephcore/src/server_main_common.cpp @@ -71,16 +71,9 @@ extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line) #include "wifi_ota.h" #endif -/* True when a board aliases its heartbeat or TX-activity LED to a PWM node. - * On ESP32, configuring led0 as plain GPIO after the PWM driver has already - * routed the pin takes the pin back from the LEDC peripheral (the GPIO - * matrix only serves one signal at a time), so on those boards led0 must be - * left to the PWM driver instead of reconfigured here. */ -#define ZEPHCORE_HAS_PWM_SHARED_LED \ - (DT_NODE_EXISTS(DT_ALIAS(heartbeat_pwm_led)) || DT_NODE_EXISTS(DT_ALIAS(lora_tx_pwm_led))) - /* LED configuration */ -#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) && !ZEPHCORE_HAS_PWM_SHARED_LED +/* 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 @@ -618,7 +611,7 @@ int server_main(const ServerRole &role) #endif /* Configure LEDs */ -#if DT_NODE_HAS_PROP(DT_ALIAS(led0), gpios) && !ZEPHCORE_HAS_PWM_SHARED_LED +#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); } From 3518a4de8ab5acba0e7ed1e16e6cc7133560f764 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:54:46 +0200 Subject: [PATCH 47/55] robot instructions update --- AGENTS.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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; From c69b3c61aafbde4d79f5c4a150f920b38932bb78 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:54:59 +0200 Subject: [PATCH 48/55] md updates --- .gitignore | 1 + docs/ARCHITECTURE.md | 2 +- tests/catalog.json | 4 ++-- tests/unit/prefs_codec_tests.cpp | 18 +++++++++--------- tests/unit/prefs_json_tests.cpp | 5 ++++- zephcore/helpers/PrefsCodec.cpp | 7 ------- zephcore/helpers/PrefsCodec.h | 4 ++-- 7 files changed, 19 insertions(+), 22 deletions(-) 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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e3a58bc20..3fc762bf0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -875,7 +875,7 @@ Does not cover the display backlight, which has its own UI brightness setting (` `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 `NodePrefs.led_brightness` (companion offset 272; repeater offset 311). +driver. Persisted in `prefs.json` as `zc.leds_brightness`; it is not part of the legacy binary files. ### 7.6 WiFi / MQTT / TCP Transports diff --git a/tests/catalog.json b/tests/catalog.json index 4d5cd48a2..3f456a59d 100644 --- a/tests/catalog.json +++ b/tests/catalog.json @@ -374,7 +374,7 @@ }, { "id": "UNIT-PREFS-001", - "name": "Companion prefs encode to the documented 273-byte layout", + "name": "Companion prefs encode to the documented 272-byte layout", "source": "tests/unit/prefs_codec_tests.cpp" }, { @@ -399,7 +399,7 @@ }, { "id": "UNIT-PREFS-006", - "name": "Server prefs encode to the documented 312-byte layout", + "name": "Server prefs encode to the documented 311-byte layout", "source": "tests/unit/prefs_codec_tests.cpp" }, { diff --git a/tests/unit/prefs_codec_tests.cpp b/tests/unit/prefs_codec_tests.cpp index 2ae969902..c8f26d809 100644 --- a/tests/unit/prefs_codec_tests.cpp +++ b/tests/unit/prefs_codec_tests.cpp @@ -13,7 +13,7 @@ namespace { struct Field { size_t file_off; size_t size; size_t struct_off; }; #define F(off, member) { off, sizeof(NodePrefs::member), offsetof(NodePrefs, member) } -// Companion /lfs/new_prefs, 273 bytes. Pads at 36-39 and 79. +// Companion /lfs/new_prefs, 272 bytes. Pads at 36-39 and 79. const Field kCompanion[] = { F(0, airtime_factor), F(4, node_name), F(40, node_lat), F(48, node_lon), F(56, freq), F(60, sf), F(61, cr), F(62, client_repeat), F(63, manual_add_contacts), F(64, bw), @@ -30,10 +30,10 @@ const Field kCompanion[] = { F(158, cad_busycap), F(159, adc_multiplier), F(163, extra_sf), F(166, v_contact_flags), F(167, fem_rxgain), F(168, display_rotate), F(169, input_rotate), F(170, cad_base), F(171, tz_offset), F(172, leds_radio_mode), F(173, leds_hb_mode), F(174, wifi_enabled), - F(175, wifi_ssid), F(208, wifi_pwd), F(272, led_brightness), + F(175, wifi_ssid), F(208, wifi_pwd), }; -// Server /lfs/repeater/prefs, 312 bytes. Offset 120 is leds_disabled as a +// Server /lfs/repeater/prefs, 311 bytes. Offset 120 is leds_disabled as a // magic byte (checked separately); pads at 36-39, 79, 123, 127-151 (bridge), // 153-155. const Field kServer[] = { @@ -50,7 +50,7 @@ const Field kServer[] = { F(295, flood_max_advert), F(296, meshtimesync), F(297, cad_auto), F(298, cad_offset), F(299, probe_interval), F(300, cad_busycap), F(301, extra_sf), F(304, fem_rxgain), F(305, display_rotate), F(306, input_rotate), F(307, cad_base), F(308, tz_offset), - F(309, leds_radio_mode), F(310, leds_hb_mode), F(311, led_brightness), + F(309, leds_radio_mode), F(310, leds_hb_mode), }; #undef F @@ -80,7 +80,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.led_brightness = 42; p.powersaving_enabled = 1; p.gps_enabled = 1; + p.leds_hb_mode = LEDS_HB_UNREAD; 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; @@ -125,7 +125,7 @@ NodePrefs expectShort(const NodePrefs &file_p, size_t len, const Field (&table)[ } // namespace -TEST(prefs_companion_layout, "UNIT-PREFS-001", "Companion prefs encode to the documented 273-byte layout") { +TEST(prefs_companion_layout, "UNIT-PREFS-001", "Companion prefs encode to the documented 272-byte layout") { NodePrefs p = sample(); uint8_t buf[COMPANION_PREFS_SIZE + 8]; std::memset(buf, 0xEE, sizeof(buf)); @@ -154,7 +154,7 @@ TEST(prefs_companion_short, "UNIT-PREFS-003", "Short companion files keep the ca // Every field boundary: each length the format has had ends on one. const size_t lens[] = { 91, 92, 93, 94, 95, 96, 127, 143, 144, 145, 146, 148, 150, 151, 152, 153, 155, 156, 157, 158, 159, 163, 166, 167, 168, 169, 170, - 171, 172, 173, 174, 175, 208, 272, 273 }; + 171, 172, 173, 174, 175, 208, 272 }; for (size_t len : lens) { NodePrefs got = defaults(); CHECK(companionPrefsDecode(got, full, len)); @@ -215,7 +215,7 @@ TEST(prefs_companion_corrupt, "UNIT-PREFS-005", "Corrupt companion bytes fall ba CHECK(got.leds_radio_mode == 0); } -TEST(prefs_server_layout, "UNIT-PREFS-006", "Server prefs encode to the documented 312-byte layout") { +TEST(prefs_server_layout, "UNIT-PREFS-006", "Server prefs encode to the documented 311-byte layout") { NodePrefs p = sample(); uint8_t buf[SERVER_PREFS_SIZE + 8]; std::memset(buf, 0xEE, sizeof(buf)); @@ -248,7 +248,7 @@ TEST(prefs_server_short, "UNIT-PREFS-008", "Short server files keep the caller's uint8_t full[SERVER_PREFS_SIZE]; serverPrefsEncode(p, full, sizeof(full)); // Every length the format has had. - const size_t lens[] = { 290, 292, 294, 296, 297, 300, 301, 304, 305, 307, 308, 309, 311, 312 }; + const size_t lens[] = { 290, 292, 294, 296, 297, 300, 301, 304, 305, 307, 308, 309, 311 }; for (size_t len : lens) { NodePrefs got = defaults(); serverPrefsDecode(got, full, len); 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/helpers/PrefsCodec.cpp b/zephcore/helpers/PrefsCodec.cpp index 9e8b74f58..a22984d27 100644 --- a/zephcore/helpers/PrefsCodec.cpp +++ b/zephcore/helpers/PrefsCodec.cpp @@ -109,7 +109,6 @@ size_t companionPrefsEncode(const NodePrefs &p, uint8_t *buf, size_t cap) w.put(&p.wifi_enabled, 1); w.put(p.wifi_ssid, sizeof(p.wifi_ssid)); w.put(p.wifi_pwd, sizeof(p.wifi_pwd)); - w.put(&p.led_brightness, 1); return w.off; } @@ -191,8 +190,6 @@ bool companionPrefsDecode(NodePrefs &prefs, const uint8_t *buf, size_t len) r.take(&p.wifi_enabled, 1); r.take(p.wifi_ssid, sizeof(p.wifi_ssid)); r.take(p.wifi_pwd, sizeof(p.wifi_pwd)); - /* led_brightness at offset 272, after wifi_pwd: a 272-byte file leaves it at the initNodePrefs() default. */ - r.take(&p.led_brightness, 1); /* The one validator: bounds, NaNs, and the unterminated char fields */ sanitizeNodePrefs(&p); @@ -283,8 +280,6 @@ size_t serverPrefsEncode(const NodePrefs &prefs, uint8_t *buf, size_t cap) /* LED activity/heartbeat modes (offsets 309-310) */ w.put(&prefs.leds_radio_mode, sizeof(prefs.leds_radio_mode)); w.put(&prefs.leds_hb_mode, sizeof(prefs.leds_hb_mode)); - /* led_brightness at offset 311. */ - w.put(&prefs.led_brightness, sizeof(prefs.led_brightness)); return w.off; } @@ -360,8 +355,6 @@ void serverPrefsDecode(NodePrefs &prefs, const uint8_t *buf, size_t len) r.get(&prefs.tz_offset, sizeof(prefs.tz_offset)); r.get(&prefs.leds_radio_mode, sizeof(prefs.leds_radio_mode)); r.get(&prefs.leds_hb_mode, sizeof(prefs.leds_hb_mode)); - /* led_brightness at offset 311: a shorter file leaves the initNodePrefs() default. */ - r.get(&prefs.led_brightness, sizeof(prefs.led_brightness)); /* Only the explicit "off" magic disables LEDs; a legacy AGC interval or an * unwritten byte both mean "on". */ diff --git a/zephcore/helpers/PrefsCodec.h b/zephcore/helpers/PrefsCodec.h index a92953fe2..f65745c21 100644 --- a/zephcore/helpers/PrefsCodec.h +++ b/zephcore/helpers/PrefsCodec.h @@ -14,8 +14,8 @@ #include #include "NodePrefs.h" -#define COMPANION_PREFS_SIZE 273 -#define SERVER_PREFS_SIZE 312 +#define COMPANION_PREFS_SIZE 272 +#define SERVER_PREFS_SIZE 311 /* Companion file layout, including the fields shared with upstream's former * new_prefs up to offset 92. Returns the byte count (COMPANION_PREFS_SIZE), From 1b3d378c678ab988338151be65a1db3969934ee2 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:27:14 +0200 Subject: [PATCH 49/55] enable MCU temp for ESP --- docs/ARCHITECTURE.md | 2 +- releasenotes/RELEASE_NOTES_1.17.7-zephcore.md | 5 +++++ zephcore/CMakeLists.txt | 7 +++++++ zephcore/adapters/board/ZephyrBoard.cpp | 9 +++++++-- zephcore/boards/common/esp32_coretemp.overlay | 16 ++++++++++++++++ 5 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 zephcore/boards/common/esp32_coretemp.overlay diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3fc762bf0..2d9ec1932 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -858,7 +858,7 @@ 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. 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. 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. diff --git a/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md b/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md index 76b55392f..88beb98b7 100644 --- a/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md +++ b/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md @@ -175,6 +175,11 @@ 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. - **`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 diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index 013bdbfc2..1f4d06fbe 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -181,6 +181,13 @@ if(ZEPHCORE_PLATFORM STREQUAL "esp32") message(STATUS " ESP32 CPU: 80 MHz (observers keep the SoC maximum)") endif() + # 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 diff --git a/zephcore/adapters/board/ZephyrBoard.cpp b/zephcore/adapters/board/ZephyrBoard.cpp index b00645a8a..e706a4e5a 100644 --- a/zephcore/adapters/board/ZephyrBoard.cpp +++ b/zephcore/adapters/board/ZephyrBoard.cpp @@ -368,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; } 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"; +}; From 5ac31569431ab0bb8155b96758b990f4d46f3e7f Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:23:33 +0200 Subject: [PATCH 50/55] observer lightsleep + companion opt-in --- docs/ARCHITECTURE.md | 21 +++- docs/CLI_commands.md | 4 +- docs/DESIGN.md | 4 + docs/adr/0009-wifi-companion-boards.md | 2 +- releasenotes/RELEASE_NOTES_1.17.7-zephcore.md | 95 ++++++++++++++++++- zephcore/CMakeLists.txt | 39 ++++---- zephcore/adapters/ble/ZephyrBLE.cpp | 20 ++++ zephcore/adapters/mqtt/ZephyrMQTTPublisher.c | 29 ++++-- zephcore/adapters/usb/ZephyrCompanionUSB.cpp | 6 ++ zephcore/adapters/usb/ZephyrUSBCDC.cpp | 28 ++++++ zephcore/adapters/wifi/ZephyrWiFiStation.c | 80 ++++++++++++---- zephcore/adapters/wifi/ZephyrWiFiStation.h | 15 ++- zephcore/app/CompanionWifi.cpp | 14 ++- zephcore/app/RepeaterMesh.cpp | 3 +- zephcore/app/main_observer.cpp | 3 +- .../boards/common/esp32_cpu_80mhz.overlay | 9 +- zephcore/boards/common/pm_esp32.conf | 31 +++--- .../esp32/heltec_wifi_lora32_v3/board.conf | 12 ++- .../esp32/heltec_wifi_lora32_v3/zephcore.yml | 1 + .../esp32/heltec_wifi_lora32_v4/zephcore.yml | 1 + .../esp32/heltec_wifi_lora32_v43/zephcore.yml | 1 + .../esp32/heltec_wireless_tracker/board.conf | 12 ++- .../heltec_wireless_tracker/zephcore.yml | 1 + .../heltec_wireless_tracker_v2/zephcore.yml | 1 + zephcore/boards/esp32/station_g2/zephcore.yml | 1 + .../boards/esp32/xiao_esp32s3/zephcore.yml | 1 + zephcore/boards/example_board/README.md | 5 +- zephcore/boards/example_board/zephcore.yml | 4 + zephcore/cmake/zephcore_board.cmake | 6 ++ zephcore/helpers/NodePrefs.h | 10 +- zephcore/helpers/pm_esp32_wake.c | 43 ++++++++- zephcore/helpers/pm_sleep_guard.h | 5 + zephcore/helpers/ui/display.c | 26 ++++- zephcore/scripts/board_manifest.py | 6 +- 34 files changed, 439 insertions(+), 100 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2d9ec1932..2368f1551 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -838,6 +838,8 @@ Repeaters and room servers default to `CONFIG_ZEPHCORE_REPEATER_GPS_INTERVAL_SEC went away is reported as a DTR drop in three cases: DTR low, VBUS removed, and a bus reset. The reset case exists because the CDC class keeps the old line state across a reset, so nothing else says that the port the host had open is gone (a hub resetting upstream leaves VBUS up). Bus suspend, resume and reset also reach a second callback. + While a host is on the bus (from reset, configuration or resume until suspend or VBUS loss) the module blocks + ESP32 light sleep: sleep stops the USB clock and the host loses the device without being told. - **`ZephyrCompanionUSB`** (companion): the wired companion transport, over the CDC port or a plain UART (`zephcore,companion-uart`). Framing: `<` (to the node) or `>` (from it), a little-endian 16-bit length, the payload; a first byte that is printable and not `<` starts the text CLI instead. It has its own receive queue and an @@ -879,8 +881,19 @@ driver. Persisted in `prefs.json` as `zc.leds_brightness`; it is not part of the ### 7.6 WiFi / MQTT / TCP Transports -- **`adapters/wifi/ZephyrWiFiStation.c`**: WiFi STA client (ESP32) used by the observer, the repeater uplink and the WiFi companion -- **`adapters/mqtt/ZephyrMQTTPublisher.c`**: MQTT publisher for observed/uplinked packets +- **`adapters/wifi/ZephyrWiFiStation.c`**: WiFi STA client (ESP32) used by the observer, the repeater uplink and the WiFi companion. + The observer and the uplink run with the driver's WiFi power save (minimum modem sleep: the WiFi radio is off + between the access point's DTIM beacons); the WiFi companion requests power save off, as before. On ESP32 + light-sleep builds the station blocks SoC light sleep until the link is ready (DHCP, SNTP) and its client has + reported a session with `zc_wifi_station_session_up()`, and again whenever either is lost. With no such client + (the WiFi companion) the block stays for the life of the boot. While the SoC sleeps the WiFi MAC wakes it for + each beacon it listens to, and `helpers/pm_esp32_wake.c` refuses a sleep while the MAC's beacon timer is active. + On light-sleep companion builds the WiFi companion runs with power save too and reports a session at once, so + the station blocks sleep only while the link is not ready. +- **`adapters/mqtt/ZephyrMQTTPublisher.c`**: MQTT publisher for observed/uplinked packets. It reports its session to + the station: up at CONNACK, down when the session ends for any reason. Keepalive: PINGREQ once a quarter of + the 60 s keepalive is left, so a broker that enforces the keepalive without grace does not close an idle + session. - **`adapters/ota/wifi_ota.c`**: WiFi SoftAP + HTTP firmware upload to MCUboot slot1 (ESP32, requires `--sysbuild`) Companion transports, as upstream's `companion_radio`: each is a `BaseSerialInterface` @@ -888,7 +901,9 @@ Companion transports, as upstream's `companion_radio`: each is a `BaseSerialInte BLE, USB/UART and TCP can all be connected at once; every connected client gets every frame (a reply to one app also reaches the other). -- **`adapters/ble/ZephyrBLE.cpp`** (+ `ble_gatt_layout.cpp`, `ble_dfu.cpp`): BLE NUS +- **`adapters/ble/ZephyrBLE.cpp`** (+ `ble_gatt_layout.cpp`, `ble_dfu.cpp`): BLE NUS. Blocks ESP32 light sleep from + connect until the link is encrypted (pairing does not survive sleep); an encrypted link and advertising do not + block it. - **`adapters/usb/ZephyrCompanionUSB.cpp`**: USB CDC-ACM, or a plain UART (`zephcore,companion-uart` chosen node; the only link on the Bluetooth-less LoRa-E5), with the text CLI - **`adapters/transport/TcpCompanionTransport.c`**: TCP (port 5000, MeshCore `SerialWifiInterface` framing) on native Linux and on WiFi companions (`app/CompanionWifi.cpp`, `capabilities: wifi: true`) - **`adapters/transport/frame_txq.c`**: the TX queue BLE and TCP share (congestion, overflow slot, lossless replies) diff --git a/docs/CLI_commands.md b/docs/CLI_commands.md index 9a8ff203e..8ea9808c1 100644 --- a/docs/CLI_commands.md +++ b/docs/CLI_commands.md @@ -45,7 +45,7 @@ The text CLI of every ZephCore role. Commands are sent over USB serial (CDC-ACM) | `start ota` | ESP32: start WiFi AP + HTTP OTA server. nRF52: reboot into BLE OTA DFU mode | | `stop ota` | *(ZephCore only)* Stop WiFi OTA server (ESP32 only) | | `clkreboot` | Set clock to a fixed reference time (15 May 2024 8:50pm UTC) then reboot. On a board with a hardware RTC the reference time is written to the chip too, as upstream, so the reset survives the reboot | -| `powersaving` / `powersaving on` / `powersaving off` | Upstream's command and replies. On ESP32 light-sleep repeaters (boards with `light_sleep: true`) it gates light sleep: `off` (persisted) keeps the SoC awake, which also keeps a USB Serial/JTAG console attached; `on` allows sleep again, replying `on - After N s (console window)` while the console window is open, else `on - Immediate effect`. Default **on** on those builds, and prefs from firmware that stored the field without acting on it are read as on once. nRF52/nRF54L: stored, replies `on - Immediate effect` (they always idle in System ON; `off` changes nothing). Other boards: `Board not supported` | +| `powersaving` / `powersaving on` / `powersaving off` | Upstream's command and replies. On ESP32 light-sleep builds (repeaters of boards with `light_sleep: true`, companions of boards with `light_sleep_companion: true`) it gates light sleep: `off` (persisted) keeps the SoC awake, which also keeps a USB Serial/JTAG console attached; `on` allows sleep again, replying `on - After N s (console window)` while the console window is open, else `on - Immediate effect`. Default **on** on those repeaters, and prefs from firmware that stored the field without acting on it are read as on once. Default **off** on companions: a companion sleeps only after `powersaving on`, and then not while a USB host is attached, the display is on, or a Bluetooth link is not yet encrypted. nRF52/nRF54L: stored, replies `on - Immediate effect` (they always idle in System ON; `off` changes nothing). Other boards: `Board not supported` | --- @@ -388,7 +388,7 @@ The observer role (ESP32, `boards/common/observer.conf`) has its own CLI and non | `get extra.sf` | LR2021 side detectors: the extra spreading factors currently received alongside `sf`, comma-separated (bare, no `> ` prefix), or `No extra SF configured`. Reflects the saved prefs, not what the chip accepted — if the set became invalid after an `sf`/`bw` change it is reported here but was refused at boot (a `WRN` line says so). | | `get adc.multiplier` | Battery voltage ADC calibration multiplier | | `get bootloader.ver` | Bootloader version string | -| `get pm` | *(ZephCore only)* ESP32 light-sleep builds: `> on asleep P% sleeps S/E wake tT gG oO radioR btnB [win Ns]` (`off` when powersaving is off): powersaving state, share of uptime asleep, sleeps taken out of light-sleep entries, wake causes (RTC timer, GPIO, other), sleeps that ended with the radio IRQ / user button active, and the console window left. Radio wakes should track the packets the node hears. Other builds: `Error: no light sleep on this build` | +| `get pm` | *(ZephCore only)* ESP32 light-sleep builds: `> on asleep P% sleeps S/E wake tT gG wW oO radioR btnB [win Ns]` (`off` when powersaving is off): powersaving state, share of uptime asleep, sleeps taken out of light-sleep entries, wake causes (RTC timer, GPIO, WiFi, other), sleeps that ended with the radio IRQ / user button active, and the console window left. Radio wakes should track the packets the node hears. WiFi wakes are the beacons an associated station (the uplink) wakes for: about ten a second on most access points, none without the uplink. Other builds: `Error: no light sleep on this build` | | `get pwrmgt.support` | `> supported` on nRF52 (VBUS detection, boot voltage), else `> unsupported` — upstream's reply | | `get pwrmgt.source` | nRF52: `> external` (VBUS present) or `> battery`; elsewhere `ERROR: Power management not supported` | | `get pwrmgt.bootreason` | `> Reset: ; Shutdown: `. The cause is this boot's hardware reset cause as labels (`PIN`, `SOFTWARE`, `BROWNOUT`, `POR`, `WATCHDOG`, `LOWPOWER` for a wake from System OFF, ...), followed by `(crash in , pc 0x...)` when the previous run ended in a fatal error and rebooted — resolve the pc with `addr2line` against the same build. The shutdown reason is `User Request`, `Low Voltage` or `None` | diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 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 88beb98b7..813bbf85d 100644 --- a/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md +++ b/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md @@ -4,10 +4,14 @@ Faster GPS fixes on three trackers, a clock that survives a reboot, fixes for US could stop responding right after boot or when the port closed during a contact sync, a contacts import that no longer stalls every few seconds, more reliable detection of clock chips, repeaters that power down an external flash chip they do not use, a formatter that erases that chip on every -board that has one, and a few fixes ported from upstream MeshCore. +board that has one, ESP32 repeaters with the WiFi uplink and observers that run much cooler and keep +their broker connection, optional light sleep for ESP32-S3 companions, and a few fixes ported from +upstream MeshCore. > [!NOTE] -> A normal upgrade keeps your identity, settings, contacts and phone pairing. +> A normal upgrade keeps your identity, settings, contacts and phone pairing. One exception: +> companions on the Heltec V3 and the Heltec Wireless Tracker now hold 120 and 110 contacts +> instead of 140 and 130; see "Companions can light-sleep" below. --- @@ -142,6 +146,87 @@ This was found while looking into a report of a XIAO nRF52840 repeater drawing a 1.17.6 than on 1.17.4 (thanks to **Kimotu**). Whether it accounts for that difference is not confirmed yet. +## ESP32 repeaters with the WiFi uplink, and observers, run cooler + +**svenlange2** reported in [#107](https://github.com/liquidraver/ZephCore/issues/107) that a Heltec +V4.3 repeater ran about 20 C cooler than stock firmware, and was back at 45 C as soon as the WiFi + +MQTT uplink was built in and connected. Thank you for the report and for the measurements: this +whole section came out of them. + +The report was right. A repeater with the uplink kept its WiFi radio fully on all the time, and on +the boards that light-sleep as repeaters it never slept again once the uplink was in the build. +Observers kept the WiFi radio on in the same way. Neither was necessary. This release changes both, +which resolves #107: + +- **WiFi power save is on** for the uplink and for observers: the WiFi radio sleeps between the + access point's beacons. The connection to the access point and to the broker is kept. +- **Light sleep works with the uplink connected**, on the boards that light-sleep as repeaters + (Heltec V3, V4, V4.3, Wireless Tracker, Wireless Tracker V2). The node sleeps between beacons once + it is connected to the broker. It stays awake while it is connecting, and for as long as the WiFi + network or the broker cannot be reached. +- **An idle connection to the broker is no longer dropped every two minutes.** Some brokers, and TLS + front ends placed before one, close a connection that has been silent for exactly the keepalive + time (60 seconds). The keepalive message was sent only when that time was already up, so on a + quiet mesh the node lost the connection every two minutes and reconnected. It is now sent 15 + seconds earlier. +- **Observers run the CPU at 80 MHz**, like every other role (240 MHz before, 160 MHz on C3 and C6 + boards). Connecting to the broker takes a few tenths of a second longer. + +On a XIAO ESP32-S3 the chip temperature went from 47 C to about 35 C with WiFi power save alone, and +to about 26 C with light sleep as well, with the uplink connected the whole time. The Heltec V4.3 +from the report is one of the boards that now sleeps with the uplink connected. A received LoRa +packet wakes the node as before, and `powersaving off` still keeps it awake. + +The MCU temperature that made the comparison possible is also from #107: see "Also in this +release". + +`get pm` now counts the wakes for WiFi beacons on their own: its reply has a new `w` field between +`g` and `o`. + +A light-sleep repeater with a display now stays awake while the display is on. The display turns +itself off 10 seconds after boot or after the last button press, as before. + +## Companions can light-sleep on seven ESP32-S3 boards (off by default) + +Companions on these boards can now put the processor into light sleep between events: + +- Heltec V3, V4, V4.3, Wireless Tracker and Wireless Tracker V2, +- Seeed XIAO ESP32-S3, +- UnitEng Station G2. + +It is **off by default**. Turn it on with `powersaving on` from the app's command line, and off +again with `powersaving off`; the setting is kept across reboots. + +This is new, and it is offered for testing: reports of how it behaves on your board are welcome, +the Station G2 most of all. Repeaters on the XIAO ESP32-S3 and the Station G2 do not light-sleep +yet. + +With it on, the node keeps advertising, stays connected to the app over Bluetooth or WiFi and +receives LoRa packets as before. It does not sleep: + +- while a computer is attached over USB, +- while the display is on, +- while a phone is pairing, +- for 10 minutes after boot and after each press of the user button. + +> [!NOTE] +> **USB on a sleeping companion:** a companion that is asleep is not seen by a computer it is +> plugged into. Press the user button after plugging in, or send `powersaving off` from the app +> first. + +WiFi companions on these boards now use WiFi power save, with powersaving on or off: the WiFi +radio sleeps between the access point's beacons and the connection is kept. + +> [!NOTE] +> **Heltec V3 and Wireless Tracker: fewer contacts.** These two run WiFi and Bluetooth without +> extra memory, and light sleep needs some of it. The contact limit goes from 140 to 120 on the +> Heltec V3 and from 130 to 110 on the Wireless Tracker, whether or not you turn powersaving on. +> A node holding more keeps the first 120 (or 110) after the update; export your contacts from +> the app before updating if you are above that. The offline message queue stays at 256. + +On the Heltec V3 a computer talking to the companion over USB also keeps it awake, from its +first message until the next reboot. + ## Formatter: the external flash chip is erased on every board that has one The nRF52 formatters (`SoftDevice_v6_formatter` and `SoftDevice_v7_formatter`, as `.uf2` and as the @@ -200,9 +285,9 @@ matches the SoftDevice version of your bootloader. It also brings a fix for boards on WiFi: the starting sequence number of a TCP connection (the companion port and the update page) was predictable, and is now derived from a secret as intended. - **Heltec V3 and Wireless Tracker companions** (WiFi and Bluetooth together, no PSRAM) had under - 1 KB of memory left; they now have about 2.5 KB. The Bluetooth controller's stack on ESP32-S3 and - ESP32-C3 companions was 4 KB and is now 2 KB; it uses about 1.1 KB with WiFi connected. Nothing - changes in use. + 1 KB of memory left; they now have about 2.5 KB, with light sleep included. The Bluetooth + controller's stack on ESP32-S3 and ESP32-C3 companions was 4 KB and is now 2 KB; it uses about + 1.1 KB with WiFi connected. - **Documentation**: the README was reorganised, build and flashing instructions moved to `docs/BUILDING.md`, and the example board and porting guide were rewritten for the current board layout. diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index 1f4d06fbe..8bb9c6769 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -172,14 +172,11 @@ if(ZEPHCORE_PLATFORM STREQUAL "esp32") # first-order term in a battery node's draw — unlike nRF52, which already # idles at microamps. Every ESP32 build runs at 80 MHz, matching Arduino # MeshCore; hal_espressif derives ESP_DEFAULT_CPU_FREQ_MHZ from cpu@0, so no - # module patch is needed. Observers keep the SoC maximum (240 MHz S3/classic, - # 160 MHz C3/C6): full WiFi + TLS + MQTT on mains or big-panel solar, so - # throughput matters more than current. "Don't apply the overlay" rather than - # an override, so the per-SoC maximum is inherited, not hardcoded. - if(NOT ZEPHCORE_BUILD_ROLE STREQUAL "observer") - zephcore_add_overlay(${_zc}/esp32_cpu_80mhz.overlay) - message(STATUS " ESP32 CPU: 80 MHz (observers keep the SoC maximum)") - endif() + # module patch is needed. Every role, observers included: 80 MHz is the + # lowest clock WiFi runs at and carries WiFi + TLS + MQTT + # (devdocs/lld/13-power-management.md, section 12). + zephcore_add_overlay(${_zc}/esp32_cpu_80mhz.overlay) + message(STATUS " ESP32 CPU: 80 MHz") # SoC temperature sensor (MCU temperature in telemetry, image-calibration # drift on LR11xx/LR2021): every role, every SoC that has the node. The @@ -188,18 +185,24 @@ if(ZEPHCORE_PLATFORM STREQUAL "esp32") zephcore_add_overlay(${_zc}/esp32_coretemp.overlay) endif() - # Light sleep: REPEATERS on boards that declare it. 80 MHz cuts the idle draw; - # light sleep is what removes it (mechanism and requirements: - # boards/common/pm_esp32.conf). Repeaters only: the Espressif HCI driver takes - # no pm_policy locks, so a companion would light-sleep mid-advertising or - # mid-connection; repeater.conf sets CONFIG_BT=n. Which boards qualify is a - # hardware fact (DIO1 on an RTC-wake-capable GPIO, and validated on hardware), - # so the board declares it: `capabilities: light_sleep: true` in its - # zephcore.yml. pm_esp32.conf lists which boards fail the wiring test and why; - # a board that passes it but was never validated (v4_r8) stays undeclared. + # Light sleep: on boards that declare it. 80 MHz cuts the idle draw; light + # sleep is what removes it (mechanism and requirements: + # boards/common/pm_esp32.conf). Which boards qualify is a hardware fact + # (the radio IRQ and button wake the SoC, pins are held, validated on + # hardware), so the board declares it in its zephcore.yml: + # `light_sleep: true` for the repeater, `light_sleep_companion: true` for the + # companion. Two lines because they differ: a repeater sleeps by default, so + # its line waits for validation on hardware (v4_r8, which only passes the + # wiring test, stays undeclared); a companion sleeps only after + # `powersaving on`, and its build costs about 4 KB of DRAM, which a WiFi + + # BLE companion without PSRAM gives up in contacts + # (devdocs/lld/13-power-management.md, section 13). if(ZEPHCORE_BUILD_ROLE STREQUAL "repeater" AND ZEPHCORE_BOARD_LIGHT_SLEEP) zephcore_add_conf(${_zc}/pm_esp32.conf) - message(STATUS " ESP32 light sleep: auto-enabled (DIO1 wake-capable repeater board)") + message(STATUS " ESP32 light sleep: auto-enabled (repeater, board declares it)") + elseif(ZEPHCORE_BUILD_ROLE STREQUAL "companion" AND ZEPHCORE_BOARD_LIGHT_SLEEP_COMPANION) + zephcore_add_conf(${_zc}/pm_esp32.conf) + message(STATUS " ESP32 light sleep: auto-enabled (companion, board declares it; opt-in at run time)") endif() # WiFi OTA for S3/C-series repeaters: WiFi AP + HTTP server + MCUboot image diff --git a/zephcore/adapters/ble/ZephyrBLE.cpp b/zephcore/adapters/ble/ZephyrBLE.cpp index 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/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 337eefe35..043548761 100644 --- a/zephcore/app/main_observer.cpp +++ b/zephcore/app/main_observer.cpp @@ -403,7 +403,8 @@ int main(void) print_banner(); /* Start WiFi (non-blocking — MQTT thread waits for WIFI_READY_BIT) */ - zc_wifi_station_start(s_creds.wifi_ssid, s_creds.wifi_psk, time_sync_cb); + zc_wifi_station_start(s_creds.wifi_ssid, s_creds.wifi_psk, time_sync_cb, + true /* WiFi power save */); /* Start MQTT publisher thread */ char client_id[64]; diff --git a/zephcore/boards/common/esp32_cpu_80mhz.overlay b/zephcore/boards/common/esp32_cpu_80mhz.overlay index 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/pm_esp32.conf b/zephcore/boards/common/pm_esp32.conf index 0ba63186b..e0dd69c30 100644 --- a/zephcore/boards/common/pm_esp32.conf +++ b/zephcore/boards/common/pm_esp32.conf @@ -1,8 +1,9 @@ # ESP32 system power management — light sleep between events. # # AUTO-ENABLED for repeater builds on boards whose zephcore.yml declares -# `light_sleep: true` (cmake/zephcore_board.cmake, CMakeLists.txt). Passing it -# by hand is supported for testing another board: +# `light_sleep: true`, and for companion builds on boards that declare +# `light_sleep_companion: true` (cmake/zephcore_board.cmake, CMakeLists.txt). +# Passing it by hand is supported for testing another board: # # west build -b xiao_esp32s3/esp32s3/procpu zephcore --pristine --sysbuild -- # -DEXTRA_CONF_FILE="boards/common/repeater.conf;boards/common/pm_esp32.conf" # @@ -37,16 +38,24 @@ # button to reopen the window, or send `powersaving off` (persisted) over LoRa; # `powersaving on` allows sleep again. Light-sleep builds default to on. # -# WiFi: the ESP32 WiFi driver starts WiFi at boot (NULL mode), and a started -# WiFi library holds its modem lock, so NO S3 repeater (they all carry WiFi OTA) -# ever light-slept before 2026-09-26. helpers/pm_esp32_wake.c stops WiFi at -# boot; `start ota` (AP) and a station connect start it again. While WiFi is in -# use a pm_policy lock is held — the OTA session for its duration -# (adapters/ota/wifi_ota.c), an uplink or observer station permanently -# (adapters/wifi/ZephyrWiFiStation.c). +# WiFi: the ESP32 WiFi driver starts WiFi at boot (NULL mode), and the WiFi +# library holds its modem lock for as long as the WiFi radio is on, so NO S3 +# repeater (they all carry WiFi OTA) ever light-slept before 2026-09-26. +# helpers/pm_esp32_wake.c stops WiFi at boot; `start ota` (AP) and a station +# connect start it again. An OTA session holds a pm_policy lock for its +# duration (adapters/ota/wifi_ota.c). A station (the MQTT uplink) runs in WiFi +# power save: the library gives its lock up between beacons and the WiFi MAC +# wakes the SoC for each one. The station holds a lock of its own until its +# MQTT session is up, and again whenever the link or the session is lost +# (adapters/wifi/ZephyrWiFiStation.c); a node whose access point is away stays +# awake. devdocs/lld/13-power-management.md, section 12. # -# REPEATERS ONLY for now. Companions keep BLE up; making the controller's -# modem sleep coexist with system light sleep is the open question in LLD 13. +# COMPANIONS sleep only after `powersaving on` (their default is off), and then +# not while a USB host is on the bus, the display is on, or a BLE link is not +# yet encrypted. The BLE controller gives its lock up between events and its +# wake timer bounds each sleep (CONFIG_PM_CUSTOM_TICKS_HOOK below). The build +# costs about 4 KB of DRAM, which is why the companion has its own capability +# line. devdocs/lld/13-power-management.md, section 13. # # CHECKING A BOARD: `get pm` shows the share of time asleep, the sleep count and # wake causes (t = RTC timer, g = GPIO, o = other) and per-pin wake counts. diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.conf b/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.conf index 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/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/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..5e1b3a292 100644 --- a/zephcore/boards/esp32/station_g2/zephcore.yml +++ b/zephcore/boards/esp32/station_g2/zephcore.yml @@ -2,6 +2,7 @@ target: station_g2/esp32s3/procpu capabilities: wifi: true + light_sleep_companion: true release: roles: [companion, repeater] catalog: diff --git a/zephcore/boards/esp32/xiao_esp32s3/zephcore.yml b/zephcore/boards/esp32/xiao_esp32s3/zephcore.yml index 0391df8c1..f198f4977 100644 --- a/zephcore/boards/esp32/xiao_esp32s3/zephcore.yml +++ b/zephcore/boards/esp32/xiao_esp32s3/zephcore.yml @@ -2,6 +2,7 @@ target: xiao_esp32s3/esp32s3/procpu capabilities: wifi: true + light_sleep_companion: true release: roles: [companion, repeater] catalog: diff --git a/zephcore/boards/example_board/README.md b/zephcore/boards/example_board/README.md index 61760ca99..6b368cf01 100644 --- a/zephcore/boards/example_board/README.md +++ b/zephcore/boards/example_board/README.md @@ -207,7 +207,10 @@ Only `lora0` is required. Everything else enables its feature by being present. - **Manifest capabilities:** `wifi: true` gives the companion a WiFi transport and belongs only on boards with the RAM for WiFi and BLE together ([ADR 0009](../../../docs/adr/0009-wifi-companion-boards.md)). `light_sleep: true` makes repeaters light-sleep; the requirements are in `boards/common/pm_esp32.conf`, and - it is declared only after it was validated on the hardware. Either way, give NSS, RESET and every MCU-driven + it is declared only after it was validated on the hardware. `light_sleep_companion: true` gives the companion + build light sleep (off until the user sends `powersaving on`); it needs the same pin holds and wake pins and + about 4 KB of free DRAM, which a WiFi + BLE companion without PSRAM pays for in contacts. Being opt-in, it may + be declared before `light_sleep`. Either way, give NSS, RESET and every MCU-driven RF switch or FEM line `ESP32_GPIO_SLEEP_HOLD_EN` from the start. - **RAM, not flash, limits the companion.** Boards without PSRAM lower `MAX_CONTACTS`; the classic ESP32 also trims channels and the offline queue. diff --git a/zephcore/boards/example_board/zephcore.yml b/zephcore/boards/example_board/zephcore.yml index 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/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/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index 89139f926..a9816f197 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -238,10 +238,12 @@ static inline void auto_shutdown_upgrade(NodePrefs *p) { } /* `powersaving` gates ESP32 light sleep (helpers/pm_esp32_wake.c). On a - * light-sleep build the default is on: those builds slept unconditionally - * before the switch existed, and the stored 0 carried no intent. Elsewhere - * it is upstream's default, off. */ -#if defined(CONFIG_PM) && defined(CONFIG_SOC_FAMILY_ESPRESSIF_ESP32) + * light-sleep repeater or room server the default is on: those builds slept + * unconditionally before the switch existed, and the stored 0 carried no + * intent. Elsewhere, light-sleep companions included, it is upstream's + * default, off. */ +#if defined(CONFIG_PM) && defined(CONFIG_SOC_FAMILY_ESPRESSIF_ESP32) && \ + !defined(CONFIG_ZEPHCORE_ROLE_COMPANION) #define POWERSAVING_DEFAULT 1 #else #define POWERSAVING_DEFAULT 0 diff --git a/zephcore/helpers/pm_esp32_wake.c b/zephcore/helpers/pm_esp32_wake.c index 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/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) From 12eba394a4b15768e600f6c8cc3ba8f8563a1cb2 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:27:56 +0200 Subject: [PATCH 51/55] add erase and reboot to observers --- docs/CLI_commands.md | 4 +- releasenotes/RELEASE_NOTES_1.17.7-zephcore.md | 4 ++ zephcore/app/main_observer.cpp | 45 ++++++++++++++++++- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/docs/CLI_commands.md b/docs/CLI_commands.md index 8ea9808c1..6f76ecf09 100644 --- a/docs/CLI_commands.md +++ b/docs/CLI_commands.md @@ -292,11 +292,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 | diff --git a/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md b/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md index 813bbf85d..b0637b0fe 100644 --- a/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md +++ b/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md @@ -265,6 +265,10 @@ matches the SoftDevice version of your bootloader. 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 diff --git a/zephcore/app/main_observer.cpp b/zephcore/app/main_observer.cpp index 043548761..21cd1cfab 100644 --- a/zephcore/app/main_observer.cpp +++ b/zephcore/app/main_observer.cpp @@ -207,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("========================="); } @@ -239,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; @@ -250,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)); @@ -312,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) From e00b128221e85e5fe4c3f3333c78c739ef6eba87 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:35:11 +0200 Subject: [PATCH 52/55] observer TLS fix --- docs/ARCHITECTURE.md | 2 ++ releasenotes/RELEASE_NOTES_1.17.7-zephcore.md | 4 ++++ zephcore/boards/common/observer.conf | 4 ++++ zephcore/boards/common/repeater_uplink.conf | 4 ++++ 4 files changed, 14 insertions(+) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2368f1551..30ad512e3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -894,6 +894,8 @@ driver. Persisted in `prefs.json` as `zc.leds_brightness`; it is not part of the 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` diff --git a/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md b/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md index b0637b0fe..f15143864 100644 --- a/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md +++ b/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md @@ -164,6 +164,10 @@ which resolves #107: (Heltec V3, V4, V4.3, Wireless Tracker, Wireless Tracker V2). The node sleeps between beacons once it is connected to the broker. It stays awake while it is connecting, and for as long as the WiFi network or the broker cannot be reached. +- **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 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/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 From 7dc00bb7558369e98c59146bd23810fd7b61bce6 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:20:48 +0200 Subject: [PATCH 53/55] west bump --- releasenotes/RELEASE_NOTES_1.17.7-zephcore.md | 2 +- zephcore/west.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md b/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md index f15143864..202767289 100644 --- a/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md +++ b/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md @@ -287,7 +287,7 @@ 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 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: From 75543d4f2261387d7b48c41758d0aec97426f627 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:12:50 +0200 Subject: [PATCH 54/55] enable lightsleep for all --- releasenotes/RELEASE_NOTES_1.17.7-zephcore.md | 26 +++++++++++++++---- zephcore/boards/common/pm_esp32.conf | 2 +- zephcore/boards/esp32/station_g2/zephcore.yml | 1 + .../boards/esp32/xiao_esp32s3/zephcore.yml | 1 + 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md b/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md index 202767289..c7631138d 100644 --- a/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md +++ b/releasenotes/RELEASE_NOTES_1.17.7-zephcore.md @@ -5,8 +5,8 @@ could stop responding right after boot or when the port closed during a contact 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, ESP32 repeaters with the WiFi uplink and observers that run much cooler and keep -their broker connection, optional light sleep for ESP32-S3 companions, and a few fixes ported from -upstream MeshCore. +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. One exception: @@ -161,7 +161,8 @@ which resolves #107: - **WiFi power save is on** for the uplink and for observers: the WiFi radio sleeps between the access point's beacons. The connection to the access point and to the broker is kept. - **Light sleep works with the uplink connected**, on the boards that light-sleep as repeaters - (Heltec V3, V4, V4.3, Wireless Tracker, Wireless Tracker V2). The node sleeps between beacons once + (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 @@ -190,6 +191,22 @@ release". 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: @@ -202,8 +219,7 @@ It is **off by default**. Turn it on with `powersaving on` from the app's comman again with `powersaving off`; the setting is kept across reboots. This is new, and it is offered for testing: reports of how it behaves on your board are welcome, -the Station G2 most of all. Repeaters on the XIAO ESP32-S3 and the Station G2 do not light-sleep -yet. +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: diff --git a/zephcore/boards/common/pm_esp32.conf b/zephcore/boards/common/pm_esp32.conf index e0dd69c30..976f53e97 100644 --- a/zephcore/boards/common/pm_esp32.conf +++ b/zephcore/boards/common/pm_esp32.conf @@ -5,7 +5,7 @@ # `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 diff --git a/zephcore/boards/esp32/station_g2/zephcore.yml b/zephcore/boards/esp32/station_g2/zephcore.yml index 5e1b3a292..43f612ab5 100644 --- a/zephcore/boards/esp32/station_g2/zephcore.yml +++ b/zephcore/boards/esp32/station_g2/zephcore.yml @@ -2,6 +2,7 @@ target: station_g2/esp32s3/procpu capabilities: wifi: true + light_sleep: true light_sleep_companion: true release: roles: [companion, repeater] diff --git a/zephcore/boards/esp32/xiao_esp32s3/zephcore.yml b/zephcore/boards/esp32/xiao_esp32s3/zephcore.yml index f198f4977..faa96b8d9 100644 --- a/zephcore/boards/esp32/xiao_esp32s3/zephcore.yml +++ b/zephcore/boards/esp32/xiao_esp32s3/zephcore.yml @@ -2,6 +2,7 @@ target: xiao_esp32s3/esp32s3/procpu capabilities: wifi: true + light_sleep: true light_sleep_companion: true release: roles: [companion, repeater] From 8c800f6cf1327bb784c3ad69777d4d70a4fdd9f9 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 10 Oct 2026 16:02:50 -0700 Subject: [PATCH 55/55] hw: take work/hw-cli's rework (c167178, 06de0ca) The I2C scan probes with a one-byte read instead of a zero-length write, and the code is trimmed to upstream's AGENTS.md: comment diet, unreachable guards and unused parameters removed, one reset-cause helper, toHex for the device id, one devicetree predicate, upstream's `... next:N` marker, and sensors read only when on the returned page. Same tree as 06de0ca. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 2 +- docs/CLI_commands.md | 19 +- zephcore/adapters/clock/ZephyrRTCDiscover.c | 21 +- zephcore/adapters/clock/ZephyrRTCDiscover.h | 25 +- zephcore/helpers/CommonCLI.cpp | 14 +- zephcore/helpers/HardwareReport.cpp | 498 +++++--------------- zephcore/helpers/HardwareReport.h | 43 +- 7 files changed, 160 insertions(+), 462 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 452f2068f..c31ccd2e5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -863,7 +863,7 @@ Repeaters and room servers default to `CONFIG_ZEPHCORE_REPEATER_GPS_INTERVAL_SEC **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. 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. 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 diff --git a/docs/CLI_commands.md b/docs/CLI_commands.md index a2bf3b91d..a2fdbd291 100644 --- a/docs/CLI_commands.md +++ b/docs/CLI_commands.md @@ -61,14 +61,14 @@ commands only from upstream's set, and ZephCore-only commands stay local by desi | 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 boot probe adopted for write-back), `gnss`, `i2c` count, `reset`. `hw N` resumes it | +| `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 boot probe's outcome for each | +| `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, or its UART, disabled in devicetree reports `declared but disabled` (the summary says `disabled`), since the GPS manager does not use it | -| `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 is read on the spot, as for a telemetry request | -| `hw all [start]` | `board`, `rtc`, `i2c`, `gps` and `sensors`, in that order, so it reads each sensor. Not the summary, and not `hw i2c scan`, which probes every address | +| `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). @@ -84,7 +84,8 @@ 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 zero-length write to each address. It finds +- **`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, @@ -123,8 +124,8 @@ Every field is something the firmware *knows*. It never infers: 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 boot probe never reached, whose I2C bus was not ready, or whose read - failed with a timeout or a busy bus, reports `unprobed`, not `absent`. The probe stops at the +- 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 @@ -146,7 +147,7 @@ remote admin, since a remote reply rides the caller's LoRa packet buffer. On a c 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 ` next:N` and `hw
N` (or `hw N` for the summary) resumes from that +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`. diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.c b/zephcore/adapters/clock/ZephyrRTCDiscover.c index 5514b2323..1d19dbc4c 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.c +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.c @@ -115,13 +115,9 @@ static bool s_probed; static bool s_skipped_ff; static bool s_reprobed; -/* Per-candidate probe outcome, for the reporting accessors. Stays UNPROBED for - * entries the loop never reached -- it stops at the first chip holding a valid - * time, so "not absent" and "present" are different answers and we keep them - * apart rather than inferring one from the other. */ +/* 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 together for zephcore_rtc_snapshot(): a - * re-probe publishes them from the system work queue while `hw` reads them. */ +/* 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)) @@ -617,7 +613,6 @@ static void rv3028_save_retry_fn(struct k_work *work) static bool rtc_probe_run(uint32_t *epoch_out, uint8_t state[], const struct rtc_desc **adopted) { - for (size_t i = 0; i < ARRAY_SIZE(rtc_descs); i++) { const struct rtc_desc *d = &rtc_descs[i]; uint8_t blk[7]; @@ -718,12 +713,8 @@ static bool rtc_probe_run(uint32_t *epoch_out, uint8_t state[], return false; } -/* rtc_probe() can run more than once: zephcore_rtc_save() probes if restore - * never ran, or again after an all-0xFF skip with no chip adopted. Each run - * starts from no outcome, so candidates it never reached report UNPROBED - * rather than the previous run's answer. The outcomes and the adopted chip - * are collected locally and published together at the end, so `hw` sees - * one run or the other, never a mix. */ +/* 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)]; @@ -802,10 +793,6 @@ size_t zephcore_rtc_snapshot(struct zephcore_rtc_entry *out, size_t max) { size_t n = MIN(max, ARRAY_SIZE(rtc_descs)); - if (out == NULL) { - return 0; - } - k_spinlock_key_t key = k_spin_lock(&s_report_lock); for (size_t i = 0; i < n; i++) { diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.h b/zephcore/adapters/clock/ZephyrRTCDiscover.h index bdc3d3899..3ef8f1af0 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.h +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.h @@ -47,22 +47,8 @@ bool zephcore_rtc_restore(uint32_t *epoch_out); */ void zephcore_rtc_save(uint32_t epoch); -/* - * ---- Reporting accessors ------------------------------------------------- - * - * Discovery knows which chips the board declares and which one it adopted, but - * until now kept both to itself, so a release build could not say what RTC it - * is driving (every MESH_DEBUG_* call is compiled away). These expose that. - * The one change to probing: each probe run starts from no adopted chip, so a - * second run reports its own outcome rather than the first run's, and a run - * publishes its outcome only when it ends. - * - * They report only what discovery actually established. The probe loop stops - * at the first chip holding a valid time, so candidates after it are never - * reached -- those report UNPROBED rather than being reported as absent. A - * candidate whose I2C bus was not ready reports UNPROBED for the same reason: - * a probe that could not run is not evidence that the chip is missing. - */ +/* 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 @@ -84,7 +70,7 @@ 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 boot probe found */ + enum zephcore_rtc_state state; /* what the last probe run found */ bool active; /* adopted as the write-back target */ }; @@ -93,10 +79,7 @@ struct zephcore_rtc_entry { size_t zephcore_rtc_declared(void); /* Copy up to max candidates into out[], in declaration order, and return how - * many were copied. The states and the adopted marker come from one probe - * run, even while a re-probe runs on another thread. No entry is active when - * none was adopted, which includes discovery not having run yet -- check - * zephcore_rtc_probed(). */ + * 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. */ diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index 3b97363ce..90b745555 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -503,24 +503,18 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch snprintf(reply, CLI_REPLY_SIZE, "File system erase: Err"); } } else if (memcmp(command, "hw", 2) == 0 && (command[2] == '\0' || command[2] == ' ')) { - /* replyCap() allows for a remote reply's "xx|" prefix. On a - * companion a local reply may be the app's CLI frame, which carries - * MAX_FRAME_SIZE - 1 bytes and cannot be told apart from the USB - * console here, so page to the frame rather than have it cut the - * resume marker off. */ + /* 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 - /* replyCap() does not take a local request's "xx|" prefix off, but - * every role's handleCommand() has already moved reply past it. */ - if (sender_timestamp == 0 && _reply_hdr_used < cap) { + /* reply already starts past a local request's "xx|" prefix. */ + if (sender_timestamp == 0) { cap -= _reply_hdr_used; } - zephcore_hw::handle(command, reply, cap, sender_timestamp == 0, - _board, _rtc, _callbacks); + 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) { diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index 56e6d3153..c3dae2851 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -16,6 +16,7 @@ #include #include +#include #include #include @@ -26,58 +27,27 @@ extern "C" { #include "../adapters/clock/ZephyrRTCDiscover.h" } -#if __has_include("../adapters/sensors/ZephyrEnvSensors.h") +#include "../adapters/gps/ZephyrGPSManager.h" #include "../adapters/sensors/ZephyrEnvSensors.h" -#define HW_HAS_SENSOR_HDR 1 -#endif -#if __has_include("../adapters/gps/ZephyrGPSManager.h") -#include "../adapters/gps/ZephyrGPSManager.h" -#define HW_HAS_GPS_HDR 1 -#endif +#define HW_GNSS_NODE DT_NODELABEL(gnss) -/* The GNSS node and its UART both okay: the devicetree half of the GPS - * manager's HAS_GPS_UART (adapters/gps/gps_internal.h), which also needs a - * supported compatible; `available` below reports that part. */ -#if DT_NODE_EXISTS(DT_NODELABEL(gnss)) -#define HW_GNSS_OKAY (DT_NODE_HAS_STATUS(DT_NODELABEL(gnss), okay) && \ - DT_NODE_HAS_STATUS(DT_BUS(DT_NODELABEL(gnss)), okay)) -#else -#define HW_GNSS_OKAY 0 -#endif +/* 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 reserved for the " next:NNNNN" resume marker, so a truncated page can - * always say where to resume. Without the reservation the marker is exactly - * what gets cut off, leaving an unresumable page. */ -#define HW_NEXT_RESERVE 12 - -/* - * Largest page index the marker can carry and the parser will accept. - * - * Set by what the marker format holds: " next:" is 6 characters, so - * HW_NEXT_RESERVE (12) leaves 5 digits plus the NUL -- " next:99999" is - * exactly 11 characters. The emitter clamps to this rather than letting - * snprintf cut digits, and the parser accepts up to the same value, because a - * page that advertises an index the parser then silently rewrites pages the - * same lines forever. One constant, so the two cannot drift apart. - */ +/* 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 this, so `hw all` and a single subcommand page - * identically and there is one place that can overflow. Line indices are - * unsigned end to end: a resume index parsed as unsigned and then rendered - * signed is how a page emits "next:-1" and becomes unresumable. - */ +/* Paged line sink: every section writes through it, so all page alike. */ struct Sink { char *buf; - size_t buf_cap; /* the caller's actual buffer size */ - size_t cap; /* usable capacity, already excluding HW_NEXT_RESERVE */ + 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 */ @@ -88,10 +58,7 @@ struct Sink { void sink_init(Sink *s, char *buf, size_t cap, unsigned first) { s->buf = buf; - /* Remember the caller's real capacity: cap below is the reduced write - * limit, and sink_finish() must not assume the difference exists. */ - s->buf_cap = cap; - s->cap = (cap > HW_NEXT_RESERVE) ? cap - HW_NEXT_RESERVE : 1; + s->cap = cap - HW_NEXT_RESERVE; s->len = 0; s->first = first; s->line = 0; @@ -100,49 +67,43 @@ void sink_init(Sink *s, char *buf, size_t cap, unsigned first) buf[0] = '\0'; } -void sink_line(Sink *s, const char *fmt, ...) +/* True if the next line will not be emitted; it is counted as skipped. */ +bool sink_skip(Sink *s) { - unsigned idx = s->line++; + if (s->full || s->line < s->first) { + s->line++; + return true; + } + return false; +} - if (s->full || idx < s->first) { +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 (w < 0) { - return; - } if ((size_t)w >= sizeof(line)) { - /* Longer than the render buffer: mark the cut, as below. */ 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 whole page would otherwise never be - * emitted, and every resume would point back at it. Emit what - * fits, marked as cut, so paging moves on. The page is not - * marked full here: it now holds cap - 1 bytes, so any next - * line fails the fit test below and sets ` next:N` itself, and - * with no next line there is nothing left to resume. */ + /* A line longer than a page is cut, so a resume moves past it. */ size_t keep = s->cap - 1; line[keep] = '\0'; - if (keep > 3) { - memcpy(line + keep - 3, "...", 3); - } + memcpy(line + keep - 3, "...", 3); need = keep; } if (s->len + need >= s->cap) { s->full = true; - /* Clamp rather than let snprintf truncate the digits: a cut - * number is a plausible-looking index pointing somewhere else. - * No section emits anywhere near this many lines, which is why - * this is about provability rather than a reachable bug. */ - s->next = (idx > HW_PAGE_INDEX_MAX) ? HW_PAGE_INDEX_MAX : idx; + s->next = idx; return; } @@ -154,44 +115,18 @@ void sink_line(Sink *s, const char *fmt, ...) s->buf[s->len] = '\0'; } -/* Append the resume marker if the page was cut short. */ void sink_finish(Sink *s) { if (s->len == 0 && !s->full) { - /* A start past the last line: an empty reply reads as an unknown - * command on a companion. */ - snprintf(s->buf, s->buf_cap, "no line %u, the report has %u", s->first, s->line); - return; - } - if (!s->full) { - return; + /* 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); } - - /* Normally cap excluded HW_NEXT_RESERVE so the marker fits, but a caller - * passing cap <= HW_NEXT_RESERVE gets no reserve at all -- sink_init() - * clamps cap to 1 there. Bound the write by what the buffer actually has - * rather than by the reserve we hoped for; no current caller is that - * small, and this is a reporting path that must not be the thing that - * overruns a reply buffer. */ - if (s->len >= s->buf_cap) { - return; - } - size_t room = s->buf_cap - s->len; - if (room > HW_NEXT_RESERVE) { - room = HW_NEXT_RESERVE; - } - snprintf(s->buf + s->len, room, " next:%u", s->next); } -/* ================= devicetree I2C inventory ================= - * - * Declared children only -- no bus traffic. Deliberately does not try to - * resolve a struct device for each node: DEVICE_DT_GET_OR_NULL still expands - * to DEVICE_DT_GET for any status-okay node, so a node with no driver bound - * (every zephcore,rtc-i2c descriptor is exactly that -- data-only, probed by - * raw I2C) would fail to link. Binding is reported by the adapters that - * actually know it: `hw rtc` and `hw sensors`. - */ +/* ================= devicetree I2C inventory ================= */ struct I2cDecl { const char *bus; @@ -200,20 +135,8 @@ struct I2cDecl { uint16_t addr; }; -/* - * The bus string must be DEVICE_DT_NAME, not DT_NODE_FULL_NAME. - * - * Zephyr derives a device's runtime name as DT_PROP_OR(node, label, - * DT_NODE_FULL_NAME(node)), and the live scan and the RTC section both report - * `dev->name`. A bus node carrying a `label` would therefore print one string - * in `hw i2c` and a different one in `hw i2c scan` and `hw rtc`, and - * declared_names_at() -- which matches the two by string -- would silently stop - * annotating scanned addresses. - * - * No board in this tree labels an I2C node today, so the two derivations agree - * and the bug is invisible on current hardware. Using the same derivation - * Zephyr does keeps it that way by construction rather than by luck. - */ +/* 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)), \ @@ -222,68 +145,37 @@ struct I2cDecl { (uint16_t)DT_REG_ADDR(node_id), \ }, -/* - * Every enabled node that sits on an I2C bus and is an addressable chip. - * - * Walks the whole devicetree via DT_FOREACH_STATUS_OKAY_NODE and filters with - * DT_ON_BUS rather than naming bus nodelabels: this tree already has boards on - * i2c22 and i2c30 (nrf54l, seeed_lr2021_evk), so any hardcoded i2c0/1/2 list - * silently omits their devices. Silently omitting a declared chip is the one - * thing this report must never do. - * - * Nodes without both reg and compatible are skipped -- not addressable chips. - */ -#define HW_I2C_NODE(node_id) \ - IF_ENABLED(UTIL_AND(DT_ON_BUS(node_id, i2c), \ - UTIL_AND(DT_NODE_HAS_PROP(node_id, reg), \ - DT_NODE_HAS_PROP(node_id, compatible))), \ - (HW_I2C_ENTRY(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) - /* Sentinel. A board may declare no I2C children at all, and a - * zero-length array is not valid C++. Never reported -- see - * i2c_decl_count(). */ - { nullptr, nullptr, nullptr, 0xFFFF }, + { 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 behind each declared device, for the live scan. Derived from - * the same devicetree walk, so it follows whatever nodelabels a board uses - * rather than a hardcoded i2c0/1/2 list. Entries repeat once per child and are - * de-duplicated at scan time. - * - * DEVICE_DT_GET is safe here in a way it is not for the child nodes: the parent - * of an I2C child is an I2C controller, and this table only exists when - * CONFIG_I2C is on, so a driver is instantiated for it. - * - * Limitation, stated rather than hidden: a bus with no declared device at all - * is not scanned, because nothing in the devicetree walk names it. - */ +/* 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(UTIL_AND(DT_ON_BUS(node_id, i2c), \ - UTIL_AND(DT_NODE_HAS_PROP(node_id, reg), \ - DT_NODE_HAS_PROP(node_id, compatible))), \ - (HW_I2C_BUS_ENTRY(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, same reason as i2c_decls */ + nullptr, /* sentinel */ }; constexpr size_t i2c_bus_ref_count() { return ARRAY_SIZE(i2c_bus_refs) - 1; } -#endif /* CONFIG_I2C */ -#if IS_ENABLED(CONFIG_I2C) -/* Name a scanned address from the devicetree, when the board declared it. - * Every declaration at that address is listed, '|'-separated: the scan cannot - * tell which of two parts sharing an address (BME280 and BMP388 at 0x77) is - * the one that answered. Returns false when nothing is declared there. */ +/* 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; @@ -295,8 +187,7 @@ bool declared_names_at(const char *bus, uint16_t addr, char *out, size_t cap) } int w = snprintf(out + used, cap - used, "%s%s", used ? "|" : "", i2c_decls[i].compat); - if (w < 0 || (size_t)w >= cap - used) { - /* Too long to name: the caller keeps the address alone. */ + if ((size_t)w >= cap - used) { out[0] = '\0'; return false; } @@ -306,9 +197,7 @@ bool declared_names_at(const char *bus, uint16_t addr, char *out, size_t cap) } #endif /* CONFIG_I2C */ -/* One copy of discovery's outcome. A re-probe can publish a new one from the - * system work queue while `hw` renders, so every line comes from this copy - * rather than from separate reads. */ +/* 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; @@ -316,8 +205,7 @@ struct RtcView { struct zephcore_rtc_entry e[kRtcMax]; size_t n; int active; /* index into e, or -1 */ - unsigned unsettled; /* unprobed or all 0xFF: a "none present" is only - * true when this is zero */ + unsigned unsettled; /* unprobed or all 0xFF */ }; void rtc_view(RtcView *v) @@ -346,36 +234,36 @@ const char *rtc_state_str(enum zephcore_rtc_state st) } } +/* 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); - if (board != nullptr) { - sink_line(s, "name: %s", board->getManufacturerName()); - } + sink_line(s, "name: %s", board->getManufacturerName()); sink_line(s, "soc: %s", CONFIG_SOC); sink_line(s, "zephyr: %s", KERNEL_VERSION_STRING); - - if (cb != nullptr) { - sink_line(s, "fw: %s (%s)", cb->getFirmwareVer(), cb->getBuildDate()); - sink_line(s, "role: %s", cb->getRole()); - } + sink_line(s, "fw: %s (%s)", cb->getFirmwareVer(), cb->getBuildDate()); + sink_line(s, "role: %s", cb->getRole()); char bl[48]; - if (board != nullptr && board->getBootloaderVersion(bl, sizeof(bl))) { + if (board->getBootloaderVersion(bl, sizeof(bl))) { sink_line(s, "bootloader: %s", bl); } uint32_t cause; - if (zephcore_boot_reset_cause(&cause)) { - /* 128 holds every label without hints, per boot_info.h. Hints are - * off: these lines page over LoRa, where width costs packets. */ - char causes[128]; - zephcore_boot_reset_cause_str(causes, sizeof(causes), false); - /* A cause of 0 is a real answer -- the chip reported no known - * cause -- and is not the same as the platform being unable to - * tell us, which is the branch below. */ + 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 { @@ -386,23 +274,16 @@ void section_board(Sink *s, mesh::MainBoard *board, CommonCLICallbacks *cb) ssize_t n = hwinfo_get_device_id(devid, sizeof(devid)); if (n > 0) { char hex[sizeof(devid) * 2 + 1]; - for (ssize_t i = 0; i < n && (size_t)i < sizeof(devid); i++) { - snprintf(hex + i * 2, 3, "%02x", devid[i]); - } + 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, mesh::RTCClock *rtc) +void section_rtc(Sink *s) { - (void)rtc; - - if (!IS_ENABLED(CONFIG_ZEPHCORE_RTC_AUTODISCOVER) && - DT_NUM_INST_STATUS_OKAY(zephcore_rtc_i2c) > 0) { - /* The discovery stubs report nothing declared, which would - * contradict `hw i2c` listing the descriptors. */ + if (HW_RTC_DISABLED) { sink_line(s, "rtc: %u declared, autodiscovery disabled", (unsigned)DT_NUM_INST_STATUS_OKAY(zephcore_rtc_i2c)); return; @@ -411,15 +292,9 @@ void section_rtc(Sink *s, mesh::RTCClock *rtc) size_t declared = zephcore_rtc_declared(); if (declared == 0) { - /* Not a failure: rtc-i2c.dtsi is opt-in per board, because its - * fixed addresses collide with common parts (0x68 is both a - * DS3231 and an MPU-class IMU). Say what is true -- the board - * declares none -- rather than "no RTC fitted", which this - * firmware has no way to know. */ 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; @@ -460,7 +335,6 @@ void section_i2c(Sink *s) void section_i2c_scan(Sink *s) { #if !IS_ENABLED(CONFIG_I2C) - /* Different from "scanned and found nothing": this build cannot scan. */ sink_line(s, "i2c: no I2C support compiled in"); #else unsigned buses = 0; @@ -468,9 +342,6 @@ void section_i2c_scan(Sink *s) for (size_t bi = 0; bi < i2c_bus_ref_count(); bi++) { const struct device *bus = i2c_bus_refs[bi]; - /* Skip a controller already scanned: the table carries one - * entry per declared child, so a bus with four devices on it - * appears four times. */ bool seen = false; for (size_t j = 0; j < bi; j++) { if (i2c_bus_refs[j] == bus) { @@ -489,39 +360,20 @@ void section_i2c_scan(Sink *s) } unsigned found = 0; - char line[128]; - int used = snprintf(line, sizeof(line), "%s:", bus->name); - - /* A bus name long enough to fill the buffer would make every - * `line + used` below out of bounds. Not reachable with today's - * devicetree names, which is exactly why it needs checking here - * rather than being assumed. */ - if (used < 0 || (size_t)used >= sizeof(line)) { - sink_line(s, "%s: name too long to scan", bus->name); - continue; - } - const int header = used; - - /* 0x08-0x77: the 7-bit range excluding the reserved low and - * high blocks. A zero-length write is the standard probe -- it - * addresses the device and stops, so a chip that would react to - * a read of register 0 is not disturbed. */ - /* Zero-length write is the standard probe, but hand it a real - * pointer: i2c_write() puts buf straight into i2c_msg.buf and - * some controller drivers assert on NULL before looking at len. */ - uint8_t probe = 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++) { - int rc = i2c_write(bus, &probe, 0, addr); + uint8_t probe; + int rc = i2c_read(bus, &probe, 1, addr); if (rc == -ETIMEDOUT || rc == -EBUSY) { - /* A dead or held bus can take the driver's whole - * timeout on every address, blocking the CLI thread - * for most of a minute. -EIO is an ordinary miss. - * Flush what was found first, so it reads as found - * before the stop. */ + /* A dead or held bus would take the driver's timeout + * on every address. */ if (used > header) { sink_line(s, "%s", line); used = header; @@ -537,48 +389,17 @@ void section_i2c_scan(Sink *s) found++; char nm[64]; - bool named = declared_names_at(bus->name, addr, nm, sizeof(nm)); - - /* Render the token on its own first, so the decision to - * wrap is made against a known length and an address can - * never be dropped. */ char tok[72]; - int tw = named ? snprintf(tok, sizeof(tok), " 0x%02x(%s)", addr, nm) - : snprintf(tok, sizeof(tok), " 0x%02x", addr); - - if (tw < 0 || (size_t)tw >= sizeof(tok)) { - /* Annotation too long for the token buffer. The - * address is the part that matters; keep it and - * lose the name. */ - tw = snprintf(tok, sizeof(tok), " 0x%02x", addr); - if (tw < 0) { - continue; - } - } - - size_t toklen = (size_t)tw; - - if ((size_t)used + toklen >= sizeof(line)) { - /* Wrap: flush what we have and restart the line, - * unless it holds only the bus name. */ - if (used > header) { - sink_line(s, "%s", line); - used = header; - line[used] = '\0'; - } - } + 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 + toklen < sizeof(line)) { - memcpy(line + used, tok, toklen + 1); - used += (int)toklen; - } else { - /* Even a fresh line cannot hold it, which needs a - * bus name nearly as long as the buffer. Give the - * address a line of its own rather than dropping a - * chip that is physically present -- the one thing - * a bus scan must never do. */ - sink_line(s, "%s", tok); + 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) { @@ -591,56 +412,37 @@ void section_i2c_scan(Sink *s) } if (buses == 0) { - /* Not "no bus enabled": buses are discovered by walking declared - * devices, so the SoC may well have an enabled controller with - * nothing declared on it. Say only what that walk established. */ sink_line(s, "i2c: no bus carries a declared device, none scanned"); } #endif /* CONFIG_I2C */ } -void section_gps(Sink *s, CommonCLICallbacks *cb) +void section_gps(Sink *s) { -#if DT_NODE_EXISTS(DT_NODELABEL(gnss)) && !HW_GNSS_OKAY - /* The GPS manager needs the node and its UART both okay - * (HAS_GPS_UART in gps_internal.h); a disabled one is declared, not used. */ +#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(DT_NODELABEL(gnss), compatible, 0)); -#elif DT_NODE_EXISTS(DT_NODELABEL(gnss)) - sink_line(s, "gnss: %s", DT_PROP_BY_IDX(DT_NODELABEL(gnss), compatible, 0)); - sink_line(s, " on %s", DT_NODE_FULL_NAME(DT_PARENT(DT_NODELABEL(gnss)))); -#if DT_NODE_HAS_PROP(DT_PARENT(DT_NODELABEL(gnss)), current_speed) - sink_line(s, " baud %u", - (unsigned)DT_PROP(DT_PARENT(DT_NODELABEL(gnss)), current_speed)); + 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 bound driver's compatible, printed above, is the only honest model - * statement. A board overlay comment naming a part is documentation, not - * detection: the rak4631 overlay documents a u-blox MAX-7Q, binds - * gnss-nmea-generic, and drives whatever NMEA receiver is fitted. - * - * Only the generic NMEA driver leaves the model genuinely unknown -- it - * parses any NMEA talker and names no part. A specific driver IS the - * model, so adding an "unidentified" line there would contradict the - * compatible printed directly above it. */ -#if DT_NODE_HAS_COMPAT(DT_NODELABEL(gnss), gnss_nmea_generic) + /* 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 -#ifdef HW_HAS_GPS_HDR sink_line(s, " available: %s", gps_is_available() ? "yes" : "no"); sink_line(s, " enabled: %s", gps_is_enabled() ? "yes" : "no"); -#endif - (void)cb; } void section_sensors(Sink *s) { -#if defined(HW_HAS_SENSOR_HDR) && IS_ENABLED(CONFIG_SENSOR) - /* One line per part the boot probe found, environment and power - * monitors alike, with the fields a fresh read of it returns. */ +#if IS_ENABLED(CONFIG_SENSOR) int n = env_sensor_count(); if (n <= 0) { @@ -652,6 +454,10 @@ void section_sensors(Sink *s) 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; @@ -668,85 +474,53 @@ void section_sensors(Sink *s) env_sensor_is_board_local(i) ? " (board)" : ""); } #else - /* No sensor support compiled in -- which is a different statement from - * a sensor manager that looked and found nothing. */ sink_line(s, "sensors: not compiled in"); #endif } -void section_summary(Sink *s, mesh::MainBoard *board, CommonCLICallbacks *cb) +void section_summary(Sink *s, CommonCLICallbacks *cb) { - /* CONFIG_BOARD, not CONFIG_BOARD_TARGET: the target already embeds the - * SoC ("rak4631/nrf52840"), which read as "rak4631/nrf52840 (nrf52840)". - * `hw board` still reports the full target. */ sink_line(s, "%s (%s)", CONFIG_BOARD, CONFIG_SOC); - if (cb != nullptr) { - sink_line(s, "fw %s role %s", cb->getFirmwareVer(), cb->getRole()); - } + sink_line(s, "fw %s role %s", cb->getFirmwareVer(), cb->getRole()); RtcView v; rtc_view(&v); if (v.active >= 0) { - /* A devicetree node's full name already ends in "@", so the - * address is not appended here -- doing so rendered - * "rtc-rv3028@52@0x52" on real hardware. */ + /* The node's full name already ends in "@". */ sink_line(s, "rtc %s", v.e[v.active].name); - } else if (!IS_ENABLED(CONFIG_ZEPHCORE_RTC_AUTODISCOVER) && - DT_NUM_INST_STATUS_OKAY(zephcore_rtc_i2c) > 0) { + } 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()) { - /* No active entry means both "probed, none found" - * and "not probed yet". Collapsing those into "none present" - * states a fact discovery never established -- the distinction - * `hw rtc` keeps, and a summary has no licence to be looser - * about it than the section it summarises. */ sink_line(s, "rtc not yet probed"); + } else if (v.unsettled > 0) { + sink_line(s, "rtc none found, %u unsettled", v.unsettled); } else { - if (v.unsettled > 0) { - sink_line(s, "rtc none found, %u unsettled", v.unsettled); - } else { - sink_line(s, "rtc none present"); - } + sink_line(s, "rtc none present"); } -#if DT_NODE_EXISTS(DT_NODELABEL(gnss)) && !HW_GNSS_OKAY - sink_line(s, "gnss %s disabled", DT_PROP_BY_IDX(DT_NODELABEL(gnss), compatible, 0)); -#elif DT_NODE_EXISTS(DT_NODELABEL(gnss)) - sink_line(s, "gnss %s", DT_PROP_BY_IDX(DT_NODELABEL(gnss), compatible, 0)); +#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()); - /* Always emitted, including when unsupported: the docs pin the summary's - * field list, and a line that silently disappears on some platforms makes - * the schema depend on the board. */ uint32_t cause; - if (zephcore_boot_reset_cause(&cause)) { - /* 128 holds every label without hints, per boot_info.h. Hints are - * off: these lines page over LoRa, where width costs packets. */ - char causes[128]; - zephcore_boot_reset_cause_str(causes, sizeof(causes), false); + 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"); } - (void)board; } -/* - * Match `name` as a whole word at the start of arg, returning the remainder or - * nullptr. - * - * A bare prefix test is the surrounding CommonCLI house style, but this command - * documents a usage string for anything it does not recognise, and a prefix - * test breaks that promise: `hw boardwalk` would run `hw board` and quietly - * discard the rest. - */ +/* 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); @@ -760,14 +534,8 @@ const char *match_word(const char *arg, const char *name) return arg + n; } -/* - * Parse the optional trailing page index. False when the tail is neither empty - * nor a plain number, so `hw board xyz` reaches the usage string rather than - * being silently treated as `hw board`. - * - * Unsigned end to end, and clamped rather than wrapped: an index parsed - * unsigned and then rendered signed is what emits an unresumable "next:-1". - */ +/* 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; @@ -785,10 +553,10 @@ bool parse_tail(const char *rest, unsigned *start) char *end = nullptr; unsigned long v = strtoul(rest, &end, 10); - while (end != nullptr && *end == ' ') { + while (*end == ' ') { end++; } - if (end != nullptr && *end != '\0') { + if (*end != '\0') { return false; } @@ -798,12 +566,10 @@ bool parse_tail(const char *rest, unsigned *start) } /* namespace */ -void handle(const char *command, char *reply, size_t cap, bool local, - mesh::MainBoard *board, mesh::RTCClock *rtc, - CommonCLICallbacks *callbacks) +void handle(const char *command, char *reply, size_t cap, + mesh::MainBoard *board, CommonCLICallbacks *callbacks) { - /* command starts at "hw"; step past it and any spaces. */ - const char *arg = command + 2; + const char *arg = command + 2; /* past "hw" */ while (*arg == ' ') { arg++; } @@ -812,18 +578,15 @@ void handle(const char *command, char *reply, size_t cap, bool local, const char *rest; unsigned start = 0; - /* Each arm must match a whole word AND carry a valid tail; a branch that - * matches the word but not the tail falls through to the usage string. */ if (*arg == '\0' || ((*arg >= '0' && *arg <= '9') && parse_tail(arg, &start))) { - /* "hw N" resumes the summary, which pages like any section. */ sink_init(&s, reply, cap, start); - section_summary(&s, board, callbacks); + 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, rtc); + 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); @@ -832,16 +595,16 @@ void handle(const char *command, char *reply, size_t cap, bool local, section_i2c(&s); } else if ((rest = match_word(arg, "gps")) && parse_tail(rest, &start)) { sink_init(&s, reply, cap, start); - section_gps(&s, callbacks); + 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, rtc); + section_rtc(&s); section_i2c(&s); - section_gps(&s, callbacks); + section_gps(&s); section_sensors(&s); } else { snprintf(reply, cap, @@ -850,7 +613,6 @@ void handle(const char *command, char *reply, size_t cap, bool local, } sink_finish(&s); - (void)local; } } /* namespace zephcore_hw */ diff --git a/zephcore/helpers/HardwareReport.h b/zephcore/helpers/HardwareReport.h index 6c54e6f97..20f02dd83 100644 --- a/zephcore/helpers/HardwareReport.h +++ b/zephcore/helpers/HardwareReport.h @@ -1,55 +1,26 @@ /* * SPDX-License-Identifier: MIT * - * `hw` — what this firmware is, and what hardware it actually found. - * - * A release build compiles every MESH_DEBUG_* call away, so the firmware knows - * its board, MCU, clock source, GNSS wiring and sensor inventory and has no - * way to tell anyone. This formats those facts for the CLI. - * - * Radio parameters are deliberately NOT reported here: `get freq`, `get sf` - * and the stats-radio family already cover them, and duplicating a live - * setting in a second place is how the two drift apart. - * - * The rule every formatter here follows: report what the code KNOWS, never - * what could be inferred. A devicetree comment naming a GNSS part is not - * evidence the part is fitted (the rak4631 overlay documents a u-blox MAX-7Q - * while binding gnss-nmea-generic, which drives whatever NMEA receiver is - * fitted). So a driver names itself, and an unprobed address reports as - * unprobed rather than absent. - * - * Named `hw`, not `hwinfo`, because is a Zephyr - * subsystem this tree already uses; a CLI command sharing that name would read - * ambiguously in the source. + * `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 -#include namespace mesh { class MainBoard; -class RTCClock; } class CommonCLICallbacks; namespace zephcore_hw { -/* - * Write the reply for `command`, which is the full CLI line starting at "hw". - * `cap` is the bytes writable at `reply`, terminator included: replyCap(), on - * a companion at most one app CLI frame (MAX_FRAME_SIZE - 1), less any "xx|" - * prefix the role has already written ahead of `reply`, local or remote. - * - * `local` is true only when the request did not arrive over the air. Note it - * is NOT a test for "this is a serial console": on companion firmware the USB - * sideband, the app's CMD_RUN_CLI_COMMAND and the V-Contact loopback all reach - * the CLI indistinguishably. It means "not remote mesh admin" and nothing more. - */ -void handle(const char *command, char *reply, size_t cap, bool local, - mesh::MainBoard *board, mesh::RTCClock *rtc, - CommonCLICallbacks *callbacks); +/* 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 */