From 1ae9d2ad78d952acd5ad9060f8e33d41fc199ab7 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 17 Sep 2026 16:58:17 -0700 Subject: [PATCH 01/78] RTC discovery: expose what the boot probe found Boot-time RTC auto-discovery already knows which chips the board declares and which one it adopted as the write-back target, but kept both to itself. A release build compiles every MESH_DEBUG_* call away, so the firmware could not say what RTC it is driving -- or that it found none. Add read-only accessors over the state the probe already computes: zephcore_rtc_declared() -- how many zephcore,rtc-i2c candidates exist zephcore_rtc_get(i, out) -- one candidate: name, bus, address, outcome zephcore_rtc_active(out) -- the adopted chip, if any zephcore_rtc_probed() -- whether discovery has run Probing behaviour is unchanged; the loop now records its per-candidate outcome in a parallel array instead of discarding it. The outcome is deliberately three-valued. The probe stops at the first chip holding a valid time, so candidates after it are never reached -- those stay UNPROBED rather than being reported as absent. Reporting an unprobed address as "absent" would be inferring a fact the firmware never established, and a chip that ACKs but fails the BCD sanity check (an IMU sharing 0x68 with the DS3231) is recorded ABSENT, because "no RTC here" is what was actually shown. Boards with no zephcore,rtc-i2c node get link-compatible stubs, matching the existing pattern in this file. Costs nothing while unused: an unmodified rak4631 repeater build is byte-identical at 245280 B flash / 83628 B RAM. Co-Authored-By: Claude Opus 5 (1M context) --- zephcore/adapters/clock/ZephyrRTCDiscover.c | 81 +++++++++++++++++++++ zephcore/adapters/clock/ZephyrRTCDiscover.h | 42 +++++++++++ 2 files changed, 123 insertions(+) diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.c b/zephcore/adapters/clock/ZephyrRTCDiscover.c index db373056e..0af069bd0 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.c +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.c @@ -55,6 +55,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)) @@ -118,9 +124,11 @@ static bool rtc_probe(uint32_t *epoch_out) uint8_t blk[7]; if (!device_is_ready(d->bus)) { + s_state[i] = ZEPHCORE_RTC_ABSENT; 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 +150,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 +248,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 +305,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..bfc32fd91 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,47 @@ 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. + */ + +enum zephcore_rtc_state { + ZEPHCORE_RTC_UNPROBED = 0, /* discovery stopped before reaching it */ + 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 From 2f5873e5a157f0db4f38f00adc6b09faa18801fe Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 17 Sep 2026 17:16:57 -0700 Subject: [PATCH 02/78] Capture the reset cause at boot instead of consuming it The MCU latches its reset cause in a register that survives the reset and must be cleared, or the bits accumulate and each boot reports the union of every reset since the last clear. Both halves of that were a problem: - The companion read the register in main() and cleared it there, so the fact was destroyed for every later reader. Anything asking afterwards -- a CLI report, a diagnostic -- got 0 and could not tell that apart from "no cause reported". - The repeater and room server never read or cleared it at all, so a board that was once brownout-reset reported BROWNOUT on every subsequent boot, forever. Add helpers/boot_info.c, which captures the cause once at POST_KERNEL -- before main() on every role -- then clears the register so the next boot starts clean. It is the single reader; everything else reads the captured copy and therefore cannot disagree or consume it. The companion's restart notice now reads that copy rather than the register. Its behaviour is unchanged: same bits, same message, same offline-queue delivery. The clearing it used to do now happens in boot_info_init, so the register is still left clean. zephcore_boot_reset_cause() distinguishes "the platform cannot report a cause" (false) from "the chip reported a cause of 0" (true, value 0). Collapsing those two into 0 is what would make a report guess. Verified on native_sim: the boot log still prints "Reset cause: 0x00000008 POR" and the v-contact notice still reads "Restarted: POR(power-on)", both now sourced from the captured copy. Co-Authored-By: Claude Opus 5 (1M context) --- zephcore/CMakeLists.txt | 3 + zephcore/helpers/boot_info.c | 106 ++++++++++++++++++++++++++++++++ zephcore/helpers/boot_info.h | 51 +++++++++++++++ zephcore/src/main_companion.cpp | 8 ++- 4 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 zephcore/helpers/boot_info.c create mode 100644 zephcore/helpers/boot_info.h diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index 90b2aa7d1..7c5ff8ada 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -702,6 +702,9 @@ target_sources(app PRIVATE adapters/sensors/ZephyrEnvSensors.cpp helpers/AdvertDataHelpers.cpp helpers/MeshTimeSync.cpp + # Captures the reset cause before anything consumes it; every role needs it + # at boot, so it is never conditional. + helpers/boot_info.c 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/helpers/boot_info.c b/zephcore/helpers/boot_info.c new file mode 100644 index 000000000..6520a4680 --- /dev/null +++ b/zephcore/helpers/boot_info.c @@ -0,0 +1,106 @@ +/* + * SPDX-License-Identifier: MIT + * Boot-time fact capture. See boot_info.h. + */ + +#include "boot_info.h" + +#include +#include +#include +#include + +#include + +static uint32_t s_reset_cause; +static bool s_reset_cause_valid; + +/* + * Captured at POST_KERNEL so it runs before main() on every role, which is + * what makes this the single reader of the register. The hwinfo reset-cause + * path is a plain register read on every SoC that implements it, so it needs + * no device to be ready first. + */ +static int boot_info_init(void) +{ + uint32_t cause = 0; + + if (hwinfo_get_reset_cause(&cause) != 0) { + /* Not supported on this platform -- distinct from "no cause". */ + return 0; + } + + s_reset_cause = cause; + s_reset_cause_valid = true; + + /* Clear so the next boot reports its own cause rather than the union of + * every reset since the last clear. Failure is not fatal: the captured + * value for this boot is already correct, and a stale-bit report next + * boot is better than refusing to boot. */ + (void)hwinfo_clear_reset_cause(); + + return 0; +} + +SYS_INIT(boot_info_init, POST_KERNEL, CONFIG_KERNEL_INIT_PRIORITY_DEFAULT); + +bool zephcore_boot_reset_cause(uint32_t *out) +{ + if (!s_reset_cause_valid) { + return false; + } + if (out != NULL) { + *out = s_reset_cause; + } + return true; +} + +int zephcore_boot_reset_cause_str(char *buf, size_t cap) +{ + static const struct { + uint32_t bit; + const char *label; + } labels[] = { + { RESET_PIN, "PIN" }, + { RESET_SOFTWARE, "SOFTWARE" }, + { RESET_BROWNOUT, "BROWNOUT" }, + { RESET_POR, "POR" }, + { RESET_WATCHDOG, "WATCHDOG" }, + { RESET_DEBUG, "DEBUG" }, + { RESET_SECURITY, "SECURITY" }, + { RESET_LOW_POWER_WAKE, "LOWPOWER" }, + { RESET_CPU_LOCKUP, "LOCKUP" }, + { RESET_PARITY, "PARITY" }, + { RESET_PLL, "PLL" }, + { RESET_CLOCK, "CLOCK" }, + { RESET_HARDWARE, "HARDWARE" }, + { RESET_USER, "USER" }, + { RESET_TEMPERATURE, "TEMPERATURE" }, + }; + + if (buf == NULL || cap == 0) { + return 0; + } + + buf[0] = '\0'; + + if (!s_reset_cause_valid) { + return 0; + } + + size_t n = 0; + for (size_t i = 0; i < ARRAY_SIZE(labels); i++) { + if ((s_reset_cause & labels[i].bit) == 0) { + continue; + } + int w = snprintf(buf + n, cap - n, " %s", labels[i].label); + if (w < 0 || (size_t)w >= cap - n) { + /* Truncated -- stop cleanly rather than half a label. */ + buf[n] = '\0'; + break; + } + n += (size_t)w; + } + + return (int)n; +} diff --git a/zephcore/helpers/boot_info.h b/zephcore/helpers/boot_info.h new file mode 100644 index 000000000..8cec810e7 --- /dev/null +++ b/zephcore/helpers/boot_info.h @@ -0,0 +1,51 @@ +/* + * SPDX-License-Identifier: MIT + * + * Boot-time facts that are readable exactly once, captured before anything + * consumes them. + * + * The MCU latches its reset cause in a register that survives the reset and + * must be cleared, or the bits accumulate and the next boot reports the union + * of every reset since the last clear. Both halves of that are a problem for + * reporting: + * + * - Whoever clears it destroys the fact for every later reader. The + * companion used to read and clear it in main(), so by the time a CLI + * command asked, the cause was gone and the honest answer was 0. + * - Whoever never clears it reports stale bits. The repeater and room + * server never touched the register, so a board that was once + * brownout-reset reported BROWNOUT on every boot thereafter. + * + * This captures the cause once at POST_KERNEL, before main() runs on any + * role, then clears the register so the next boot starts clean. Every reader + * -- the companion's restart notice and the `hw` CLI report alike -- reads the + * captured copy, so they cannot disagree and neither can consume it. + */ + +#pragma once + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * The reset cause latched for THIS boot. Returns false if the platform has no + * hwinfo reset-cause support, which is different from a cause of 0 (the chip + * reported no known cause) and is reported differently. + */ +bool zephcore_boot_reset_cause(uint32_t *out); + +/* + * Render the cause as space-prefixed labels, e.g. " PIN SOFTWARE". Writes "" + * when the cause is 0 or carries only bits this build has no label for. + * Returns the number of characters written, excluding the NUL. + */ +int zephcore_boot_reset_cause_str(char *buf, size_t cap); + +#ifdef __cplusplus +} +#endif diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index f404465b4..2628c4cbb 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -24,6 +24,7 @@ LOG_MODULE_REGISTER(zephcore_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #include #include #include +#include #include #include #include @@ -1378,7 +1379,11 @@ int main(void) boot_cause_msg[0] = '\0'; { uint32_t cause; - if (hwinfo_get_reset_cause(&cause) == 0) { + /* Captured at POST_KERNEL by helpers/boot_info.c, which also did the + * clearing this block used to do. Reading the captured copy rather + * than the register is what lets the `hw` CLI report the same cause + * later in the session -- the register is already cleared by now. */ + if (zephcore_boot_reset_cause(&cause)) { LOG_INF("Reset cause: 0x%08x%s%s%s%s%s%s", cause, (cause & RESET_PIN) ? " PIN" : "", (cause & RESET_SOFTWARE) ? " SOFTWARE" : "", @@ -1386,7 +1391,6 @@ int main(void) (cause & RESET_POR) ? " POR" : "", (cause & RESET_WATCHDOG) ? " WATCHDOG" : "", (cause & RESET_CPU_LOCKUP)? " LOCKUP" : ""); - hwinfo_clear_reset_cause(); /* Every known cause becomes a v-contact message once the mesh * is up (queued below, after RTC restore + prefs load) — the * message rides the offline queue only, so the "noise" of a From e6a61a3dd213de787825e69da450763d803eec5b Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 17 Sep 2026 17:17:20 -0700 Subject: [PATCH 03/78] Add the `hw` CLI command: what hardware this firmware actually found A release build compiles every MESH_DEBUG_* call away, so the firmware knows its board, MCU, clock source, GNSS wiring and I2C inventory and has no way to tell anyone. `hw` reports it. Because ZephCore's companion already routes through CommonCLI rather than carrying its own command table, one implementation reaches all four roles -- companion, repeater, room server and observer -- at once. hw summary hw board board target, SoC, Zephyr and firmware version, role, bootloader, reset cause, device id hw rtc declared RTC candidates and the probe outcome for each hw i2c devicetree-declared I2C inventory (no bus traffic) hw i2c scan live 0x08-0x77 bus scan, naming declared addresses hw gps GNSS driver, transport and wiring hw sensors sensor availability and channels hw all every section hw usage Named `hw`, not `hwinfo`: is a Zephyr subsystem this tree already uses, and a CLI command sharing that name would read ambiguously in the source. No existing command starts with "hw". `hw i2c` and `hw i2c scan` deliberately answer different questions and say which they answered. Devicetree enumeration reports what the board declares and bound at boot; it cannot see an undeclared chip. A scan sees what is on the wire but knows no names. Silently substituting one for the other is how a report states a fact nobody established. The reporting rule throughout is to print what the code knows and never what could be inferred: - The GNSS section reports the bound driver's own compatible, not the board overlay's prose. The rak4631 overlay documents a u-blox MAX-7Q while binding gnss-nmea-generic, which drives whichever NMEA receiver is actually fitted -- so quoting the overlay would confidently report the wrong part. - A board declaring no RTC reports "none declared in devicetree", not "no RTC", which this firmware cannot know. rtc-i2c.dtsi is opt-in per board because its fixed addresses collide with common parts. - An RTC candidate the boot probe never reached reports "unprobed" rather than "absent". - A build with no sensor support says so, which is a different statement from a sensor manager that looked and found nothing. Two defect classes from the prior MeshCore review are structurally excluded rather than avoided by care: page indices are unsigned end to end (a resume index parsed unsigned then rendered signed is what emits an unresumable "next:-1"), and every section writes through one paged sink that reserves room for the resume marker, so the marker is never the thing truncation eats. Remote-admin replies use CLI_REMOTE_REPLY_SIZE and local ones CLI_REPLY_SIZE, matching the existing `get cad.stats` and `get meshtimesync` idiom. Note the local flag means "not remote mesh admin", not "is a serial console" -- on companion firmware the USB sideband, the app's CMD_RUN_CLI_COMMAND and the V-Contact loopback all arrive indistinguishably as sender_timestamp 0. The devicetree inventory takes no device handles: DEVICE_DT_GET on a status-okay node with no driver bound fails to LINK, and every zephcore,rtc-i2c descriptor is exactly that. The live scan is additionally gated on CONFIG_I2C, since a node can be status-okay in a build with no I2C driver compiled in -- native_sim is precisely that case. Cost on a rak4631 repeater build: +3904 B flash, +0 B RAM. Builds clean for rak4631 repeater, rak4631 companion and native_sim/native/64. Co-Authored-By: Claude Opus 5 (1M context) --- zephcore/CMakeLists.txt | 1 + zephcore/helpers/CommonCLI.cpp | 9 + zephcore/helpers/HardwareReport.cpp | 536 ++++++++++++++++++++++++++++ zephcore/helpers/HardwareReport.h | 51 +++ 4 files changed, 597 insertions(+) create mode 100644 zephcore/helpers/HardwareReport.cpp create mode 100644 zephcore/helpers/HardwareReport.h diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index 7c5ff8ada..dbae55b8f 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -705,6 +705,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/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index a0d1f4fe1..28d335465 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" @@ -1470,6 +1471,14 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch } } else if (memcmp(command, "ver", 3) == 0) { snprintf(reply, CLI_REPLY_SIZE, "%s (Build: %s)", _callbacks->getFirmwareVer(), _callbacks->getBuildDate()); + } else if (memcmp(command, "hw", 2) == 0) { + /* Hardware report. Remote admin replies ride the caller's LoRa packet + * buffer, so the capacity differs -- same idiom as get cad.stats and + * get meshtimesync above. Long sections page with a start index. */ + 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, "board", 5) == 0) { snprintf(reply, CLI_REPLY_SIZE, "%s", _board->getManufacturerName()); } else if (memcmp(command, "sensor get ", 11) == 0) { diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp new file mode 100644 index 000000000..ac0eeb788 --- /dev/null +++ b/zephcore/helpers/HardwareReport.cpp @@ -0,0 +1,536 @@ +/* + * 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 + +/* + * 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 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; + 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; + s->next = idx; + return; + } + + if (s->len) { + s->buf[s->len++] = '\n'; + } + memcpy(s->buf + s->len, line, strlen(line)); + s->len += strlen(line); + s->buf[s->len] = '\0'; +} + +/* Append the resume marker if the page was cut short. */ +void sink_finish(Sink *s) +{ + if (!s->full) { + return; + } + /* cap excluded HW_NEXT_RESERVE, so this always fits. */ + snprintf(s->buf + s->len, HW_NEXT_RESERVE, " 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; +}; + +#define HW_I2C_ENTRY(node_id) \ + { \ + DT_NODE_FULL_NAME(DT_PARENT(node_id)), \ + DT_NODE_FULL_NAME(node_id), \ + DT_PROP_BY_IDX(node_id, compatible, 0), \ + (uint16_t)DT_REG_ADDR(node_id), \ + }, + +/* Skip any child without both reg and compatible -- it is not an addressable + * chip and has nothing to report. */ +#define HW_I2C_CHILD(node_id) \ + IF_ENABLED(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[] = { +#if DT_NODE_HAS_STATUS(DT_NODELABEL(i2c0), okay) + DT_FOREACH_CHILD_STATUS_OKAY(DT_NODELABEL(i2c0), HW_I2C_CHILD) +#endif +#if DT_NODE_HAS_STATUS(DT_NODELABEL(i2c1), okay) + DT_FOREACH_CHILD_STATUS_OKAY(DT_NODELABEL(i2c1), HW_I2C_CHILD) +#endif +#if DT_NODE_HAS_STATUS(DT_NODELABEL(i2c2), okay) + DT_FOREACH_CHILD_STATUS_OKAY(DT_NODELABEL(i2c2), HW_I2C_CHILD) +#endif + /* 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; } + +/* + * Bus handles, for the live scan only. + * + * Gated on CONFIG_I2C as well as node status: a devicetree node can be + * status-okay while no I2C driver is compiled in (native_sim is exactly that), + * and DEVICE_DT_GET on such a node references a device object nothing ever + * defines, so the image fails to LINK rather than reporting a missing bus at + * runtime. The declared inventory above deliberately takes no device handles + * for the same reason. + */ +#if IS_ENABLED(CONFIG_I2C) +const struct device *const i2c_buses[] = { +#if DT_NODE_HAS_STATUS(DT_NODELABEL(i2c0), okay) + DEVICE_DT_GET(DT_NODELABEL(i2c0)), +#endif +#if DT_NODE_HAS_STATUS(DT_NODELABEL(i2c1), okay) + DEVICE_DT_GET(DT_NODELABEL(i2c1)), +#endif +#if DT_NODE_HAS_STATUS(DT_NODELABEL(i2c2), okay) + DEVICE_DT_GET(DT_NODELABEL(i2c2)), +#endif + nullptr, /* sentinel, same reason as i2c_decls */ +}; + +constexpr size_t i2c_bus_count() { return ARRAY_SIZE(i2c_buses) - 1; } +#else +constexpr size_t i2c_bus_count() { return 0; } +#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)) { + char causes[96]; + zephcore_boot_reset_cause_str(causes, sizeof(causes)); + /* 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 + if (i2c_bus_count() == 0) { + sink_line(s, "i2c: no bus enabled"); + return; + } + + for (size_t b = 0; b < i2c_bus_count(); b++) { + const struct device *bus = i2c_buses[b]; + + 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); + + /* 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. */ + for (uint16_t addr = 0x08; addr <= 0x77; addr++) { + if (i2c_write(bus, nullptr, 0, addr) != 0) { + continue; + } + found++; + const char *nm = declared_name_at(bus->name, addr); + int w = snprintf(line + used, sizeof(line) - used, + nm ? " 0x%02x(%s)" : " 0x%02x", addr, nm); + if (w < 0 || (size_t)(used + w) >= sizeof(line)) { + /* This address did not fit. Flush the line so far, + * start a continuation, and re-render the address + * into it. Dropping it would silently omit a chip + * that is physically present, which is the one + * thing a bus scan must never do. */ + line[used] = '\0'; + sink_line(s, "%s", line); + used = snprintf(line, sizeof(line), "%s:", bus->name); + w = snprintf(line + used, sizeof(line) - used, + nm ? " 0x%02x(%s)" : " 0x%02x", addr, nm); + if (w > 0 && (size_t)(used + w) < sizeof(line)) { + used += w; + } + } else { + used += w; + } + } + + if (found == 0) { + sink_line(s, "%s: none found", bus->name); + } else { + sink_line(s, "%s", line); + } + } +#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 driver's own compatible 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. */ + sink_line(s, " model: not identified (generic NMEA)"); +#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) +{ + sink_line(s, "%s (%s)", CONFIG_BOARD_TARGET, 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)) { + sink_line(s, "rtc %s@0x%02x", active.name, active.addr); + } else if (zephcore_rtc_declared() == 0) { + sink_line(s, "rtc none declared"); + } 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()); + + uint32_t cause; + if (zephcore_boot_reset_cause(&cause)) { + char causes[96]; + zephcore_boot_reset_cause_str(causes, sizeof(causes)); + sink_line(s, "reset%s", causes[0] ? causes : " none"); + } + (void)board; +} + +/* Parse an optional trailing page index. Unsigned throughout, and a value + * beyond any plausible line count is clamped rather than wrapped. */ +unsigned parse_start(const char *arg) +{ + if (arg == nullptr) { + return 0; + } + while (*arg == ' ') { + arg++; + } + if (*arg < '0' || *arg > '9') { + return 0; + } + unsigned long v = strtoul(arg, nullptr, 10); + return (v > 9999U) ? 9999U : (unsigned)v; +} + +} /* 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; + + if (*arg == '\0') { + sink_init(&s, reply, cap, 0); + section_summary(&s, board, callbacks); + } else if (memcmp(arg, "board", 5) == 0) { + sink_init(&s, reply, cap, parse_start(arg + 5)); + section_board(&s, board, callbacks); + } else if (memcmp(arg, "rtc", 3) == 0) { + sink_init(&s, reply, cap, parse_start(arg + 3)); + section_rtc(&s, rtc); + } else if (memcmp(arg, "i2c scan", 8) == 0) { + sink_init(&s, reply, cap, parse_start(arg + 8)); + section_i2c_scan(&s); + } else if (memcmp(arg, "i2c", 3) == 0) { + sink_init(&s, reply, cap, parse_start(arg + 3)); + section_i2c(&s); + } else if (memcmp(arg, "gps", 3) == 0) { + sink_init(&s, reply, cap, parse_start(arg + 3)); + section_gps(&s, callbacks); + } else if (memcmp(arg, "sensors", 7) == 0) { + sink_init(&s, reply, cap, parse_start(arg + 7)); + section_sensors(&s); + } else if (memcmp(arg, "all", 3) == 0) { + sink_init(&s, reply, cap, parse_start(arg + 3)); + 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..0191e0098 --- /dev/null +++ b/zephcore/helpers/HardwareReport.h @@ -0,0 +1,51 @@ +/* + * 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, radio, clock source, GNSS wiring and sensor inventory and + * has no way to tell anyone. This formats those facts for the CLI. + * + * 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 06207ab261f32aea27491a939add6bdbfaa5ca49 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 17 Sep 2026 17:18:04 -0700 Subject: [PATCH 04/78] Document the `hw` command and what it will not infer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZephCore pins CLI reply formats in docs/Repeater_CLI_commands.md, so a new command is not landed until it is documented there. Records the command surface, and the two things about it that are easy to get wrong later: - `hw i2c` and `hw i2c scan` answer different questions. A devicetree enumeration cannot see an undeclared chip; a bus scan sees the wire but knows no names. The doc says so, so a future change does not quietly make one stand in for the other. - The report never infers. The GNSS model comes from the bound driver, not the overlay comment; a board that declares no RTC says exactly that rather than "no RTC"; an unreached probe candidate is "unprobed", not "absent". Also notes the paging contract and why room is reserved for the resume marker, and adds the command to the CommonCLI list in ARCHITECTURE.md §6.6. Co-Authored-By: Claude Opus 5 (1M context) --- docs/ARCHITECTURE.md | 1 + docs/Repeater_CLI_commands.md | 69 +++++++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c7e82dd4c..3039e1533 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -720,6 +720,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 GPS: `gps on/off/setloc/advert`, `set gps duty ` Sensors: `sensor get/set/list` diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index d996c8b7f..916a93fae 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -35,6 +35,69 @@ 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: board target, SoC, firmware, role, clock source, GNSS, I2C count, reset cause | +| `hw board [start]` | Board target, SoC, Zephyr version, firmware and build date, role, bootloader, reset cause, device id | +| `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 every enabled bus, naming declared addresses | +| `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). + +### `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. + +### 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 | @@ -283,9 +346,9 @@ All `set uplink.*` changes are saved immediately and only applied after reboot. | `get probe.interval` | Seconds between periodic radio measurements (noise-floor sample + CAD probe). 0 = CAD probing off | | `get dc.restarts` | Duty-cycle re-arm counter — RxTimeout re-arms **plus** parked-RX watchdog recoveries, sharing one total. **Read it as a rate: divide by uptime.** A bare count is not interpretable, and the two sources it merges cost very differently. An RxTimeout re-arm is ~7 ms of deaf time (the `Calibrate(ALL)` gap in the driver's `restart_rx`) after which the chip returns to duty cycle immediately — packets, not power. A watchdog recovery means the chip sat parked in *full RX* for one to two watchdog periods (`2·(preamble+8)` symbols, floored at 250 ms) — power, not packets, since parked RX still receives. The counter cannot tell you which, so read the worst case. **Measured normal: ~250/hr on a high site at SF8/BW 62.5** (one every ~14 s), where the worst case — every event a park — costs about 3.5% of the duty cycle's savings. Nothing to act on below roughly **2000/hr**; above that the parked-RX share starts eating a meaningful fraction of the saving and it becomes worth splitting the counter to find out. A high rate means the preamble detector is tripping without a decodable packet following, which on an elevated site is usually distant marginal traffic rather than interference — cross-check `get cad.stats`, whose adaptive detPeak offset rises independently in a genuinely busy RF environment. Reset by `clear stats`. | | `get cad` | Always `on` — ZephCore performs CAD/LBT unconditionally and has no enable knob. Kept as a boolean reply for Arduino MeshCore app compatibility; the real status lives in `get cad.stats`. | -| `get cad.auto` | Whether the adaptive-CAD staircase is acting on probe statistics (`on`/`off`). Set with `set cad.auto`. | -| `get cad.offset` | Operating detPeak offset from the chip family's per-SF/per-bandwidth base, signed. Also shown as `o` in `get cad.stats`, alongside the absolute peak it resolves to. | -| `get cad.busycap` | Faint-tolerance / airtime cap in percent, or `0 (off)`. Also shown as `bc` in `get cad.stats`. | +| `get cad.auto` | Whether the adaptive-CAD staircase is acting on probe statistics (`on`/`off`). Set with `set cad.auto`. | +| `get cad.offset` | Operating detPeak offset from the chip family's per-SF/per-bandwidth base, signed. Also shown as `o` in `get cad.stats`, alongside the absolute peak it resolves to. | +| `get cad.busycap` | Faint-tolerance / airtime cap in percent, or `0 (off)`. Also shown as `bc` in `get cad.stats`. | | `get cad.stats` | Adaptive-CAD status: header (`a` auto on/off, `o` operating detPeak offset, `pk` absolute peak with family base, `sp` noise-floor RSSI burst quality as `mean-spread-dB/zero-spread-%` (plus `(burst-count rN/bN/aN/dN)` on the local USB console, omitted over the air to protect the 161 B reply budget, where `r` is completed RSSI reads, `b` reads the chip refused as busy, `a` bursts abandoned because of one, and `d` attempts turned away before a burst even started by the duty-cycle sleep window — on a healthy radio `b`/`a` stay at 0, and a large `a` against a near-zero burst count is the signature of a sampler being refused rather than one losing the odd read) — a non-zero mean proves the 8 reads are independent however high the share climbs; only mean `0.0` with a high share indicts the sampler. See `ADAPTIVE_CAD.md`. `bc` busy cap), then a 3-rung window around the operating offset (`*` marks it) with probe/busy/fp/tp counts and false-positive rate — the three levels the knee controller reads. Probing runs even while `cad.auto` is off (dry-run), so this is the observation tool for picking a site-appropriate detPeak. See `ADAPTIVE_CAD.md`. Not available on SX127x boards (no hardware CAD). | | `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 | From 6c3f0b1138d7a6d231e8100c7992fdc3f28b9834 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 17 Sep 2026 17:26:38 -0700 Subject: [PATCH 05/78] Review round 1: stop three reports from claiming more than is known All four findings were real. Three were the report asserting something the firmware had not established -- the exact failure the module exists to avoid -- and the fourth was its own comment overstating its coverage. 1. A not-ready I2C bus was recorded as ZEPHCORE_RTC_ABSENT. A probe that could not run is not evidence the chip is missing, so a board whose I2C driver failed to initialise would have been reported as having no RTC fitted. It stays UNPROBED now, and the enum's documented meaning is widened to cover both ways a candidate goes unprobed. 2. The GNSS section printed "model: not identified (generic NMEA)" unconditionally, so a board binding a specific driver (luatos,air530z, say) got a model line contradicting the compatible printed directly above it. The line is now emitted only when the bound driver really is gnss-nmea-generic, which is the only case where the model is genuinely unknown. A specific driver IS the model. 3. HardwareReport.h claimed the report covers the radio. It does not, and it should not: `get freq`, `get sf` and the stats-radio family already report those, and duplicating a live setting in a second place is how the two drift apart. The comment now says so, and the docs state the omission rather than leaving it to be noticed. 4. The docs described the summary as carrying a "clock source" field. It does carry one -- it is named `rtc` -- but describing output in words that do not appear in it invites exactly this confusion, so both table rows now name the literal fields the command emits. Builds clean for rak4631 repeater, rak4631 companion and native_sim/native/64. Repeater cost is now +3912 B flash, +0 B RAM. Co-Authored-By: Claude Opus 5 (1M context) --- docs/Repeater_CLI_commands.md | 8 ++++++-- zephcore/adapters/clock/ZephyrRTCDiscover.c | 5 ++++- zephcore/adapters/clock/ZephyrRTCDiscover.h | 7 +++++-- zephcore/helpers/HardwareReport.cpp | 17 ++++++++++++----- zephcore/helpers/HardwareReport.h | 8 ++++++-- 5 files changed, 33 insertions(+), 12 deletions(-) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index 916a93fae..1c51b3a4e 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -44,8 +44,8 @@ it is available on **all four roles** — companion, repeater, room server and o | Command | Description | |---------|-------------| -| `hw` | Summary: board target, SoC, firmware, role, clock source, GNSS, I2C count, reset cause | -| `hw board [start]` | Board target, SoC, Zephyr version, firmware and build date, role, bootloader, reset cause, device id | +| `hw` | Summary. Emits exactly these fields, one per line: board target and SoC, `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 every enabled bus, naming declared addresses | @@ -56,6 +56,10 @@ it is available on **all four roles** — companion, repeater, room server and o `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. diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.c b/zephcore/adapters/clock/ZephyrRTCDiscover.c index 0af069bd0..e85aea2dd 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.c +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.c @@ -124,7 +124,10 @@ static bool rtc_probe(uint32_t *epoch_out) uint8_t blk[7]; if (!device_is_ready(d->bus)) { - s_state[i] = ZEPHCORE_RTC_ABSENT; + /* 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) { diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.h b/zephcore/adapters/clock/ZephyrRTCDiscover.h index bfc32fd91..fc4552a9b 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.h +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.h @@ -49,11 +49,14 @@ void zephcore_rtc_save(uint32_t epoch); * * 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. + * 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, /* discovery stopped before reaching it */ + 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 */ }; diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index ac0eeb788..81dfa240b 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -384,11 +384,18 @@ void section_gps(Sink *s, CommonCLICallbacks *cb) sink_line(s, " baud %u", (unsigned)DT_PROP(DT_PARENT(DT_NODELABEL(gnss)), current_speed)); #endif - /* The driver's own compatible 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. */ - sink_line(s, " model: not identified (generic NMEA)"); + /* 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 diff --git a/zephcore/helpers/HardwareReport.h b/zephcore/helpers/HardwareReport.h index 0191e0098..ad7767d28 100644 --- a/zephcore/helpers/HardwareReport.h +++ b/zephcore/helpers/HardwareReport.h @@ -4,8 +4,12 @@ * `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, radio, clock source, GNSS wiring and sensor inventory and - * has no way to tell anyone. This formats those facts for the CLI. + * 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 From 439277d4a0a0943b3b59e99ff3df992caf3f8eb4 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 17 Sep 2026 17:39:49 -0700 Subject: [PATCH 06/78] Review round 2: cover every I2C bus, and honour the documented usage string Three suppressed findings, all real. Suppressed is a confidence label, not a severity one. 1. The I2C inventory and scan only looked at i2c0, i2c1 and i2c2. This tree already has boards on i2c22 and i2c30 -- seeed_lr2021_evk enables i2c22 with an SSD1306 on it -- so their devices were omitted from both the declared inventory and the live scan. A report that silently drops a declared chip is worse than one that refuses to answer, and the scan already carried a comment saying exactly that about a different path. Rather than extend the list, both tables are now built by walking the whole devicetree with DT_FOREACH_STATUS_OKAY_NODE and filtering on DT_ON_BUS(node, i2c), so they follow whatever nodelabels a board uses and no future bus can be missed by omission. Verified on seeed_lr2021_evk/nrf54l15/cpuapp: i2c22 is i2c@c8000 in the generated devicetree, and both i2c@c8000 and its ssd1306@3c are now present in the linked image's inventory strings. 2 & 3. Subcommand dispatch used memcmp prefix tests, so `hw boardwalk` ran `hw board` and `hw gpsx` ran `hw gps`. The surrounding CommonCLI style is prefix matching, but this command documents a usage string for anything it does not recognise, and a prefix test quietly breaks that promise. Matching now requires a word boundary, and the trailing page index must be empty or a plain number, so `hw i2c scanx` and `hw board xyz` reach the usage string instead of being silently reinterpreted. An earlier attempt used STRUCT_SECTION_FOREACH + DEVICE_API_IS to find controllers, which is the more general approach but does not link from inside an anonymous namespace: the iterable-section symbols get C++ name-mangled into the namespace. Deriving the controllers from the same devicetree walk avoids that and fixes the finding just as completely. The one case it does not reach -- an enabled bus with no declared device at all -- is stated in the code rather than left to be discovered. Builds clean for rak4631 repeater, rak4631 companion, seeed_lr2021_evk/nrf54l15/cpuapp and native_sim/native/64. Repeater cost is now +4208 B flash, +0 B RAM. Co-Authored-By: Claude Opus 5 (1M context) --- zephcore/helpers/HardwareReport.cpp | 200 ++++++++++++++++++---------- 1 file changed, 132 insertions(+), 68 deletions(-) diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index 81dfa240b..93671d471 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -135,29 +135,31 @@ struct I2cDecl { #define HW_I2C_ENTRY(node_id) \ { \ - DT_NODE_FULL_NAME(DT_PARENT(node_id)), \ + DT_NODE_FULL_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), \ }, -/* Skip any child without both reg and compatible -- it is not an addressable - * chip and has nothing to report. */ -#define HW_I2C_CHILD(node_id) \ - IF_ENABLED(UTIL_AND(DT_NODE_HAS_PROP(node_id, reg), \ - DT_NODE_HAS_PROP(node_id, compatible)), \ +/* + * 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[] = { -#if DT_NODE_HAS_STATUS(DT_NODELABEL(i2c0), okay) - DT_FOREACH_CHILD_STATUS_OKAY(DT_NODELABEL(i2c0), HW_I2C_CHILD) -#endif -#if DT_NODE_HAS_STATUS(DT_NODELABEL(i2c1), okay) - DT_FOREACH_CHILD_STATUS_OKAY(DT_NODELABEL(i2c1), HW_I2C_CHILD) -#endif -#if DT_NODE_HAS_STATUS(DT_NODELABEL(i2c2), okay) - DT_FOREACH_CHILD_STATUS_OKAY(DT_NODELABEL(i2c2), HW_I2C_CHILD) -#endif + 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(). */ @@ -166,33 +168,34 @@ const I2cDecl i2c_decls[] = { constexpr size_t i2c_decl_count() { return ARRAY_SIZE(i2c_decls) - 1; } +#if IS_ENABLED(CONFIG_I2C) /* - * Bus handles, for the live scan only. + * 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. * - * Gated on CONFIG_I2C as well as node status: a devicetree node can be - * status-okay while no I2C driver is compiled in (native_sim is exactly that), - * and DEVICE_DT_GET on such a node references a device object nothing ever - * defines, so the image fails to LINK rather than reporting a missing bus at - * runtime. The declared inventory above deliberately takes no device handles - * for the same reason. + * Limitation, stated rather than hidden: a bus with no declared device at all + * is not scanned, because nothing in the devicetree walk names it. */ -#if IS_ENABLED(CONFIG_I2C) -const struct device *const i2c_buses[] = { -#if DT_NODE_HAS_STATUS(DT_NODELABEL(i2c0), okay) - DEVICE_DT_GET(DT_NODELABEL(i2c0)), -#endif -#if DT_NODE_HAS_STATUS(DT_NODELABEL(i2c1), okay) - DEVICE_DT_GET(DT_NODELABEL(i2c1)), -#endif -#if DT_NODE_HAS_STATUS(DT_NODELABEL(i2c2), okay) - DEVICE_DT_GET(DT_NODELABEL(i2c2)), -#endif +#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_count() { return ARRAY_SIZE(i2c_buses) - 1; } -#else -constexpr size_t i2c_bus_count() { return 0; } +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. */ @@ -318,13 +321,25 @@ void section_i2c_scan(Sink *s) /* Different from "scanned and found nothing": this build cannot scan. */ sink_line(s, "i2c: no I2C support compiled in"); #else - if (i2c_bus_count() == 0) { - sink_line(s, "i2c: no bus enabled"); - return; - } - - for (size_t b = 0; b < i2c_bus_count(); b++) { - const struct device *bus = i2c_buses[b]; + 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); @@ -372,6 +387,10 @@ void section_i2c_scan(Sink *s) sink_line(s, "%s", line); } } + + if (buses == 0) { + sink_line(s, "i2c: no bus enabled"); + } #endif /* CONFIG_I2C */ } @@ -471,21 +490,62 @@ void section_summary(Sink *s, mesh::MainBoard *board, CommonCLICallbacks *cb) (void)board; } -/* Parse an optional trailing page index. Unsigned throughout, and a value - * beyond any plausible line count is clamped rather than wrapped. */ -unsigned parse_start(const char *arg) +/* + * 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) { - if (arg == nullptr) { - return 0; + size_t n = strlen(name); + + if (strncmp(arg, name, n) != 0) { + return nullptr; } - while (*arg == ' ') { - arg++; + if (arg[n] != '\0' && arg[n] != ' ') { + return nullptr; } - if (*arg < '0' || *arg > '9') { - return 0; + 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; } - unsigned long v = strtoul(arg, nullptr, 10); - return (v > 9999U) ? 9999U : (unsigned)v; + + char *end = nullptr; + unsigned long v = strtoul(rest, &end, 10); + + while (end != nullptr && *end == ' ') { + end++; + } + if (end != nullptr && *end != '\0') { + return false; + } + + *start = (v > 9999UL) ? 9999U : (unsigned)v; + return true; } } /* namespace */ @@ -501,30 +561,34 @@ void handle(const char *command, char *reply, size_t cap, bool local, } 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 (memcmp(arg, "board", 5) == 0) { - sink_init(&s, reply, cap, parse_start(arg + 5)); + } else if ((rest = match_word(arg, "board")) && parse_tail(rest, &start)) { + sink_init(&s, reply, cap, start); section_board(&s, board, callbacks); - } else if (memcmp(arg, "rtc", 3) == 0) { - sink_init(&s, reply, cap, parse_start(arg + 3)); + } else if ((rest = match_word(arg, "rtc")) && parse_tail(rest, &start)) { + sink_init(&s, reply, cap, start); section_rtc(&s, rtc); - } else if (memcmp(arg, "i2c scan", 8) == 0) { - sink_init(&s, reply, cap, parse_start(arg + 8)); + } else if ((rest = match_word(arg, "i2c scan")) && parse_tail(rest, &start)) { + sink_init(&s, reply, cap, start); section_i2c_scan(&s); - } else if (memcmp(arg, "i2c", 3) == 0) { - sink_init(&s, reply, cap, parse_start(arg + 3)); + } else if ((rest = match_word(arg, "i2c")) && parse_tail(rest, &start)) { + sink_init(&s, reply, cap, start); section_i2c(&s); - } else if (memcmp(arg, "gps", 3) == 0) { - sink_init(&s, reply, cap, parse_start(arg + 3)); + } else if ((rest = match_word(arg, "gps")) && parse_tail(rest, &start)) { + sink_init(&s, reply, cap, start); section_gps(&s, callbacks); - } else if (memcmp(arg, "sensors", 7) == 0) { - sink_init(&s, reply, cap, parse_start(arg + 7)); + } else if ((rest = match_word(arg, "sensors")) && parse_tail(rest, &start)) { + sink_init(&s, reply, cap, start); section_sensors(&s); - } else if (memcmp(arg, "all", 3) == 0) { - sink_init(&s, reply, cap, parse_start(arg + 3)); + } 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); From 912670b3a29c230648a32c71eef0f2ac8e2bf4b8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 17 Sep 2026 17:40:08 -0700 Subject: [PATCH 07/78] Document the I2C coverage guarantee and its one gap Records that both `hw i2c` and `hw i2c scan` cover every enabled bus regardless of nodelabel, which is the property round 2 fixed, so a future change cannot quietly narrow it back to i2c0-i2c2 without contradicting the docs. States the gap the implementation genuinely has rather than letting it be discovered: an enabled bus carrying no declared device is not scanned, because buses are found by walking declared devices. Co-Authored-By: Claude Opus 5 (1M context) --- docs/Repeater_CLI_commands.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index 1c51b3a4e..90bcc7f93 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -74,6 +74,11 @@ This is deliberate and the replies say which is which. 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. +Both cover **every** I2C bus the board enables, whatever its devicetree nodelabel — the tree +has boards on `i2c22` and `i2c30`, not just `i2c0`–`i2c2`. One gap is worth knowing: an enabled +bus with **no declared device at all** is not scanned, because the buses are discovered by +walking declared devices. In practice a bus is enabled because something is on it. + ### What the report will not do Every field is something the firmware *knows*. It never infers: From 786b1a5e38d9f1a149bb27669fa6229b176e842e Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 17 Sep 2026 17:48:32 -0700 Subject: [PATCH 08/78] Review round 3: the empty-scan message claimed more than was known `i2c: no bus enabled` was wrong about which fact it had. Buses are discovered by walking declared devices, so `buses == 0` means no bus carries a declared device -- the SoC may well have an enabled controller with nothing declared on it, and that case would have been reported as having no I2C at all. Third instance of the same class in this PR, so it is worth naming: every one of these was a message whose wording outran the evidence behind it, not a wrong computation. The value was always right; the sentence was too strong. Co-Authored-By: Claude Opus 5 (1M context) --- zephcore/helpers/HardwareReport.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index 93671d471..303fabddb 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -389,7 +389,10 @@ void section_i2c_scan(Sink *s) } if (buses == 0) { - sink_line(s, "i2c: no bus enabled"); + /* 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 */ } From d84acfb65c0affca7a235e05da47f5a5cd2ffeb6 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 17 Sep 2026 17:48:32 -0700 Subject: [PATCH 09/78] rak4631: declare the RTC descriptors so a fitted RAK12002 is used WisBlock slots can carry a RAK12002 (RV3028 at 0x52), but this overlay included sensors-i2c.dtsi and not rtc-i2c.dtsi, so ZephCore declared zero RTC candidates on this board. A fitted module was invisible: the firmware ran the soft clock and lost the time on every power-off, with nothing to indicate why. The descriptors are data-only and probed by raw I2C at boot, so a board with no RTC module simply fails the probe and is skipped. Nothing binds and nothing is reported present -- which is what the `hw rtc` section will now say on a board without one. Safe on this board specifically, which is why the include is opt-in rather than in a common base: the RTC addresses (0x68/0x51/0x52/0x32) collide with nothing declared here. WisBlock sensor slots use 0x38/0x40/0x44/0x5c/0x70/0x76/0x77 and the OLED is 0x3c. 0x68 is shared with MPU-class IMUs including the RAK1905. That is not a devicetree clash, since nothing declares 0x68 here, and the discovery probe rejects a chip whose time registers are not valid BCD -- precisely the case that check was written for, so an IMU is never adopted as a write-back target. Costs +1576 B flash on a repeater build: the discovery code was previously compiling to its no-RTC-declared stubs on this board. Co-Authored-By: Claude Opus 5 (1M context) --- .../boards/nrf52840/rak4631/board.overlay | 454 +++++++++--------- 1 file changed, 236 insertions(+), 218 deletions(-) diff --git a/zephcore/boards/nrf52840/rak4631/board.overlay b/zephcore/boards/nrf52840/rak4631/board.overlay index 9b1de92ce..822a2a9a6 100644 --- a/zephcore/boards/nrf52840/rak4631/board.overlay +++ b/zephcore/boards/nrf52840/rak4631/board.overlay @@ -1,218 +1,236 @@ -/* - * RAK4631 overlay for SoftDevice + UF2 bootloader. - * App at 0x26000, storage at 0xD4000, bootloader at 0xF4000. - * Uses SoftDevice s140 v6.1.1 (ends at 0x26000). - * Console: USB CDC ACM. - * Battery: AIN3 = P0.05, voltage divider per RAK4631 schematic. - * - * QSPI Flash: Optional external flash on WisBlock Base boards. - * Supports: P25Q16H, MX25R1635F, W25Q16, GD25Q16, IS25LP080, ZD25WQ16 - * If no QSPI flash present, falls back to internal flash only. - * - * GPS: RAK1910 (u-blox MAX-7Q) on UART1 at 9600 baud (Slot A) - * Sensors: Auto-detected on I2C0 - supports: - * - RAK1901 (SHTC3) at 0x70 - * - RAK1902 (LPS22HB) at 0x5C - * - RAK1906 (BME680) at 0x76 - * - * Optional SSD1306 128x64 @ 0x3C on I2C0 (P0.13/P0.14, no reset GPIO). - * If the panel is absent, display init may fail - * at runtime; UI continues without OLED. - */ - -#include -#include - -/delete-node/ &boot_partition; -/delete-node/ &slot0_partition; -/delete-node/ &slot1_partition; -/delete-node/ &storage_partition; - -/ { - aliases { - /* leds: green=heartbeat, blue=unread + LoRa TX; lora-tx-led=blue */ - led0 = &green_led; - led1 = &blue_led; - lora-tx-led = &blue_led; - sw0 = &user_button; - }; - chosen { - zephyr,code-partition = &code_partition; - zephyr,console = &cdc_acm_uart; - zephyr,shell-uart = &cdc_acm_uart; - zephyr,display = &ssd1306; - }; - - /* User button: P0.09 (NFC1). P0.00/P0.01 are 32k crystal — do not use for GPIO. */ - buttons: buttons { - compatible = "gpio-keys"; - - user_button: button_0 { - gpios = <&gpio0 9 (GPIO_PULL_UP | GPIO_ACTIVE_LOW)>; - zephyr,code = ; - label = "User Button"; - }; - }; - - /* longpress: short=KEY_A (multitap), long=KEY_ENTER */ - user_btn_longpress { - compatible = "zephyr,input-longpress"; - input = <&buttons>; - input-codes = ; - short-codes = ; - long-codes = ; - long-delay-ms = <1000>; - }; - - page_btn_multitap { - compatible = "zephcore,input-multi-tap"; - input-codes = ; - tap-codes = ; - tap-delay-ms = <400>; - }; -}; - -/* Battery ADC - AIN3 on P0.05 with voltage divider */ -/ { - zephyr,user { - io-channels = <&adc 3>; - vbat-mv-multiplier = <6161>; /* matches RAK4631 divider + SAADC ref */ - }; -}; - -&adc { - #address-cells = <1>; - #size-cells = <0>; - channel@3 { - reg = <3>; - zephyr,gain = "ADC_GAIN_1_6"; - zephyr,reference = "ADC_REF_INTERNAL"; - zephyr,acquisition-time = ; - zephyr,input-positive = ; - zephyr,resolution = <12>; - }; -}; - -&zephyr_udc0 { - cdc_acm_uart: cdc_acm_uart { - compatible = "zephyr,cdc-acm-uart"; - }; -}; - -&lora { - dio1-gpios = <&gpio1 15 (GPIO_PULL_DOWN | GPIO_ACTIVE_HIGH)>; - rx-boosted; - - /* Upstream DTS labels P1.05 as rx-enable-gpios (per-direction - * antenna control), but on RAK4631 this pin is the SX1262 module - * power-enable (HIGH = on; see MeshCore variants/rak4631). The - * driver toggles per-direction GPIOs each TX/RX, which would - * pulse this pin LOW during RX. Stop the driver from touching it - * by deleting the property; the gpio-hog below pins it HIGH. */ - /delete-property/ rx-enable-gpios; -}; - -&gpio1 { - sx1262_power_en { - gpio-hog; - gpios = <5 GPIO_ACTIVE_HIGH>; - output-high; - line-name = "sx1262-power-en"; - }; - - /* 3V3_S rail enable — WB_IO2 / P1.02, HIGH = on. This is the switched - * 3.3V bus on every WisBlock base board: it feeds the GPS slot, the - * sensor slot and the OLED. Without this the pin sits at its reset - * default (input/float) and the GPS module is simply never powered. - * - * Hogged rather than exposed as `chosen zephcore,gps-power`: the rail is - * not GPS-private, so duty-cycling it for the GPS would also drop the - * I2C sensors and the display. - * - * Note this pin is often described as "GPS reset" (it lands on the - * RAK1910/RAK12500 RESET_N in Slot A). Do not drive it as a reset — - * u-blox specify RESET_N as a reset only, not an enable (the SiP draws - * significant current while held in reset). See MeshCore PR #3051. */ - v3v3s_en { - gpio-hog; - gpios = <2 GPIO_ACTIVE_HIGH>; - output-high; - line-name = "3v3s-enable"; - }; -}; - -/* LEDs are active-high on this board (stock RAK4631 DTS used active-low). */ -&green_led { - gpios = <&gpio1 3 GPIO_ACTIVE_HIGH>; -}; - -&blue_led { - gpios = <&gpio1 4 GPIO_ACTIVE_HIGH>; -}; - -/* - * GPS on UART1 - RAK1910 (u-blox MAX-7Q) at 9600 baud, Slot A. - * - * The WisBlock GPS slot is wired to the core module's Serial1 = P0.15 (MCU RX - * <- GPS TX) / P0.16 (MCU TX -> GPS RX), which is Zephyr's uart1 on this board - * (uart0 is P0.19/P0.20 = Serial2, which the GPS slot does not reach). Matches - * MeshCore variants/rak4631 (PIN_SERIAL1_RX 15 / _TX 16) and ZephCore's sibling - * RAK-family boards gat562_30s and rak3401_1watt. uart1 is also nrf-uarte - * upstream, whereas uart0 is the legacy nrf-uart. - */ -&uart1 { - current-speed = <9600>; - gnss: gnss-nmea-generic { - compatible = "gnss-nmea-generic"; - }; -}; - -/* uart0 (P0.19/P0.20) is unused — console is USB CDC ACM and the GPS is on - * uart1. Upstream leaves it enabled at 115200; turn it off so it does not - * hold pins or a UARTE instance. */ -&uart0 { - status = "disabled"; -}; - -/* I2C0: optional SSD1306 + sensors */ -&i2c0 { - ssd1306: ssd1306@3c { - compatible = "solomon,ssd1306"; - reg = <0x3c>; - width = <128>; - height = <64>; - segment-offset = <0>; - page-offset = <0>; - display-offset = <0>; - multiplex-ratio = <63>; - segment-remap; - com-invdir; - inversion-on; - prechargep = <0x22>; - }; - - #include "../../common/sensors-i2c.dtsi" -}; - -/* RAK4631 has BME680, not BME280 — override the common DTSI default */ -/delete-node/ &bme280; -&i2c0 { - bme680: bme680@76 { - compatible = "bosch,bme680"; - reg = <0x76>; - }; -}; - -/* - * QSPI Flash — disabled. - * RAK19003 Mini Base Board has no QSPI flash. The upstream DTS enables &qspi, - * which hangs at boot if no chip is present. Disable it explicitly. - * For base boards with QSPI (RAK19007, RAK5005-O), create a separate variant. - */ -&qspi { - status = "disabled"; -}; - -/* SoftDevice v6 flash layout */ -#include "../../common/nrf52_partitions_sdv6.dtsi" -#include "../../common/nrf52_wakeup.dtsi" +/* + * RAK4631 overlay for SoftDevice + UF2 bootloader. + * App at 0x26000, storage at 0xD4000, bootloader at 0xF4000. + * Uses SoftDevice s140 v6.1.1 (ends at 0x26000). + * Console: USB CDC ACM. + * Battery: AIN3 = P0.05, voltage divider per RAK4631 schematic. + * + * QSPI Flash: Optional external flash on WisBlock Base boards. + * Supports: P25Q16H, MX25R1635F, W25Q16, GD25Q16, IS25LP080, ZD25WQ16 + * If no QSPI flash present, falls back to internal flash only. + * + * GPS: RAK1910 (u-blox MAX-7Q) on UART1 at 9600 baud (Slot A) + * Sensors: Auto-detected on I2C0 - supports: + * - RAK1901 (SHTC3) at 0x70 + * - RAK1902 (LPS22HB) at 0x5C + * - RAK1906 (BME680) at 0x76 + * + * Optional SSD1306 128x64 @ 0x3C on I2C0 (P0.13/P0.14, no reset GPIO). + * If the panel is absent, display init may fail + * at runtime; UI continues without OLED. + */ + +#include +#include + +/delete-node/ &boot_partition; +/delete-node/ &slot0_partition; +/delete-node/ &slot1_partition; +/delete-node/ &storage_partition; + +/ { + aliases { + /* leds: green=heartbeat, blue=unread + LoRa TX; lora-tx-led=blue */ + led0 = &green_led; + led1 = &blue_led; + lora-tx-led = &blue_led; + sw0 = &user_button; + }; + chosen { + zephyr,code-partition = &code_partition; + zephyr,console = &cdc_acm_uart; + zephyr,shell-uart = &cdc_acm_uart; + zephyr,display = &ssd1306; + }; + + /* User button: P0.09 (NFC1). P0.00/P0.01 are 32k crystal — do not use for GPIO. */ + buttons: buttons { + compatible = "gpio-keys"; + + user_button: button_0 { + gpios = <&gpio0 9 (GPIO_PULL_UP | GPIO_ACTIVE_LOW)>; + zephyr,code = ; + label = "User Button"; + }; + }; + + /* longpress: short=KEY_A (multitap), long=KEY_ENTER */ + user_btn_longpress { + compatible = "zephyr,input-longpress"; + input = <&buttons>; + input-codes = ; + short-codes = ; + long-codes = ; + long-delay-ms = <1000>; + }; + + page_btn_multitap { + compatible = "zephcore,input-multi-tap"; + input-codes = ; + tap-codes = ; + tap-delay-ms = <400>; + }; +}; + +/* Battery ADC - AIN3 on P0.05 with voltage divider */ +/ { + zephyr,user { + io-channels = <&adc 3>; + vbat-mv-multiplier = <6161>; /* matches RAK4631 divider + SAADC ref */ + }; +}; + +&adc { + #address-cells = <1>; + #size-cells = <0>; + channel@3 { + reg = <3>; + zephyr,gain = "ADC_GAIN_1_6"; + zephyr,reference = "ADC_REF_INTERNAL"; + zephyr,acquisition-time = ; + zephyr,input-positive = ; + zephyr,resolution = <12>; + }; +}; + +&zephyr_udc0 { + cdc_acm_uart: cdc_acm_uart { + compatible = "zephyr,cdc-acm-uart"; + }; +}; + +&lora { + dio1-gpios = <&gpio1 15 (GPIO_PULL_DOWN | GPIO_ACTIVE_HIGH)>; + rx-boosted; + + /* Upstream DTS labels P1.05 as rx-enable-gpios (per-direction + * antenna control), but on RAK4631 this pin is the SX1262 module + * power-enable (HIGH = on; see MeshCore variants/rak4631). The + * driver toggles per-direction GPIOs each TX/RX, which would + * pulse this pin LOW during RX. Stop the driver from touching it + * by deleting the property; the gpio-hog below pins it HIGH. */ + /delete-property/ rx-enable-gpios; +}; + +&gpio1 { + sx1262_power_en { + gpio-hog; + gpios = <5 GPIO_ACTIVE_HIGH>; + output-high; + line-name = "sx1262-power-en"; + }; + + /* 3V3_S rail enable — WB_IO2 / P1.02, HIGH = on. This is the switched + * 3.3V bus on every WisBlock base board: it feeds the GPS slot, the + * sensor slot and the OLED. Without this the pin sits at its reset + * default (input/float) and the GPS module is simply never powered. + * + * Hogged rather than exposed as `chosen zephcore,gps-power`: the rail is + * not GPS-private, so duty-cycling it for the GPS would also drop the + * I2C sensors and the display. + * + * Note this pin is often described as "GPS reset" (it lands on the + * RAK1910/RAK12500 RESET_N in Slot A). Do not drive it as a reset — + * u-blox specify RESET_N as a reset only, not an enable (the SiP draws + * significant current while held in reset). See MeshCore PR #3051. */ + v3v3s_en { + gpio-hog; + gpios = <2 GPIO_ACTIVE_HIGH>; + output-high; + line-name = "3v3s-enable"; + }; +}; + +/* LEDs are active-high on this board (stock RAK4631 DTS used active-low). */ +&green_led { + gpios = <&gpio1 3 GPIO_ACTIVE_HIGH>; +}; + +&blue_led { + gpios = <&gpio1 4 GPIO_ACTIVE_HIGH>; +}; + +/* + * GPS on UART1 - RAK1910 (u-blox MAX-7Q) at 9600 baud, Slot A. + * + * The WisBlock GPS slot is wired to the core module's Serial1 = P0.15 (MCU RX + * <- GPS TX) / P0.16 (MCU TX -> GPS RX), which is Zephyr's uart1 on this board + * (uart0 is P0.19/P0.20 = Serial2, which the GPS slot does not reach). Matches + * MeshCore variants/rak4631 (PIN_SERIAL1_RX 15 / _TX 16) and ZephCore's sibling + * RAK-family boards gat562_30s and rak3401_1watt. uart1 is also nrf-uarte + * upstream, whereas uart0 is the legacy nrf-uart. + */ +&uart1 { + current-speed = <9600>; + gnss: gnss-nmea-generic { + compatible = "gnss-nmea-generic"; + }; +}; + +/* uart0 (P0.19/P0.20) is unused — console is USB CDC ACM and the GPS is on + * uart1. Upstream leaves it enabled at 115200; turn it off so it does not + * hold pins or a UARTE instance. */ +&uart0 { + status = "disabled"; +}; + +/* I2C0: optional SSD1306 + sensors */ +&i2c0 { + ssd1306: ssd1306@3c { + compatible = "solomon,ssd1306"; + reg = <0x3c>; + width = <128>; + height = <64>; + segment-offset = <0>; + page-offset = <0>; + display-offset = <0>; + multiplex-ratio = <63>; + segment-remap; + com-invdir; + inversion-on; + prechargep = <0x22>; + }; + + #include "../../common/sensors-i2c.dtsi" + + /* + * WisBlock slots can carry a RAK12002 (RV3028 at 0x52), so opt in to + * boot-time time restore. The descriptors are data-only and probed by + * raw I2C, so a board with no RTC module fitted simply fails the probe + * and is skipped -- nothing is bound and nothing is reported present. + * + * Safe on this board specifically: the RTC addresses (0x68/0x51/0x52/ + * 0x32) do not collide with anything declared here. The WisBlock sensor + * slots use 0x38/0x40/0x44/0x5c/0x70/0x76/0x77 and the OLED is 0x3c. + * + * 0x68 is shared with MPU-class IMUs, including the RAK1905. That is + * not a devicetree clash (nothing declares 0x68 here), and the + * discovery probe rejects a chip whose time registers are not valid + * BCD, which is exactly the case that check exists for -- an IMU is + * never adopted as a write-back target. + */ + #include "../../common/rtc-i2c.dtsi" +}; + +/* RAK4631 has BME680, not BME280 — override the common DTSI default */ +/delete-node/ &bme280; +&i2c0 { + bme680: bme680@76 { + compatible = "bosch,bme680"; + reg = <0x76>; + }; +}; + +/* + * QSPI Flash — disabled. + * RAK19003 Mini Base Board has no QSPI flash. The upstream DTS enables &qspi, + * which hangs at boot if no chip is present. Disable it explicitly. + * For base boards with QSPI (RAK19007, RAK5005-O), create a separate variant. + */ +&qspi { + status = "disabled"; +}; + +/* SoftDevice v6 flash layout */ +#include "../../common/nrf52_partitions_sdv6.dtsi" +#include "../../common/nrf52_wakeup.dtsi" From 05c9c6bef9b454e5b1e111cfb3aaa9f93d0baaf8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 17 Sep 2026 17:54:50 -0700 Subject: [PATCH 10/78] Review round 4: reset per-candidate probe state on every probe rtc_probe() can run more than once -- zephcore_rtc_save() probes if restore never ran -- and it returns early as soon as a chip holds a valid time. s_state[] was never cleared between runs, so a later probe could leave the previous run's PRESENT or ABSENT on candidates it never reached. That is exactly the stale claim the three-valued outcome exists to prevent: the header documents UNPROBED as "not probed", and an entry carrying a previous run's verdict silently contradicts it. Clearing at the top of the probe rather than tracking a generation count keeps the invariant local to the one function that can break it. Co-Authored-By: Claude Opus 5 (1M context) --- zephcore/adapters/clock/ZephyrRTCDiscover.c | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.c b/zephcore/adapters/clock/ZephyrRTCDiscover.c index e85aea2dd..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 @@ -119,6 +120,13 @@ 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]; From 1820b90bcb9b55309ccbcab8fa534e22be549396 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 17 Sep 2026 18:03:47 -0700 Subject: [PATCH 11/78] Hardware validation: two redundant fields in the summary Found by running the command on a RAK4631, which is the only way these could surface -- both compile fine and both read wrong. rtc rtc-rv3028@52@0x52 rak4631/nrf52840 (nrf52840) A devicetree node's full name already ends in "@", so appending the address repeats it. CONFIG_BOARD_TARGET already embeds the SoC, so pairing it with CONFIG_SOC prints the part twice. The summary now uses the node name alone and CONFIG_BOARD; `hw board` still reports the full board target and the address, where they are the point rather than noise. Co-Authored-By: Claude Opus 5 (1M context) --- zephcore/helpers/HardwareReport.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index 303fabddb..8970458ba 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -462,14 +462,20 @@ void section_sensors(Sink *s) void section_summary(Sink *s, mesh::MainBoard *board, CommonCLICallbacks *cb) { - sink_line(s, "%s (%s)", CONFIG_BOARD_TARGET, CONFIG_SOC); + /* 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)) { - sink_line(s, "rtc %s@0x%02x", active.name, active.addr); + /* 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 { From b86ec0f02fc2fd534bae786cb45af8fee44d9b59 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 17 Sep 2026 18:13:21 -0700 Subject: [PATCH 12/78] Review round 6: derive the bus string the way Zephyr names devices The declared inventory built its bus string from DT_NODE_FULL_NAME, while the live scan and the RTC section both report dev->name. Zephyr derives a device name as DT_PROP_OR(node, label, DT_NODE_FULL_NAME(node)), so a bus node carrying a `label` would print one identifier in `hw i2c` and another in `hw i2c scan` and `hw rtc` -- and declared_name_at(), which matches the two by string, would quietly stop annotating scanned addresses. No board in this tree labels an I2C node, so the two derivations agree today and hardware testing on a RAK4631 could not surface it: the scan annotated 0x52 correctly precisely because the strings happened to match. Correct by accident is still a latent bug, and it would have appeared the first time any board or an upstream Zephyr board definition added a label. Using DEVICE_DT_NAME makes the three sections agree by construction. Co-Authored-By: Claude Opus 5 (1M context) --- zephcore/helpers/HardwareReport.cpp | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index 8970458ba..20de40cf0 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -133,9 +133,23 @@ 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_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) \ { \ - DT_NODE_FULL_NAME(DT_BUS(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), \ From 8dd7a83ba98a1a0d463e1b10dc14d219cf8f41c8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 17 Sep 2026 18:33:42 -0700 Subject: [PATCH 13/78] Add the Heltec WiFi LoRa 32 V4-R8 board ZephCore had no board for this revision, and neither existing V4 board fits it. Flashing either drives the wrong pins. "R8" is the SoC part: ESP32-S3R8 with 8 MB octal PSRAM, where the plain V4 is an S3R2 with 2 MB quad. Octal PSRAM consumes GPIO33-37, and that single fact explains every pin move on this revision: Vext_Ctrl GPIO36 -> GPIO40 LED GPIO35 -> GPIO46 VGNSS_Ctrl GPIO34 -> GPIO42 ADC_Ctrl GPIO37 -> removed GNSS_RST GPIO42 -> removed (GPIO42 reused for VGNSS_Ctrl) heltec_wifi_lora32_v43's own devicetree already said as much without drawing the conclusion: it notes GPIO33-34 are free "because the V4 has 2 MB *quad* PSRAM". On an octal part they are not. Based on _v43, which shares the KCT8103L FEM (CTX GPIO5, CSD GPIO2, POWER GPIO7) and the I2C0 pinout (SDA 17, SCL 18). Changes from it: - Vext regulator moved to GPIO40, still active-LOW. - LED and its LEDC PWM channel moved to GPIO46. - GPS enable moved to GPIO42 and the gps-reset node and alias removed, which also keeps this board on the plain enable path rather than the HAS_T1000_GPS_CONTROL sequence that a gps-reset alias selects. - vbat-enable regulator removed. ZephyrBoard's lookup is DT_NODE_EXISTS-guarded and degrades to NULL, so the permanently connected divider is simply read directly. - SoC is N16R8, not N16R2. The PSRAM needed three separate changes and only the last one actually took effect, which is worth recording: selecting SOC_ESP32S3_WROOM_N16R8 and setting CONFIG_SPIRAM_MODE_OCT still left CONFIG_ESP_SPIRAM_SIZE=2097152, because that value is derived from the devicetree psram0 node and the DTS was still including esp32s3_wroom_n16r2.dtsi. A build can therefore claim the right SoC while addressing 2 MB in the wrong mode on an 8 MB octal part. Now reads N16R8 + MODE_OCT + 8388608. Pin assignments verified against the vendor pinmap at https://heltec.org/project/wifi-lora-32-v4/ and cross-checked against Arduino MeshCore variants/heltec_v4_r8, which agree on every line. Not yet run on hardware; flashing follows. Co-Authored-By: Claude Opus 5 (1M context) --- docs/supported_boards.md | 12 + .../esp32/heltec_wifi_lora32_v4_r8/Kconfig | 3 + .../Kconfig.heltec_wifi_lora32_v4_r8 | 3 + .../heltec_wifi_lora32_v4_r8/board.cmake | 7 + .../esp32/heltec_wifi_lora32_v4_r8/board.conf | 47 +++ .../heltec_wifi_lora32_v4_r8/board.overlay | 157 ++++++++++ .../esp32/heltec_wifi_lora32_v4_r8/board.yml | 6 + .../heltec_wifi_lora32_v4_r8-pinctrl.dtsi | 53 ++++ .../heltec_wifi_lora32_v4_r8_procpu.dts | 284 ++++++++++++++++++ .../heltec_wifi_lora32_v4_r8_procpu_defconfig | 12 + .../partitions.overlay | 53 ++++ 11 files changed, 637 insertions(+) create mode 100644 zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/Kconfig create mode 100644 zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/Kconfig.heltec_wifi_lora32_v4_r8 create mode 100644 zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/board.cmake create mode 100644 zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/board.conf create mode 100644 zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/board.overlay create mode 100644 zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/board.yml create mode 100644 zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8-pinctrl.dtsi create mode 100644 zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8_procpu.dts create mode 100644 zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8_procpu_defconfig create mode 100644 zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/partitions.overlay diff --git a/docs/supported_boards.md b/docs/supported_boards.md index 2b16f7bc8..71e79cf35 100644 --- a/docs/supported_boards.md +++ b/docs/supported_boards.md @@ -55,6 +55,7 @@ station_g2/esp32s3/procpu heltec_wifi_lora32_v3/esp32s3/procpu heltec_wifi_lora32_v4/esp32s3/procpu heltec_wifi_lora32_v43/esp32s3/procpu +heltec_wifi_lora32_v4_r8/esp32s3/procpu heltec_wireless_tracker/esp32s3/procpu heltec_wireless_tracker_v2/esp32s3/procpu thinknode_m9/esp32s3/procpu @@ -65,6 +66,17 @@ ttgo_lora32/esp32/procpu # source-only, no published firmware > ESP32 boards require `west blobs fetch hal_espressif` before first build. > > Heltec V3 console/shell use `uart0` (UART serial) in ZephCore. + +> **Heltec WiFi LoRa 32 V4-R8** (`heltec_wifi_lora32_v4_r8/esp32s3/procpu`): the "R8" is the +> SoC part — **ESP32-S3R8, 8 MB octal PSRAM**, where the plain V4 is an S3R2 with 2 MB quad. +> Octal PSRAM consumes GPIO33–37, and that one fact explains every pin move on this revision: +> Vext_Ctrl GPIO36→**40**, LED GPIO35→**46**, VGNSS_Ctrl GPIO34→**42**, while ADC_Ctrl (GPIO37) +> and GNSS_RST (GPIO42, reused) are **removed**. The FEM is KCT8103L with CTX on GPIO5, as on +> V4.3. Verified against the vendor pinmap at . +> +> **Do not flash `heltec_wifi_lora32_v4` or `_v43` to an R8**: Vext would be driven on GPIO36 +> instead of GPIO40, so the rail feeding the OLED and sensors never switches, and GPIO37 would +> be driven as an ADC enable this revision does not have. > > **Heltec Wireless Tracker** (`heltec_wireless_tracker/esp32s3/procpu`): V1.1 > ESP32-S3-FN8 companion with SX1262, ST7735R 160x80 TFT, and UC6580 GPS. diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/Kconfig b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/Kconfig new file mode 100644 index 000000000..959ef82f9 --- /dev/null +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/Kconfig @@ -0,0 +1,3 @@ +config HEAP_MEM_POOL_ADD_SIZE_BOARD + int + default 4096 diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/Kconfig.heltec_wifi_lora32_v4_r8 b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/Kconfig.heltec_wifi_lora32_v4_r8 new file mode 100644 index 000000000..dbd6dfe24 --- /dev/null +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/Kconfig.heltec_wifi_lora32_v4_r8 @@ -0,0 +1,3 @@ +config BOARD_HELTEC_WIFI_LORA32_V4_R8 + select SOC_ESP32S3_WROOM_N16R8 + select SOC_ESP32S3_PROCPU if BOARD_HELTEC_WIFI_LORA32_V4_R8_ESP32S3_PROCPU diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/board.cmake b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/board.cmake new file mode 100644 index 000000000..f3c3ac100 --- /dev/null +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/board.cmake @@ -0,0 +1,7 @@ +if(NOT "${OPENOCD}" MATCHES "^${ESPRESSIF_TOOLCHAIN_PATH}/.*") + set(OPENOCD OPENOCD-NOTFOUND) +endif() +find_program(OPENOCD openocd PATHS ${ESPRESSIF_TOOLCHAIN_PATH}/openocd-esp32/bin NO_DEFAULT_PATH) + +include(${ZEPHYR_BASE}/boards/common/esp32.board.cmake) +include(${ZEPHYR_BASE}/boards/common/openocd.board.cmake) diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/board.conf b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/board.conf new file mode 100644 index 000000000..2abe4b549 --- /dev/null +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/board.conf @@ -0,0 +1,47 @@ +# Heltec WiFi LoRa 32 V4-R8 — ESP32-S3R8, 16 MB flash, 8 MB octal PSRAM +# +# Hardware: +# SX1262 + KCT8103L external PA/FEM (GPIO5 CTX instead of GC1109 GPIO46 CPS) +# SSD1306 128x64 OLED on I2C0 +# User button on GPIO0 +# +# V4-R8 vs V4/V4.3. "R8" is the SoC part — ESP32-S3R8, 8 MB octal PSRAM, where +# the plain V4 is an S3R2 with 2 MB quad. Octal PSRAM consumes GPIO33-37, and +# that single fact explains every pin move on this revision: +# Vext_Ctrl GPIO36 -> GPIO40 +# LED GPIO35 -> GPIO46 +# VGNSS_Ctrl GPIO34 -> GPIO42 +# ADC_Ctrl GPIO37 -> removed +# GNSS_RST GPIO42 -> removed (GPIO42 reused for VGNSS_Ctrl) +# FEM is KCT8103L with CTX on GPIO5, as on V4.3. +# Verified against the vendor pinmap at https://heltec.org/project/wifi-lora-32-v4/ +# and Arduino MeshCore variants/heltec_v4_r8. +# Include order: prj.conf → zephcore_common.conf → esp32_common.conf → board.conf + +# Board identification +CONFIG_ZEPHCORE_BOARD_NAME="Heltec V4-R8" +CONFIG_BT_DIS_MODEL_NUMBER_STR="Heltec WiFi LoRa 32 V4-R8" + +# Flash size — 16 MB (override esp32_common.conf 4 MB default) +CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y + +# PSRAM mode — ESP32-S3R8 is silicon-strapped OPI octal, unlike the V4/V4.3 +# S3R2's quad. Selecting SOC_ESP32S3_WROOM_N16R8 alone is NOT enough: the build +# still came out CONFIG_SPIRAM_MODE_QUAD with CONFIG_ESP_SPIRAM_SIZE=2097152, +# i.e. addressing 2 MB in the wrong mode on an 8 MB octal part. Matches the +# other S3R8 boards here (station_g2, xiao_esp32s3, thinknode_m9, meshnology_w12). +CONFIG_SPIRAM_MODE_OCT=y + +# Undocumented register 0x8B5 RX improvement (MeshCore PR#1398) +# Applies to both GC1109 and KCT8103L PA boards. +CONFIG_ZEPHCORE_SX126X_HELTEC_REG_PATCH=y + +# TX power — SX1262 input to KCT8103L PA. +# Measured (MeshCore issue #1708, GC1109 data — KCT8103L similar): TX=18 → ~27 dBm +# radiated, practical efficiency peak. TX=10 → ~20 dBm; TX=22 saturates same as TX=18. +CONFIG_ZEPHCORE_DEFAULT_TX_POWER_DBM=12 + +# Single button, ENTER emitted via a zephyr,input-longpress filter (see +# 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 diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/board.overlay b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/board.overlay new file mode 100644 index 000000000..973a79b0b --- /dev/null +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/board.overlay @@ -0,0 +1,157 @@ +/* + * SPDX-License-Identifier: MIT + * Heltec WiFi LoRa 32 V4.3 — ZephCore overlay + * + * The DTS already defines: SX1262 (lora0), SSD1306 OLED, button, LED, PWM, + * vext_enable, fem_power, I2C0, SPI2, BLE, WiFi. + * This overlay adds ZephCore-specific integration on top. + * + * LoRa SPI2: SCLK=9, MOSI=10, MISO=11, NSS=8 + * LoRa ctrl: DIO1=14, BUSY=13, RESET=12 + * FEM: POWER=7, KCT8103L CSD=2, CTX=5 + * Display I2C0: SDA=17, SCL=18, RST=21 + * Battery ADC: GPIO1 = ADC1_CH0 (&adc0), no enable pin (ADC_Ctrl removed on R8) + * Vext: GPIO40 (active-LOW, powers OLED and sensors) + * Button: GPIO0 + * LED: GPIO46 + * GPS UART1: TX=GPIO39, RX=GPIO38, 9600 baud (Quectel L76) + * GPS ctrl: VGNSS_Ctrl GPIO42 (active-LOW); no reset line on R8 + */ + +#include + +/ { + chosen { + /* Native USB serial for ZephCore console/CLI */ + zephyr,console = &usb_serial; + zephyr,shell-uart = &usb_serial; + }; + + /* Battery ADC — ZephCore reads via zephyr,user { io-channels }. + * GPIO1 = ADC unit 1 = node &adc0 (label is 0-indexed, unit is 1-indexed); + * the old &adc1 was unit 2 = GPIO11 = SX1262 MISO — always read 0. + * Multiplier: Vref_internal(1100) * divider_ratio(5.2) = 5720 on the + * 100K/390K divider (was 17886, which assumed a 3300mV full-scale that + * ADC_REF_INTERNAL does not provide). Channel config is in the DTS. */ + zephyr,user { + io-channels = <&adc0 0>; + vbat-mv-multiplier = <5720>; + }; + + /* No vbat-enable regulator. + * + * V4/V4.3 gate the divider with ADC_Ctrl on GPIO37, but GPIO33-37 are the + * octal-PSRAM pads and this part is an ESP32-S3R8, so that pin is gone -- + * the vendor pinmap lists ADC_Ctrl as "removed" on V4-R8. The divider is + * therefore permanently connected and ZephyrBoard reads it directly; its + * vbat_enable lookup is DT_NODE_EXISTS-guarded and degrades to NULL. + */ + + /* GPS enable — VGNSS_Ctrl on GPIO42, active-LOW. + * + * V4/V4.3 put VGNSS_Ctrl on GPIO34 and a separate GNSS_RST on GPIO42. The + * R8 has neither pin available in that arrangement: GPIO33-37 are consumed + * by the octal PSRAM, so VGNSS_Ctrl was moved onto GPIO42 and the reset line + * was dropped. Matches the vendor pinmap and Arduino MeshCore + * variants/heltec_v4_r8 (PIN_GPS_EN=42, PIN_GPS_EN_ACTIVE=LOW, and no + * PIN_GPS_RESET at all). + * + * With no gps-reset alias this board stays off the manager's + * HAS_T1000_GPS_CONTROL sequence and uses the plain enable path, which is + * what the hardware supports. + */ + gps_en: gps-enable { + compatible = "gpio-leds"; + gps_enable_pin: gps_enable { + gpios = <&gpio1 10 GPIO_ACTIVE_LOW>; /* GPIO42 = gpio1 pin 10 */ + label = "GPS Enable"; + }; + }; + + aliases { + gps-enable = &gps_enable_pin; + }; + + /* Button chain — same mapping as RAK4631 / WisMesh Pocket (rak4631/board.overlay). */ + user_btn_longpress { + compatible = "zephyr,input-longpress"; + input = <&buttons>; + input-codes = ; + short-codes = ; + long-codes = ; + long-delay-ms = <1000>; + }; + + page_btn_multitap { + compatible = "zephcore,input-multi-tap"; + input-codes = ; + tap-codes = ; + tap-delay-ms = <400>; + }; +}; + +/* Enable native USB serial for console/CLI */ +&usb_serial { + status = "okay"; +}; + +/* Patch LoRa radio: add pull-down on DIO1 and enable RX boosted mode. + * DTS defines antenna-enable, tx-enable, dio2-tx-enable, and TCXO already. */ +&lora0 { + dio1-gpios = <&gpio0 14 (GPIO_PULL_DOWN | GPIO_ACTIVE_HIGH)>; + rx-boosted; +}; + +/* GPS on UART1 — optional external NMEA module on the pin header, wired to the + * same pins Arduino MeshCore uses for this board (PIN_GPS_RX=38, PIN_GPS_TX=39; + * Arduino names them from the MCU's point of view, so RX=38 is the MCU input). + * Upstream leaves uart1 disabled and gives it no pinctrl, so both are set here. + * GPIO38/39 are otherwise unclaimed on the V4; the console stays on usb_serial + * (or uart0/GPIO43-44 for companion USB builds). 9600 baud matches Arduino's + * default for this variant. + * + * The node is unconditional, like the V3's gnss node for its optional module: + * with nothing fitted the driver simply never sees a fix. */ +&pinctrl { + uart1_default: uart1_default { + group1 { + pinmux = ; + output-high; + }; + + group2 { + pinmux = ; + bias-pull-up; + }; + }; +}; + +&uart1 { + status = "okay"; + current-speed = <9600>; + pinctrl-0 = <&uart1_default>; + pinctrl-names = "default"; + + gnss: gnss-nmea-generic { + compatible = "gnss-nmea-generic"; + }; +}; + +/* I2C sensors — auto-detected at runtime */ +&i2c0 { + #include "../../common/sensors-i2c.dtsi" +}; + +/* + * Flash partitions live in partitions.overlay (this dir) so the identical map + * is fed to both the app and the MCUboot image — see that file and + * sysbuild/CMakeLists.txt. + */ + +/* LittleFS auto-mount — standard /lfs mount point */ +#include "../../common/filesystem.dtsi" +/* USB CDC-ACM — companion USB transport, auto-enabled for companion builds via + * boards/common/esp32s3_usb.conf (see the auto-include block in CMakeLists.txt). + * The chosen console above is usb_serial, i.e. the same D+/D- pins, so the conf's + * paired overlay moves the console to uart0/GPIO43-44 for those builds. */ +#include "../../common/esp32s3_usb_otg.dtsi" diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/board.yml b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/board.yml new file mode 100644 index 000000000..f79b06923 --- /dev/null +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/board.yml @@ -0,0 +1,6 @@ +board: + name: heltec_wifi_lora32_v4_r8 + full_name: Heltec WiFi LoRa 32 V4-R8 + vendor: heltec + socs: + - name: esp32s3 diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8-pinctrl.dtsi b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8-pinctrl.dtsi new file mode 100644 index 000000000..e670ed265 --- /dev/null +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8-pinctrl.dtsi @@ -0,0 +1,53 @@ +/* + * SPDX-License-Identifier: MIT + * Heltec WiFi LoRa 32 V4.3 — Pin Control Definitions + * + * Pin assignments identical to V4.2 (GPIO5/CTX is plain GPIO, no pinctrl entry needed): + * UART0: TX=GPIO43, RX=GPIO44 + * I2C0: SDA=GPIO17, SCL=GPIO18 (SSD1306 OLED + sensors) + * SPI2: SCLK=GPIO9, MOSI=GPIO10, MISO=GPIO11 (SX1262 LoRa) + * LEDC0: CH0=GPIO35 (PWM LED) + */ + +#include +#include +#include + +&pinctrl { + uart0_default: uart0_default { + group1 { + pinmux = ; + output-high; + }; + group2 { + pinmux = ; + bias-pull-up; + }; + }; + + i2c0_default: i2c0_default { + group1 { + pinmux = , + ; + drive-open-drain; + }; + }; + + spim2_default: spim2_default { + group1 { + pinmux = , + ; + }; + group2 { + pinmux = ; + output-low; + }; + }; + + ledc0_default: ledc0_default { + group1 { + pinmux = ; + output-enable; + }; + }; +}; diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8_procpu.dts b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8_procpu.dts new file mode 100644 index 000000000..1e835ea58 --- /dev/null +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8_procpu.dts @@ -0,0 +1,284 @@ +/* + * SPDX-License-Identifier: MIT + * Heltec WiFi LoRa 32 V4-R8 — ZephCore procpu DTS + * + * Hardware: + * MCU: ESP32-S3 (16 MB flash, 2 MB QSPI PSRAM) + * Radio: SX1262 on SPI2, KCT8103L external PA/FEM + * OLED: SSD1306 128x64 on I2C0 + * Button: GPIO0 + * LED: GPIO46 (PWM via LEDC0) + * Vext: GPIO40 active-LOW PMOS rail switch (powers OLED and sensors) + * BatADC: GPIO1 = ADC1_CH0 (&adc0), no enable pin (ADC_Ctrl removed on R8) + * + * V4-R8: ESP32-S3R8 (8 MB octal PSRAM). Octal PSRAM claims GPIO33-37, which is + * why Vext moved 36->40, the LED 35->46, VGNSS_Ctrl 34->42, and ADC_Ctrl and + * GNSS_RST were dropped. FEM is KCT8103L as on V4.3. + * + * V4.3 vs V4.2 difference — FEM chip changed: + * V4.2: GC1109 PA — TX_EN on GPIO46 (CPS) + * V4.3: KCT8103L PA — CTX on GPIO5 (HIGH=TX, LOW=RX/LNA) + * Detection: GPIO2 default pull differs (GC1109: pull-down, KCT8103L: pull-up) + * (See MeshCore PR#1867, Heltec schematic HTIT-WB32LAF_V4.3) + * + * All other hardware is identical to V4.2: + * GPIO7 = VFEM_Ctrl LDO power (active-HIGH, always-on) + * GPIO2 = KCT8103L CSD (chip enable, active-HIGH) + * GPIO5 = KCT8103L CTX (TX/RX select: HIGH=TX, LOW=RX/LNA) ← V4.3 change + * + * LoRa SPI2: SCLK=9, MOSI=10, MISO=11, NSS=8 + * LoRa ctrl: DIO1=14, BUSY=13, RESET=12 + * FEM: POWER=7, CSD=2, CTX=5 + * Display I2C0: SDA=17, SCL=18, RST=21 + */ + +/dts-v1/; + +#include +#include "heltec_wifi_lora32_v4_r8-pinctrl.dtsi" +#include +#include +#include +#include +#include +#include + +/ { + model = "Heltec WiFi LoRa 32 V4.3 PROCPU"; + compatible = "heltec,wifi-lora32-v4_3"; + + aliases { + led0 = &led0; + pwm-0 = &ledc0; + pwm-led0 = &pwm_led_white; + uart-0 = &uart0; + i2c-0 = &i2c0; + lora0 = &lora0; + sw0 = &button0; + watchdog0 = &wdt0; + }; + + leds { + compatible = "gpio-leds"; + led0: led { + gpios = <&gpio1 14 GPIO_ACTIVE_HIGH>; /* GPIO46 = gpio1 pin 14 */ + label = "White LED"; + }; + }; + + pwmleds { + compatible = "pwm-leds"; + pwm_led_white: pwm_led_gpio46 { + label = "White PWM LED"; + pwms = <&ledc0 0 PWM_MSEC(10) PWM_POLARITY_NORMAL>; + }; + }; + + buttons: buttons { + compatible = "gpio-keys"; + button0: button_0 { + gpios = <&gpio0 0 (GPIO_PULL_UP | GPIO_ACTIVE_LOW)>; + label = "USER Key"; + zephyr,code = ; + }; + }; + + /* No 'vbatt' voltage-divider node: ZephCore reads the battery via + * zephyr,user { io-channels } (see board.overlay), and a voltage-divider + * on &adc1 ch0 would target GPIO11 = the SX1262 MISO. See &adc0 below. */ + + /* Vext rail switch — PMOS, ACTIVE LOW (GPIO40 low = rail ON). + * Powers the OLED (incl. its I2C pull-ups) and the expansion header. + * Same rail circuit as V4.2 (only the PA differs on V4.3); verified on V4 + * hardware that GPIO40 high leaves the rail floating (~1.6 V) and the OLED + * unpowered, while low gives a clean 3.3 V. Confirmed against Arduino + * MeshCore: RefCountedDigitalPin::begin() drives PIN_VEXT_EN to !active + * (LOW) and the V4 OLED build constructs the display with NULL periph_power, + * so GPIO40 is held LOW continuously there too. + * regulator-boot-on + always-on: the rail comes up at regulator init, + * well before the SSD1306 driver init. */ + vext_enable: vext-enable { + compatible = "regulator-fixed"; + regulator-name = "vext"; + enable-gpios = <&gpio1 8 GPIO_ACTIVE_LOW>; /* GPIO40 = gpio1 pin 8 */ + regulator-boot-on; + regulator-always-on; + }; + + /* No adc_ctrl power-domain node: it would claim GPIO37 (gpio1 pin 5) with + * the opposite polarity and fight the vbat_enable regulator in board.overlay, + * which is the actual per-read driver for the ADC divider enable. */ + + /* FEM LDO power supply — GPIO7 powers the KCT8103L LDO. + * Must be HIGH before any radio operation. Keep always-on. */ + fem_power: fem-power { + compatible = "regulator-fixed"; + regulator-name = "fem-power"; + enable-gpios = <&gpio0 7 GPIO_ACTIVE_HIGH>; /* VFEM_Ctrl = GPIO7 */ + regulator-boot-on; + regulator-always-on; + }; + + chosen { + zephyr,sram = &sram1; + zephyr,console = &uart0; + zephyr,shell-uart = &uart0; + zephyr,flash = &flash0; + zephyr,code-partition = &slot0_partition; + zephyr,bt-hci = &esp32_bt_hci; + zephyr,display = &ssd1306_128x64; + }; +}; + +/* Battery ADC on adc0 (ADC unit 1 -> GPIO1 = ADC1_CH0). The node *labelled* + * adc0 is unit 1; the node labelled adc1 is unit 2 (= GPIO11 = SX1262 MISO). + * The channel@0 config is required — without it adc_channel_setup_dt() fails and + * getBattMilliVolts() returns 0 before ever performing a conversion. ADC_GAIN_1 + * (0 dB) with ADC_REF_INTERNAL (~1100 mV); the 100K/390K divider output for a + * 3.0-4.2 V cell sits well inside range. */ +&adc0 { + status = "okay"; + #address-cells = <1>; + #size-cells = <0>; + + channel@0 { + reg = <0>; + zephyr,gain = "ADC_GAIN_1"; + zephyr,reference = "ADC_REF_INTERNAL"; + zephyr,acquisition-time = ; + zephyr,resolution = <12>; + }; +}; + +/* ADC unit 2 (label adc1) is unused; keep it disabled so nothing can claim + * GPIO11 (SX1262 MISO) as an ADC pad. */ +&adc1 { + status = "disabled"; +}; + +&uart0 { + status = "okay"; + current-speed = <115200>; + pinctrl-0 = <&uart0_default>; + pinctrl-names = "default"; +}; + +&gpio0 { + status = "okay"; +}; + +&gpio1 { + status = "okay"; +}; + +/* Reduce CPU clock from 240 MHz to 160 MHz — saves power, HAL minimum */ +&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 = ; + pinctrl-0 = <&i2c0_default>; + pinctrl-names = "default"; + + ssd1306_128x64: display-controller@3c { + compatible = "solomon,ssd1306"; + reg = <0x3c>; + width = <128>; + height = <64>; + segment-offset = <0>; + page-offset = <0>; + display-offset = <0>; + multiplex-ratio = <63>; + segment-remap; + com-invdir; + inversion-on; + prechargep = <0x22>; + reset-gpios = <&gpio0 21 GPIO_ACTIVE_LOW>; /* RST_OLED = GPIO21 */ + vin-supply = <&vext_enable>; + }; +}; + +&spi2 { + #address-cells = <1>; + #size-cells = <0>; + status = "okay"; + pinctrl-0 = <&spim2_default>; + pinctrl-names = "default"; + + cs-gpios = <&gpio0 8 GPIO_ACTIVE_LOW>; /* NSS = GPIO8 */ + + lora0: modem@0 { + compatible = "semtech,sx1262"; + reg = <0>; + reset-gpios = <&gpio0 12 (GPIO_OPEN_DRAIN | GPIO_ACTIVE_LOW)>; /* RESET = GPIO12 */ + busy-gpios = <&gpio0 13 GPIO_ACTIVE_HIGH>; /* BUSY = GPIO13 */ + dio1-gpios = <&gpio0 14 GPIO_ACTIVE_HIGH>; /* DIO1 = GPIO14 */ + + /* External PA/FEM control (KCT8103L, V4.3): + * GPIO2 = CSD (chip enable): driven HIGH during radio, LOW in sleep + * GPIO5 = CTX (receive-path select): LOW = through the LNA, + * HIGH = bypass. Held HIGH during TX, where the branch + * is inert because DIO2/CPS has selected the TX path. + * V4.3 CHANGE: CTX moved from GPIO46 (GC1109 CPS) to GPIO5 (KCT8103L CTX). + * CTX is lna-bypass-gpios, NOT tx-enable-gpios: it enables nothing, + * and binding it as a TX enable is what made `set radio.fem.rxgain + * off` gate CSD instead in 1.17.2, shutting the FEM down and taking + * the receive path with it (~69 dB, measured). */ + /* + * ESP32_GPIO_SLEEP_HOLD_EN on both: light sleep powers down + * RTC_PERIPH (see boards/common/pm_esp32.overlay for the full + * chain), and GPIO2/GPIO5 are RTC-capable pads (0-21), so their + * state is not retained across a sleep unless explicitly held. + * These two lines are the FEM's entire control surface — CSD low + * disables the PA and the LNA with it, CTX high bypasses the LNA — + * so losing them means transmitting through a bypassed PA, tens of + * dB down, with RX sensitivity gone too, while the node otherwise + * looks healthy and keeps relaying. + * The flag is ignored when CONFIG_PM=n, so this is inert on + * builds that never light-sleep. + */ + antenna-enable-gpios = <&gpio0 2 (GPIO_ACTIVE_HIGH | + ESP32_GPIO_SLEEP_HOLD_EN)>; /* KCT8103L CSD = GPIO2 */ + lna-bypass-gpios = <&gpio0 5 (GPIO_ACTIVE_HIGH | + ESP32_GPIO_SLEEP_HOLD_EN)>; /* KCT8103L CTX = GPIO5 */ + + /* DIO2 drives the SX1262 RF switch output, which on a KCT8103L + * board is the part's CPS pin — the TX/RX path select. MeshCore's + * heltec_v4 target defines CSD=2 and CTX=5 for the KCT8103L, no + * CPS GPIO, and SX126X_DIO2_AS_RF_SWITCH=true; same on t096 and + * tracker_v2. Note the V4.2 GC1109 inverts these two pin names — + * there CPS is GPIO46 (an MCU pin, see heltec_wifi_lora32_v4) and + * DIO2 drives the pin that part calls CTX. On the KCT8103L, DIO2 + * doing the path select is what leaves CTX free to mean nothing + * but LNA-or-bypass. */ + dio2-tx-enable; + + /* TCXO 1.8V via DIO3 — same as V4.2 */ + dio3-tcxo-voltage = ; + tcxo-power-startup-delay-ms = <5>; + + spi-max-frequency = <4000000>; + }; +}; + +&timer0 { status = "okay"; }; +&timer1 { status = "okay"; }; +&timer2 { status = "okay"; }; +&timer3 { status = "okay"; }; + +&wdt0 { status = "okay"; }; +&trng0 { status = "okay"; }; + +&esp32_bt_hci { status = "okay"; }; +&wifi { status = "okay"; }; diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8_procpu_defconfig b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8_procpu_defconfig new file mode 100644 index 000000000..aa3683bc3 --- /dev/null +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8_procpu_defconfig @@ -0,0 +1,12 @@ +# Heltec WiFi LoRa 32 V4-R8 — minimal Zephyr defconfig (procpu) +CONFIG_CONSOLE=y +CONFIG_SERIAL=y +CONFIG_UART_CONSOLE=y +CONFIG_GPIO=y +CONFIG_CLOCK_CONTROL=y +CONFIG_PINCTRL=y +CONFIG_SPI=y +CONFIG_I2C=y +CONFIG_FLASH=y +CONFIG_FLASH_MAP=y +CONFIG_NVS=y diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/partitions.overlay b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/partitions.overlay new file mode 100644 index 000000000..5a300ab63 --- /dev/null +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/partitions.overlay @@ -0,0 +1,53 @@ +/* + * Flash partition layout — SHARED by the app image and MCUboot. + * + * Fed to BOTH images (app via EXTRA_DTC_OVERLAY_FILE, MCUboot via + * mcuboot_EXTRA_DTC_OVERLAY_FILE in sysbuild/CMakeLists.txt) so their flash maps + * are identical — otherwise MCUboot stages/looks for the OTA image at a different + * address than the app writes it to and OTA silently reverts on reboot. + * + * slot0 starts at 0x10000 (right after the 64KB MCUboot region), matching the + * Arduino/ESP-IDF app offset (Mesh America "flash-update" compatible). sys_partition + * is reclaimed. Slots are equal size and slot1 ends exactly on the unchanged + * storage/lfs offsets, so a -merged reflash keeps identity/prefs/contacts/BLE-bonds. + * + * 16MB layout. + */ +/delete-node/ &sys_partition; +/delete-node/ &slot0_lpcore_partition; +/delete-node/ &slot1_lpcore_partition; +/delete-node/ &storage_partition; +/delete-node/ &scratch_partition; +/delete-node/ &coredump_partition; +/delete-node/ &slot0_partition; +/delete-node/ &slot1_partition; + +/ { + chosen { + zephyr,settings-partition = &storage_partition; + }; +}; + +&flash0 { + partitions { + /* App slots — equal size (swap-mode compatible), slot0 at 0x10000. */ + slot0_partition: partition@10000 { + label = "image-0"; + reg = <0x10000 0x7C6000>; + }; + slot1_partition: partition@7D6000 { + label = "image-1"; + reg = <0x7D6000 0x7C6000>; + }; + /* BLE bond NVS — offset unchanged so bonds survive the reflash. */ + storage_partition: partition@F9C000 { + label = "storage"; + reg = <0xF9C000 0x4000>; + }; + /* LittleFS (identity/prefs/contacts) — offset unchanged. */ + lfs_partition: partition@FA0000 { + label = "lfs"; + reg = <0xFA0000 0x60000>; + }; + }; +}; From aecbc421fba99d8d18a121fba8ec03267400fead Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 17 Sep 2026 18:40:33 -0700 Subject: [PATCH 14/78] Companion CLI: mark a truncated reply instead of cutting it silently The CLI reply buffer is COMPANION_CLI_REPLY_SIZE (256) but a CMD_RUN_CLI_COMMAND response frame carries MAX_FRAME_SIZE-1 (175), and the app protocol has no continuation for it. A reply between those two sizes therefore arrived missing its tail, with nothing to say so. Found on hardware: `hw board` over the companion app path ended mid-word at "(none reported" and its last line was simply absent. That reads as a complete answer and is indistinguishable from one -- which is the failure this is worth fixing for, since a reader has no way to tell a short reply from a cut one. Pre-existing and not specific to `hw`: any command whose output exceeds 175 bytes is affected, the stats JSON among them. The frame limit itself is not changed -- that is the app protocol's, not this function's. The last three bytes of a cut reply are now "..." so the truncation is visible. Paging still cannot help here, because a command sizes its output to CLI_REPLY_SIZE and cannot see which transport will carry it: on companion firmware the USB sideband, the app's CMD_RUN_CLI_COMMAND and the V-Contact loopback are indistinguishable at that layer. Co-Authored-By: Claude Opus 5 (1M context) --- zephcore/app/CompanionMesh.cpp | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index de19d91eb..8b1edb777 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -3791,11 +3791,25 @@ bool CompanionMesh::handleProtocolFrame(const uint8_t *data, size_t len) strcpy(reply, "Unknown command"); } - /* A CommonCLI reply can be longer than one companion frame and the - * app protocol has no continuation for this response, so truncate - * rather than drop. */ + /* A CommonCLI reply can be longer than one companion frame and + * the app protocol has no continuation for this response, so + * truncate rather than drop -- but SAY SO. + * + * The CLI buffer is COMPANION_CLI_REPLY_SIZE (256) while a frame + * carries MAX_FRAME_SIZE-1 (175), so a reply between those two + * sizes used to arrive silently missing its tail. That reads as a + * complete answer and is indistinguishable from one: `hw board` + * ended mid-word at "(none reported" with its last line simply + * absent. A reader cannot tell a short answer from a cut one, so + * the cut is now marked. */ size_t rlen = strlen(reply); - if (rlen > MAX_FRAME_SIZE - 1) rlen = MAX_FRAME_SIZE - 1; + if (rlen > MAX_FRAME_SIZE - 1) { + const char kCut[] = "..."; + const size_t kCutLen = sizeof(kCut) - 1; + + rlen = MAX_FRAME_SIZE - 1; + memcpy(reply + rlen - kCutLen, kCut, kCutLen); + } writeFrame(rsp, rlen + 1); } else { sendPacketError(ERR_ILLEGAL_ARG); From 96bbb832dc6abe97d579842dfda4a9720158d63d Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 17 Sep 2026 18:56:09 -0700 Subject: [PATCH 15/78] Review round 7: bound the resume marker, validate two snprintfs, fix board identity Four real findings, three of them in code whose entire purpose is bounded output -- which is where an unchecked write is least excusable. 1. sink_finish() wrote up to HW_NEXT_RESERVE bytes on the assumption that sink_init() had held that much back. It has not when a caller passes cap <= HW_NEXT_RESERVE: sink_init() clamps cap to 1 there and no reserve exists, so the marker could run past the caller's buffer. The sink now remembers the real buffer size and bounds the write by it. No current caller is that small (161 and 256), which is what kept it latent. 2. section_i2c_scan() used the result of snprintf() without checking it, in two places. A bus name long enough to fill the line buffer would make every subsequent `line + used` and `line[used]` out of bounds. Both sites are checked now; the seeding one reports the bus as unscannable rather than proceeding. 3. The I2C probe passed a NULL buffer with length 0. i2c_write() assigns buf straight into i2c_msg.buf and some controller drivers assert on NULL before they look at the length, so the probe now points at a real byte. Behaviour is unchanged on the two drivers tested (nrfx twim, esp32). 4. The V4-R8 devicetree still identified itself as "Heltec WiFi LoRa 32 V4.3 PROCPU" with compatible "heltec,wifi-lora32-v4_3", inherited from the board it was copied from. That surfaces in runtime identification, which is precisely what this PR's command reports, so a board lying about its own model is worse here than usual. Also emits "reset not supported" in the summary rather than dropping the line, since the docs pin the summary's field list and a line that vanishes on some platforms makes the schema board-dependent. Co-Authored-By: Claude Opus 5 (1M context) --- .../heltec_wifi_lora32_v4_r8_procpu.dts | 4 +- zephcore/helpers/HardwareReport.cpp | 45 +++++++++++++++++-- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8_procpu.dts b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8_procpu.dts index 1e835ea58..68a53e759 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8_procpu.dts +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8_procpu.dts @@ -44,8 +44,8 @@ #include / { - model = "Heltec WiFi LoRa 32 V4.3 PROCPU"; - compatible = "heltec,wifi-lora32-v4_3"; + model = "Heltec WiFi LoRa 32 V4-R8 PROCPU"; + compatible = "heltec,wifi-lora32-v4-r8"; aliases { led0 = &led0; diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index 20de40cf0..71bf33682 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -54,6 +54,7 @@ namespace { */ 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 */ @@ -65,6 +66,9 @@ 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->len = 0; s->first = first; @@ -112,8 +116,21 @@ void sink_finish(Sink *s) if (!s->full) { return; } - /* cap excluded HW_NEXT_RESERVE, so this always fits. */ - snprintf(s->buf + s->len, HW_NEXT_RESERVE, " 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 ================= @@ -364,12 +381,26 @@ void section_i2c_scan(Sink *s) 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, nullptr, 0, addr) != 0) { + if (i2c_write(bus, &probe, 0, addr) != 0) { continue; } found++; @@ -385,6 +416,9 @@ void section_i2c_scan(Sink *s) 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; + } w = snprintf(line + used, sizeof(line) - used, nm ? " 0x%02x(%s)" : " 0x%02x", addr, nm); if (w > 0 && (size_t)(used + w) < sizeof(line)) { @@ -504,11 +538,16 @@ void section_summary(Sink *s, mesh::MainBoard *board, CommonCLICallbacks *cb) 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)) { char causes[96]; zephcore_boot_reset_cause_str(causes, sizeof(causes)); sink_line(s, "reset%s", causes[0] ? causes : " none"); + } else { + sink_line(s, "reset not supported"); } (void)board; } From a897450eb0e4cb52c8cccc6a0c061d3f8bb034bd Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 17 Sep 2026 19:06:42 -0700 Subject: [PATCH 16/78] Review round 8: fix stale board identity in comments, and one I broke myself Three suppressed findings, all real, all mine. Two are leftovers from copying the V4.3 board: board.overlay and the pinctrl header still announced themselves as "Heltec WiFi LoRa 32 V4.3". Misleading breadcrumbs in exactly the files someone opens to cross-check a pin move. The third is a docs/code divergence I introduced in 1820b90. That commit changed the summary's first line from CONFIG_BOARD_TARGET to CONFIG_BOARD to stop it printing the SoC twice, and I did not update the docs, which still promised the full board target. `hw board` does report the target, so the two were describing different things under the same name. Also corrects a comment I corrupted myself. The Vext block was copied from the V4.3 board and I updated it with a blunt GPIO36 -> GPIO40 substitution, which left it claiming the V4 rail measurement had been taken on GPIO40 and that this board constructs its display with NULL periph_power. Neither is true: the measurement was on GPIO36, and it is the plain V4 that passes NULL while the R8 passes &board.periph_power and therefore actually asserts the pin. The rewritten comment states what is true for this revision, including the part that matters for anyone checking polarity later: MeshCore's R8 variant declares PIN_VEXT_EN_ACTIVE=LOW and asserts it, which is independent agreement that low enables this rail. Confirmed here -- the SSD1306 acknowledges at 0x3c, which needs the rail powered. Co-Authored-By: Claude Opus 5 (1M context) --- docs/Repeater_CLI_commands.md | 2 +- .../heltec_wifi_lora32_v4_r8/board.overlay | 2 +- .../heltec_wifi_lora32_v4_r8-pinctrl.dtsi | 6 ++++-- .../heltec_wifi_lora32_v4_r8_procpu.dts | 21 +++++++++++++------ 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index 90bcc7f93..0c58d8709 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -44,7 +44,7 @@ it is available on **all four roles** — companion, repeater, room server and o | Command | Description | |---------|-------------| -| `hw` | Summary. Emits exactly these fields, one per line: board target and SoC, `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 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 | diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/board.overlay b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/board.overlay index 973a79b0b..9e4104db0 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/board.overlay +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/board.overlay @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: MIT - * Heltec WiFi LoRa 32 V4.3 — ZephCore overlay + * Heltec WiFi LoRa 32 V4-R8 — ZephCore overlay * * The DTS already defines: SX1262 (lora0), SSD1306 OLED, button, LED, PWM, * vext_enable, fem_power, I2C0, SPI2, BLE, WiFi. diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8-pinctrl.dtsi b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8-pinctrl.dtsi index e670ed265..d9bda5d40 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8-pinctrl.dtsi +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8-pinctrl.dtsi @@ -1,8 +1,10 @@ /* * SPDX-License-Identifier: MIT - * Heltec WiFi LoRa 32 V4.3 — Pin Control Definitions + * Heltec WiFi LoRa 32 V4-R8 — Pin Control Definitions * - * Pin assignments identical to V4.2 (GPIO5/CTX is plain GPIO, no pinctrl entry needed): + * Pinctrl assignments identical to V4.2/V4.3 (GPIO5/CTX is plain GPIO, no pinctrl + * entry needed). The R8's pin moves -- Vext, LED, VGNSS_Ctrl -- are all plain + * GPIO and live in the DTS and overlay, not here: * UART0: TX=GPIO43, RX=GPIO44 * I2C0: SDA=GPIO17, SCL=GPIO18 (SSD1306 OLED + sensors) * SPI2: SCLK=GPIO9, MOSI=GPIO10, MISO=GPIO11 (SX1262 LoRa) diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8_procpu.dts b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8_procpu.dts index 68a53e759..709e3d8d8 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8_procpu.dts +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v4_r8/heltec_wifi_lora32_v4_r8_procpu.dts @@ -89,12 +89,21 @@ /* Vext rail switch — PMOS, ACTIVE LOW (GPIO40 low = rail ON). * Powers the OLED (incl. its I2C pull-ups) and the expansion header. - * Same rail circuit as V4.2 (only the PA differs on V4.3); verified on V4 - * hardware that GPIO40 high leaves the rail floating (~1.6 V) and the OLED - * unpowered, while low gives a clean 3.3 V. Confirmed against Arduino - * MeshCore: RefCountedDigitalPin::begin() drives PIN_VEXT_EN to !active - * (LOW) and the V4 OLED build constructs the display with NULL periph_power, - * so GPIO40 is held LOW continuously there too. + * + * Same rail circuit as V4.2/V4.3, moved from GPIO36 to GPIO40 by the + * octal-PSRAM pin reshuffle (GPIO33-37 are the PSRAM pads on an S3R8). + * + * Arduino MeshCore drives this revision the same way, and unlike the plain + * V4 it actually asserts the pin: variants/heltec_v4_r8 sets PIN_VEXT_EN=40 + * with PIN_VEXT_EN_ACTIVE=LOW, and its target.cpp constructs the display + * with &board.periph_power, so RefCountedDigitalPin::claim() drives the pin + * to the active level. (The plain V4 passes NULL there, so its pin only ever + * sits at the !active level begin() set -- which is why its ACTIVE=HIGH + * constant never shows up as a difference.) + * + * Confirmed on hardware here: with this regulator holding GPIO40 low, the + * SSD1306 acknowledges at 0x3c, which it cannot do on an unpowered rail. + * * regulator-boot-on + always-on: the rail comes up at regulator init, * well before the SSD1306 driver init. */ vext_enable: vext-enable { From 50aa8b6f5200fc42f402ec1b31567a9b5c35b90b Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 17 Sep 2026 19:15:33 -0700 Subject: [PATCH 17/78] Review round 9: the summary was claiming a fact discovery never established Three suppressed findings, all real. The one that matters: `hw`'s summary could print "rtc none present" when discovery had not run at all. zephcore_rtc_active() returns false both for "probed, found nothing" and "not probed yet", and the summary collapsed them. `hw rtc` already keeps the two apart via zephcore_rtc_probed(), so the summary was stating something the detailed section refuses to state -- and a summary has no licence to be looser than what it summarises. It now reports "rtc not yet probed" for that case. That is precisely the failure this whole module is written to avoid, sitting in its own summary, which is worth saying plainly rather than filing under housekeeping. The other two are a matched pair, and only make sense fixed together. The resume marker formats into HW_NEXT_RESERVE, leaving five digits, while the parser clamped incoming indices to 9999. So an emitted index above 9999 would have been accepted back only after being silently rewritten -- paging the same lines forever. Both sides now use one HW_PAGE_INDEX_MAX, and the emitter clamps rather than letting snprintf truncate the digits, since a cut number is a plausible-looking index pointing somewhere else. No section emits anywhere near that many lines, so this is about the pair being provably consistent rather than a reachable bug. Co-Authored-By: Claude Opus 5 (1M context) --- zephcore/helpers/HardwareReport.cpp | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index 71bf33682..c7227863c 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -44,6 +44,17 @@ namespace { * 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. + * + * " next:" plus a NUL leaves 5 digits inside HW_NEXT_RESERVE, so 99999 is the + * format's ceiling; the parser's own clamp has to match whatever the emitter + * can produce, or a page could advertise an index that feeding it back + * silently rewrites -- paging the same lines forever. Both sides use this + * constant so they cannot drift apart. + */ +#define HW_PAGE_INDEX_MAX 9999U + /* * Paged line sink. * @@ -98,7 +109,11 @@ void sink_line(Sink *s, const char *fmt, ...) size_t need = strlen(line) + (s->len ? 1 : 0); if (s->len + need >= s->cap) { s->full = true; - s->next = idx; + /* 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; } @@ -526,6 +541,13 @@ void section_summary(Sink *s, mesh::MainBoard *board, CommonCLICallbacks *cb) 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"); } @@ -606,7 +628,7 @@ bool parse_tail(const char *rest, unsigned *start) return false; } - *start = (v > 9999UL) ? 9999U : (unsigned)v; + *start = (v > HW_PAGE_INDEX_MAX) ? HW_PAGE_INDEX_MAX : (unsigned)v; return true; } From 7f93422aaa3976191c70791299496b4b1c05a404 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 17 Sep 2026 19:21:32 -0700 Subject: [PATCH 18/78] Review round 10: the docs contradicted themselves about scan coverage The command table promised `hw i2c scan` covered "every enabled bus" while a paragraph in the same section correctly explained that buses are found by walking declared devices, so an enabled bus with no declared device is never probed. Both sentences are mine and they cannot both be true. For a command whose entire value is not claiming more than is known, docs that overstate its coverage are the same defect as code that does -- a reader trusting the table would conclude an address was absent from the bus when the bus was never probed at all. Rewritten to state what each half actually covers: `hw i2c` lists every declared device on any bus, `hw i2c scan` probes each bus carrying at least one declared device. The nodelabel-independence point is kept, since that was the substance of the round 2 fix, but separated from the coverage claim it was being conflated with. The remaining gap is named outright, including the case that would actually hit it -- a bus enabled purely for an undeclared or hot-plugged chip. Co-Authored-By: Claude Opus 5 (1M context) --- docs/Repeater_CLI_commands.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index 0c58d8709..404ac8ddd 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -48,7 +48,7 @@ it is available on **all four roles** — companion, repeater, room server and o | `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 every enabled bus, naming declared addresses | +| `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 | @@ -74,10 +74,19 @@ This is deliberate and the replies say which is which. 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. -Both cover **every** I2C bus the board enables, whatever its devicetree nodelabel — the tree -has boards on `i2c22` and `i2c30`, not just `i2c0`–`i2c2`. One gap is worth knowing: an enabled -bus with **no declared device at all** is not scanned, because the buses are discovered by -walking declared devices. In practice a bus is enabled because something is on it. +**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 From f1130d41d7a7be3a65b4ab91375190ee916a46b0 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 17 Sep 2026 19:29:35 -0700 Subject: [PATCH 19/78] Review round 11: make the page-index ceiling match the comment explaining it HW_PAGE_INDEX_MAX was 9999 under a comment that spends four lines establishing the format's ceiling is 99999. The comment was right and the constant was wrong: " next:" is 6 characters, so HW_NEXT_RESERVE (12) holds 5 digits plus the NUL -- " next:99999" is exactly 11 characters. I introduced this in the round 9 fix. That change unified the emitter and parser onto one constant, which was the point, but I took the value from the parser's existing 9999 clamp while writing a comment that justified 99999. The result reads as a deliberate limit with a stated rationale that does not support it, which is worse than either value chosen honestly -- anyone reading it would have to work out which half to trust. Now 99999, matching the reserve, and the comment derives the number from the format rather than asserting a ceiling it then ignores. Co-Authored-By: Claude Opus 5 (1M context) --- zephcore/helpers/HardwareReport.cpp | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index c7227863c..17517e10a 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -47,13 +47,14 @@ namespace { /* * Largest page index the marker can carry and the parser will accept. * - * " next:" plus a NUL leaves 5 digits inside HW_NEXT_RESERVE, so 99999 is the - * format's ceiling; the parser's own clamp has to match whatever the emitter - * can produce, or a page could advertise an index that feeding it back - * silently rewrites -- paging the same lines forever. Both sides use this - * constant so they cannot drift apart. + * 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 9999U +#define HW_PAGE_INDEX_MAX 99999U /* * Paged line sink. From 22b0f38c988a8c98aa20e00ba4f44cda1ea9bdc7 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 17 Sep 2026 19:39:17 -0700 Subject: [PATCH 20/78] Review round 12: the scan could drop a present chip, under a comment saying it must not section_i2c_scan() formatted each address straight into the line, retried once on a fresh line if it did not fit, and then did nothing if the retry also failed: if (w > 0 && (size_t)(used + w) < sizeof(line)) { used += w; } /* no else -- address silently dropped */ found++ had already counted it, so the reported total and the listed addresses disagreed and a physically present chip vanished from the output. Three lines above that branch is a comment of mine stating that dropping an address is the one thing a bus scan must never do. I wrote both. Restructured so the guarantee is a property of the shape rather than of remembering to handle a third case. The token is rendered into its own buffer first, so the wrap decision is made against a known length and an address cannot fall between two branches, and every path ends with the address emitted: - annotation too long for the token buffer -> keep the address, lose the name - token does not fit the current line -> flush and restart the line - token does not fit even a fresh line (needs a bus name nearly as long as the buffer) -> give the address a line of its own Not reachable on any board in this tree: the longest real case is " 0x52(zephcore,rtc-i2c)" at 23 characters into a 128-byte line. That is why it survived twelve rounds and hardware testing on two boards -- the dropping branch is only entered by inputs no current devicetree produces. Verified on hardware after the change: the scan still reports 0x52(zephcore,rtc-i2c) on the RAK4631, unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- zephcore/helpers/HardwareReport.cpp | 52 +++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index 17517e10a..0f695717e 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -420,28 +420,52 @@ void section_i2c_scan(Sink *s) continue; } found++; + const char *nm = declared_name_at(bus->name, addr); - int w = snprintf(line + used, sizeof(line) - used, - nm ? " 0x%02x(%s)" : " 0x%02x", addr, nm); - if (w < 0 || (size_t)(used + w) >= sizeof(line)) { - /* This address did not fit. Flush the line so far, - * start a continuation, and re-render the address - * into it. Dropping it would silently omit a chip - * that is physically present, which is the one - * thing a bus scan must never do. */ + + /* 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; } - w = snprintf(line + used, sizeof(line) - used, - nm ? " 0x%02x(%s)" : " 0x%02x", addr, nm); - if (w > 0 && (size_t)(used + w) < sizeof(line)) { - used += w; - } + } + + if ((size_t)used + toklen < sizeof(line)) { + memcpy(line + used, tok, toklen + 1); + used += (int)toklen; } else { - used += w; + /* 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); } } From fe9b346067881e2bf650837e1715499d5084a591 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 17 Sep 2026 20:20:21 -0700 Subject: [PATCH 21/78] Restore the original line endings my edits had silently rewritten Two files I modified came back with their line endings normalised, which turned small changes into whole-file rewrites: rak4631/board.overlay 218 CRLF lines -> LF (real change: 18 lines) docs/Repeater_CLI_commands.md 3 stray CRLF rows -> LF (real change: 81 lines) Harmless on this branch and fatal to a contribution: the RAK4631 overlay diff read as 236 insertions and 218 deletions for what is an 18-line addition, which nobody can review and no upstream maintainer should be asked to. Both files now carry exactly the endings upstream/dev has -- the overlay CRLF throughout, the docs file LF apart from the same three `get cad.*` table rows that were already CRLF -- so both diffs are pure additions with zero deletions. Found while mapping commits for the cherry-pick branches, not by the review bot, which reads the diff it is given and has no reason to question its size. Worth checking before any branch goes upstream: `git show -w --stat` against the plain `--stat` shows the gap immediately. Note `file(1)` reports "with CRLF line terminators" on a file with three stray CRLF lines out of 404, so it is the wrong instrument here. Counting b'\r\n' against b'\n' is not. Co-Authored-By: Claude Opus 5 (1M context) --- docs/Repeater_CLI_commands.md | 6 +- .../boards/nrf52840/rak4631/board.overlay | 472 +++++++++--------- 2 files changed, 239 insertions(+), 239 deletions(-) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index 404ac8ddd..3eaaebc24 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -364,9 +364,9 @@ All `set uplink.*` changes are saved immediately and only applied after reboot. | `get probe.interval` | Seconds between periodic radio measurements (noise-floor sample + CAD probe). 0 = CAD probing off | | `get dc.restarts` | Duty-cycle re-arm counter — RxTimeout re-arms **plus** parked-RX watchdog recoveries, sharing one total. **Read it as a rate: divide by uptime.** A bare count is not interpretable, and the two sources it merges cost very differently. An RxTimeout re-arm is ~7 ms of deaf time (the `Calibrate(ALL)` gap in the driver's `restart_rx`) after which the chip returns to duty cycle immediately — packets, not power. A watchdog recovery means the chip sat parked in *full RX* for one to two watchdog periods (`2·(preamble+8)` symbols, floored at 250 ms) — power, not packets, since parked RX still receives. The counter cannot tell you which, so read the worst case. **Measured normal: ~250/hr on a high site at SF8/BW 62.5** (one every ~14 s), where the worst case — every event a park — costs about 3.5% of the duty cycle's savings. Nothing to act on below roughly **2000/hr**; above that the parked-RX share starts eating a meaningful fraction of the saving and it becomes worth splitting the counter to find out. A high rate means the preamble detector is tripping without a decodable packet following, which on an elevated site is usually distant marginal traffic rather than interference — cross-check `get cad.stats`, whose adaptive detPeak offset rises independently in a genuinely busy RF environment. Reset by `clear stats`. | | `get cad` | Always `on` — ZephCore performs CAD/LBT unconditionally and has no enable knob. Kept as a boolean reply for Arduino MeshCore app compatibility; the real status lives in `get cad.stats`. | -| `get cad.auto` | Whether the adaptive-CAD staircase is acting on probe statistics (`on`/`off`). Set with `set cad.auto`. | -| `get cad.offset` | Operating detPeak offset from the chip family's per-SF/per-bandwidth base, signed. Also shown as `o` in `get cad.stats`, alongside the absolute peak it resolves to. | -| `get cad.busycap` | Faint-tolerance / airtime cap in percent, or `0 (off)`. Also shown as `bc` in `get cad.stats`. | +| `get cad.auto` | Whether the adaptive-CAD staircase is acting on probe statistics (`on`/`off`). Set with `set cad.auto`. | +| `get cad.offset` | Operating detPeak offset from the chip family's per-SF/per-bandwidth base, signed. Also shown as `o` in `get cad.stats`, alongside the absolute peak it resolves to. | +| `get cad.busycap` | Faint-tolerance / airtime cap in percent, or `0 (off)`. Also shown as `bc` in `get cad.stats`. | | `get cad.stats` | Adaptive-CAD status: header (`a` auto on/off, `o` operating detPeak offset, `pk` absolute peak with family base, `sp` noise-floor RSSI burst quality as `mean-spread-dB/zero-spread-%` (plus `(burst-count rN/bN/aN/dN)` on the local USB console, omitted over the air to protect the 161 B reply budget, where `r` is completed RSSI reads, `b` reads the chip refused as busy, `a` bursts abandoned because of one, and `d` attempts turned away before a burst even started by the duty-cycle sleep window — on a healthy radio `b`/`a` stay at 0, and a large `a` against a near-zero burst count is the signature of a sampler being refused rather than one losing the odd read) — a non-zero mean proves the 8 reads are independent however high the share climbs; only mean `0.0` with a high share indicts the sampler. See `ADAPTIVE_CAD.md`. `bc` busy cap), then a 3-rung window around the operating offset (`*` marks it) with probe/busy/fp/tp counts and false-positive rate — the three levels the knee controller reads. Probing runs even while `cad.auto` is off (dry-run), so this is the observation tool for picking a site-appropriate detPeak. See `ADAPTIVE_CAD.md`. Not available on SX127x boards (no hardware CAD). | | `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 | diff --git a/zephcore/boards/nrf52840/rak4631/board.overlay b/zephcore/boards/nrf52840/rak4631/board.overlay index 822a2a9a6..4cc696d8b 100644 --- a/zephcore/boards/nrf52840/rak4631/board.overlay +++ b/zephcore/boards/nrf52840/rak4631/board.overlay @@ -1,236 +1,236 @@ -/* - * RAK4631 overlay for SoftDevice + UF2 bootloader. - * App at 0x26000, storage at 0xD4000, bootloader at 0xF4000. - * Uses SoftDevice s140 v6.1.1 (ends at 0x26000). - * Console: USB CDC ACM. - * Battery: AIN3 = P0.05, voltage divider per RAK4631 schematic. - * - * QSPI Flash: Optional external flash on WisBlock Base boards. - * Supports: P25Q16H, MX25R1635F, W25Q16, GD25Q16, IS25LP080, ZD25WQ16 - * If no QSPI flash present, falls back to internal flash only. - * - * GPS: RAK1910 (u-blox MAX-7Q) on UART1 at 9600 baud (Slot A) - * Sensors: Auto-detected on I2C0 - supports: - * - RAK1901 (SHTC3) at 0x70 - * - RAK1902 (LPS22HB) at 0x5C - * - RAK1906 (BME680) at 0x76 - * - * Optional SSD1306 128x64 @ 0x3C on I2C0 (P0.13/P0.14, no reset GPIO). - * If the panel is absent, display init may fail - * at runtime; UI continues without OLED. - */ - -#include -#include - -/delete-node/ &boot_partition; -/delete-node/ &slot0_partition; -/delete-node/ &slot1_partition; -/delete-node/ &storage_partition; - -/ { - aliases { - /* leds: green=heartbeat, blue=unread + LoRa TX; lora-tx-led=blue */ - led0 = &green_led; - led1 = &blue_led; - lora-tx-led = &blue_led; - sw0 = &user_button; - }; - chosen { - zephyr,code-partition = &code_partition; - zephyr,console = &cdc_acm_uart; - zephyr,shell-uart = &cdc_acm_uart; - zephyr,display = &ssd1306; - }; - - /* User button: P0.09 (NFC1). P0.00/P0.01 are 32k crystal — do not use for GPIO. */ - buttons: buttons { - compatible = "gpio-keys"; - - user_button: button_0 { - gpios = <&gpio0 9 (GPIO_PULL_UP | GPIO_ACTIVE_LOW)>; - zephyr,code = ; - label = "User Button"; - }; - }; - - /* longpress: short=KEY_A (multitap), long=KEY_ENTER */ - user_btn_longpress { - compatible = "zephyr,input-longpress"; - input = <&buttons>; - input-codes = ; - short-codes = ; - long-codes = ; - long-delay-ms = <1000>; - }; - - page_btn_multitap { - compatible = "zephcore,input-multi-tap"; - input-codes = ; - tap-codes = ; - tap-delay-ms = <400>; - }; -}; - -/* Battery ADC - AIN3 on P0.05 with voltage divider */ -/ { - zephyr,user { - io-channels = <&adc 3>; - vbat-mv-multiplier = <6161>; /* matches RAK4631 divider + SAADC ref */ - }; -}; - -&adc { - #address-cells = <1>; - #size-cells = <0>; - channel@3 { - reg = <3>; - zephyr,gain = "ADC_GAIN_1_6"; - zephyr,reference = "ADC_REF_INTERNAL"; - zephyr,acquisition-time = ; - zephyr,input-positive = ; - zephyr,resolution = <12>; - }; -}; - -&zephyr_udc0 { - cdc_acm_uart: cdc_acm_uart { - compatible = "zephyr,cdc-acm-uart"; - }; -}; - -&lora { - dio1-gpios = <&gpio1 15 (GPIO_PULL_DOWN | GPIO_ACTIVE_HIGH)>; - rx-boosted; - - /* Upstream DTS labels P1.05 as rx-enable-gpios (per-direction - * antenna control), but on RAK4631 this pin is the SX1262 module - * power-enable (HIGH = on; see MeshCore variants/rak4631). The - * driver toggles per-direction GPIOs each TX/RX, which would - * pulse this pin LOW during RX. Stop the driver from touching it - * by deleting the property; the gpio-hog below pins it HIGH. */ - /delete-property/ rx-enable-gpios; -}; - -&gpio1 { - sx1262_power_en { - gpio-hog; - gpios = <5 GPIO_ACTIVE_HIGH>; - output-high; - line-name = "sx1262-power-en"; - }; - - /* 3V3_S rail enable — WB_IO2 / P1.02, HIGH = on. This is the switched - * 3.3V bus on every WisBlock base board: it feeds the GPS slot, the - * sensor slot and the OLED. Without this the pin sits at its reset - * default (input/float) and the GPS module is simply never powered. - * - * Hogged rather than exposed as `chosen zephcore,gps-power`: the rail is - * not GPS-private, so duty-cycling it for the GPS would also drop the - * I2C sensors and the display. - * - * Note this pin is often described as "GPS reset" (it lands on the - * RAK1910/RAK12500 RESET_N in Slot A). Do not drive it as a reset — - * u-blox specify RESET_N as a reset only, not an enable (the SiP draws - * significant current while held in reset). See MeshCore PR #3051. */ - v3v3s_en { - gpio-hog; - gpios = <2 GPIO_ACTIVE_HIGH>; - output-high; - line-name = "3v3s-enable"; - }; -}; - -/* LEDs are active-high on this board (stock RAK4631 DTS used active-low). */ -&green_led { - gpios = <&gpio1 3 GPIO_ACTIVE_HIGH>; -}; - -&blue_led { - gpios = <&gpio1 4 GPIO_ACTIVE_HIGH>; -}; - -/* - * GPS on UART1 - RAK1910 (u-blox MAX-7Q) at 9600 baud, Slot A. - * - * The WisBlock GPS slot is wired to the core module's Serial1 = P0.15 (MCU RX - * <- GPS TX) / P0.16 (MCU TX -> GPS RX), which is Zephyr's uart1 on this board - * (uart0 is P0.19/P0.20 = Serial2, which the GPS slot does not reach). Matches - * MeshCore variants/rak4631 (PIN_SERIAL1_RX 15 / _TX 16) and ZephCore's sibling - * RAK-family boards gat562_30s and rak3401_1watt. uart1 is also nrf-uarte - * upstream, whereas uart0 is the legacy nrf-uart. - */ -&uart1 { - current-speed = <9600>; - gnss: gnss-nmea-generic { - compatible = "gnss-nmea-generic"; - }; -}; - -/* uart0 (P0.19/P0.20) is unused — console is USB CDC ACM and the GPS is on - * uart1. Upstream leaves it enabled at 115200; turn it off so it does not - * hold pins or a UARTE instance. */ -&uart0 { - status = "disabled"; -}; - -/* I2C0: optional SSD1306 + sensors */ -&i2c0 { - ssd1306: ssd1306@3c { - compatible = "solomon,ssd1306"; - reg = <0x3c>; - width = <128>; - height = <64>; - segment-offset = <0>; - page-offset = <0>; - display-offset = <0>; - multiplex-ratio = <63>; - segment-remap; - com-invdir; - inversion-on; - prechargep = <0x22>; - }; - - #include "../../common/sensors-i2c.dtsi" - - /* - * WisBlock slots can carry a RAK12002 (RV3028 at 0x52), so opt in to - * boot-time time restore. The descriptors are data-only and probed by - * raw I2C, so a board with no RTC module fitted simply fails the probe - * and is skipped -- nothing is bound and nothing is reported present. - * - * Safe on this board specifically: the RTC addresses (0x68/0x51/0x52/ - * 0x32) do not collide with anything declared here. The WisBlock sensor - * slots use 0x38/0x40/0x44/0x5c/0x70/0x76/0x77 and the OLED is 0x3c. - * - * 0x68 is shared with MPU-class IMUs, including the RAK1905. That is - * not a devicetree clash (nothing declares 0x68 here), and the - * discovery probe rejects a chip whose time registers are not valid - * BCD, which is exactly the case that check exists for -- an IMU is - * never adopted as a write-back target. - */ - #include "../../common/rtc-i2c.dtsi" -}; - -/* RAK4631 has BME680, not BME280 — override the common DTSI default */ -/delete-node/ &bme280; -&i2c0 { - bme680: bme680@76 { - compatible = "bosch,bme680"; - reg = <0x76>; - }; -}; - -/* - * QSPI Flash — disabled. - * RAK19003 Mini Base Board has no QSPI flash. The upstream DTS enables &qspi, - * which hangs at boot if no chip is present. Disable it explicitly. - * For base boards with QSPI (RAK19007, RAK5005-O), create a separate variant. - */ -&qspi { - status = "disabled"; -}; - -/* SoftDevice v6 flash layout */ -#include "../../common/nrf52_partitions_sdv6.dtsi" -#include "../../common/nrf52_wakeup.dtsi" +/* + * RAK4631 overlay for SoftDevice + UF2 bootloader. + * App at 0x26000, storage at 0xD4000, bootloader at 0xF4000. + * Uses SoftDevice s140 v6.1.1 (ends at 0x26000). + * Console: USB CDC ACM. + * Battery: AIN3 = P0.05, voltage divider per RAK4631 schematic. + * + * QSPI Flash: Optional external flash on WisBlock Base boards. + * Supports: P25Q16H, MX25R1635F, W25Q16, GD25Q16, IS25LP080, ZD25WQ16 + * If no QSPI flash present, falls back to internal flash only. + * + * GPS: RAK1910 (u-blox MAX-7Q) on UART1 at 9600 baud (Slot A) + * Sensors: Auto-detected on I2C0 - supports: + * - RAK1901 (SHTC3) at 0x70 + * - RAK1902 (LPS22HB) at 0x5C + * - RAK1906 (BME680) at 0x76 + * + * Optional SSD1306 128x64 @ 0x3C on I2C0 (P0.13/P0.14, no reset GPIO). + * If the panel is absent, display init may fail + * at runtime; UI continues without OLED. + */ + +#include +#include + +/delete-node/ &boot_partition; +/delete-node/ &slot0_partition; +/delete-node/ &slot1_partition; +/delete-node/ &storage_partition; + +/ { + aliases { + /* leds: green=heartbeat, blue=unread + LoRa TX; lora-tx-led=blue */ + led0 = &green_led; + led1 = &blue_led; + lora-tx-led = &blue_led; + sw0 = &user_button; + }; + chosen { + zephyr,code-partition = &code_partition; + zephyr,console = &cdc_acm_uart; + zephyr,shell-uart = &cdc_acm_uart; + zephyr,display = &ssd1306; + }; + + /* User button: P0.09 (NFC1). P0.00/P0.01 are 32k crystal — do not use for GPIO. */ + buttons: buttons { + compatible = "gpio-keys"; + + user_button: button_0 { + gpios = <&gpio0 9 (GPIO_PULL_UP | GPIO_ACTIVE_LOW)>; + zephyr,code = ; + label = "User Button"; + }; + }; + + /* longpress: short=KEY_A (multitap), long=KEY_ENTER */ + user_btn_longpress { + compatible = "zephyr,input-longpress"; + input = <&buttons>; + input-codes = ; + short-codes = ; + long-codes = ; + long-delay-ms = <1000>; + }; + + page_btn_multitap { + compatible = "zephcore,input-multi-tap"; + input-codes = ; + tap-codes = ; + tap-delay-ms = <400>; + }; +}; + +/* Battery ADC - AIN3 on P0.05 with voltage divider */ +/ { + zephyr,user { + io-channels = <&adc 3>; + vbat-mv-multiplier = <6161>; /* matches RAK4631 divider + SAADC ref */ + }; +}; + +&adc { + #address-cells = <1>; + #size-cells = <0>; + channel@3 { + reg = <3>; + zephyr,gain = "ADC_GAIN_1_6"; + zephyr,reference = "ADC_REF_INTERNAL"; + zephyr,acquisition-time = ; + zephyr,input-positive = ; + zephyr,resolution = <12>; + }; +}; + +&zephyr_udc0 { + cdc_acm_uart: cdc_acm_uart { + compatible = "zephyr,cdc-acm-uart"; + }; +}; + +&lora { + dio1-gpios = <&gpio1 15 (GPIO_PULL_DOWN | GPIO_ACTIVE_HIGH)>; + rx-boosted; + + /* Upstream DTS labels P1.05 as rx-enable-gpios (per-direction + * antenna control), but on RAK4631 this pin is the SX1262 module + * power-enable (HIGH = on; see MeshCore variants/rak4631). The + * driver toggles per-direction GPIOs each TX/RX, which would + * pulse this pin LOW during RX. Stop the driver from touching it + * by deleting the property; the gpio-hog below pins it HIGH. */ + /delete-property/ rx-enable-gpios; +}; + +&gpio1 { + sx1262_power_en { + gpio-hog; + gpios = <5 GPIO_ACTIVE_HIGH>; + output-high; + line-name = "sx1262-power-en"; + }; + + /* 3V3_S rail enable — WB_IO2 / P1.02, HIGH = on. This is the switched + * 3.3V bus on every WisBlock base board: it feeds the GPS slot, the + * sensor slot and the OLED. Without this the pin sits at its reset + * default (input/float) and the GPS module is simply never powered. + * + * Hogged rather than exposed as `chosen zephcore,gps-power`: the rail is + * not GPS-private, so duty-cycling it for the GPS would also drop the + * I2C sensors and the display. + * + * Note this pin is often described as "GPS reset" (it lands on the + * RAK1910/RAK12500 RESET_N in Slot A). Do not drive it as a reset — + * u-blox specify RESET_N as a reset only, not an enable (the SiP draws + * significant current while held in reset). See MeshCore PR #3051. */ + v3v3s_en { + gpio-hog; + gpios = <2 GPIO_ACTIVE_HIGH>; + output-high; + line-name = "3v3s-enable"; + }; +}; + +/* LEDs are active-high on this board (stock RAK4631 DTS used active-low). */ +&green_led { + gpios = <&gpio1 3 GPIO_ACTIVE_HIGH>; +}; + +&blue_led { + gpios = <&gpio1 4 GPIO_ACTIVE_HIGH>; +}; + +/* + * GPS on UART1 - RAK1910 (u-blox MAX-7Q) at 9600 baud, Slot A. + * + * The WisBlock GPS slot is wired to the core module's Serial1 = P0.15 (MCU RX + * <- GPS TX) / P0.16 (MCU TX -> GPS RX), which is Zephyr's uart1 on this board + * (uart0 is P0.19/P0.20 = Serial2, which the GPS slot does not reach). Matches + * MeshCore variants/rak4631 (PIN_SERIAL1_RX 15 / _TX 16) and ZephCore's sibling + * RAK-family boards gat562_30s and rak3401_1watt. uart1 is also nrf-uarte + * upstream, whereas uart0 is the legacy nrf-uart. + */ +&uart1 { + current-speed = <9600>; + gnss: gnss-nmea-generic { + compatible = "gnss-nmea-generic"; + }; +}; + +/* uart0 (P0.19/P0.20) is unused — console is USB CDC ACM and the GPS is on + * uart1. Upstream leaves it enabled at 115200; turn it off so it does not + * hold pins or a UARTE instance. */ +&uart0 { + status = "disabled"; +}; + +/* I2C0: optional SSD1306 + sensors */ +&i2c0 { + ssd1306: ssd1306@3c { + compatible = "solomon,ssd1306"; + reg = <0x3c>; + width = <128>; + height = <64>; + segment-offset = <0>; + page-offset = <0>; + display-offset = <0>; + multiplex-ratio = <63>; + segment-remap; + com-invdir; + inversion-on; + prechargep = <0x22>; + }; + + #include "../../common/sensors-i2c.dtsi" + + /* + * WisBlock slots can carry a RAK12002 (RV3028 at 0x52), so opt in to + * boot-time time restore. The descriptors are data-only and probed by + * raw I2C, so a board with no RTC module fitted simply fails the probe + * and is skipped -- nothing is bound and nothing is reported present. + * + * Safe on this board specifically: the RTC addresses (0x68/0x51/0x52/ + * 0x32) do not collide with anything declared here. The WisBlock sensor + * slots use 0x38/0x40/0x44/0x5c/0x70/0x76/0x77 and the OLED is 0x3c. + * + * 0x68 is shared with MPU-class IMUs, including the RAK1905. That is + * not a devicetree clash (nothing declares 0x68 here), and the + * discovery probe rejects a chip whose time registers are not valid + * BCD, which is exactly the case that check exists for -- an IMU is + * never adopted as a write-back target. + */ + #include "../../common/rtc-i2c.dtsi" +}; + +/* RAK4631 has BME680, not BME280 — override the common DTSI default */ +/delete-node/ &bme280; +&i2c0 { + bme680: bme680@76 { + compatible = "bosch,bme680"; + reg = <0x76>; + }; +}; + +/* + * QSPI Flash — disabled. + * RAK19003 Mini Base Board has no QSPI flash. The upstream DTS enables &qspi, + * which hangs at boot if no chip is present. Disable it explicitly. + * For base boards with QSPI (RAK19007, RAK5005-O), create a separate variant. + */ +&qspi { + status = "disabled"; +}; + +/* SoftDevice v6 flash layout */ +#include "../../common/nrf52_partitions_sdv6.dtsi" +#include "../../common/nrf52_wakeup.dtsi" From 041cb8214ab557177ab3690cab987df946313b1a Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 1 Oct 2026 20:27:33 -0700 Subject: [PATCH 22/78] 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 fcd0f20fb40c605a8c3599c13a1d09434566fa94 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 1 Oct 2026 20:40:05 -0700 Subject: [PATCH 23/78] 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 1dd6dc885f01be07e5620d74c5f2bcd131550979 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 1 Oct 2026 20:40:05 -0700 Subject: [PATCH 24/78] rak4631: declare only the RAK12002 RTC, not every RTC candidate The overlay included the whole of rtc-i2c.dtsi, whose own header says to include it only on boards that physically have an RTC. Its DS3231 descriptor at 0x68 shares the address of WisBlock IMUs, and the probe accepts a candidate whose power-loss bit reads set even when its time registers are not valid BCD. An IMU with that bit set in register 0x0F would be adopted as the clock, and every time sync would then write seven bytes over its registers and clear that bit. The comment saying the probe rejects an IMU was wrong. Declare the one candidate a WisBlock carries, the RAK12002's RV3028 at 0x52, with the same descriptor the shared file uses. Co-Authored-By: Claude Opus 5.5 --- .../boards/nrf52840/rak4631/board.overlay | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/zephcore/boards/nrf52840/rak4631/board.overlay b/zephcore/boards/nrf52840/rak4631/board.overlay index afea0e4b1..6eb70ee83 100644 --- a/zephcore/boards/nrf52840/rak4631/board.overlay +++ b/zephcore/boards/nrf52840/rak4631/board.overlay @@ -194,13 +194,20 @@ #include "../../common/sensors-i2c.dtsi" /* - * WisBlock slots can carry a RAK12002 (RV3028 at 0x52). The RTC - * descriptors are probed by raw I2C, so a board without one fails the - * probe and is skipped. None of their addresses (0x68/0x51/0x52/0x32) - * is declared here; 0x68 is shared with IMUs such as the RAK1905, which - * the probe rejects because their time registers are not valid BCD. + * A WisBlock slot can carry a RAK12002, an RV3028 at 0x52, so declare + * that one candidate for the boot-time probe; a board without it just + * fails the probe. Not the whole of rtc-i2c.dtsi: its DS3231 at 0x68 + * shares the address of WisBlock IMUs, and the probe could adopt one as + * the clock and write the time into its registers. */ - #include "../../common/rtc-i2c.dtsi" + rtc_rv3028: rtc-rv3028@52 { + compatible = "zephcore,rtc-i2c"; + reg = <0x52>; + time-reg = <0x00>; + date-index = <4>; + status-reg = <0x0e>; /* status */ + status-mask = <0x01>; /* PORF -- power-on reset */ + }; }; /* RAK4631 has BME680, not BME280 — override the common DTSI default */ From 0aa6b62b8d1b402facbd76849c4a2faccb0ff705 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 1 Oct 2026 20:45:21 -0700 Subject: [PATCH 25/78] 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 24728a05b98ca09f288ec68a19b008596ae8296f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 1 Oct 2026 21:45:28 -0700 Subject: [PATCH 26/78] 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 69001b7a75fad35bdf5a6fffda3d5a098d07769f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 00:08:49 -0700 Subject: [PATCH 27/78] 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 fa7dedd183d3c3a2e7ad26644ba3f2aa81e4b30a Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 01:11:21 -0700 Subject: [PATCH 28/78] 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 69ae19f293a16d0f31dc0b1dd3fcb472a445dc16 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 01:12:39 -0700 Subject: [PATCH 29/78] 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 99f8cf84bc1c3d6057737fd2243669093b0f444e Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 02:17:41 -0700 Subject: [PATCH 30/78] 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 4c8e9066fefefeb7300cc32a6f57507e5f1ad3f0 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 03:33:05 -0700 Subject: [PATCH 31/78] 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 581fe6ce3c404c43a7a46211069b89f29ebdaaec Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 06:27:43 -0700 Subject: [PATCH 32/78] 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 45729a5bc8a25689d6653c5d0ede0ac827981d83 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 12:43:20 -0700 Subject: [PATCH 33/78] 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 0aa174d100c0baa6b10ca54941efc0c7fbefc1f9 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 12:47:32 -0700 Subject: [PATCH 34/78] 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 7a9d049ad13fe97425126daa78b681d111fa402f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 13:04:39 -0700 Subject: [PATCH 35/78] 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 959cc96e58f644a3eda689d39fea2e14f12e5c9e Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 13:19:03 -0700 Subject: [PATCH 36/78] 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 a4fdb4a219cd8cf0b85779e37a3e6380339512a6 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 13:31:34 -0700 Subject: [PATCH 37/78] 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 d0fd45f6c8b9d953a143026017ff19cea8630015 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 13:32:44 -0700 Subject: [PATCH 38/78] 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 cf743cae16783bb6cf482391cb7b59af66f1b216 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 13:46:34 -0700 Subject: [PATCH 39/78] 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 e1950de78faa2dccd816dfd351814c1a4cd42097 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 13:58:28 -0700 Subject: [PATCH 40/78] 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 9f62d1007af1cc3980def7559698d6b4e09dfaaf Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 15:23:46 -0700 Subject: [PATCH 41/78] 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 a097ab19fa0fd352b4f69d35debbfbe496f7267b Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 15:33:28 -0700 Subject: [PATCH 42/78] 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 515dd950d6231bae254ef0108dd9d53d8cc3eaff Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 2 Oct 2026 15:54:48 -0700 Subject: [PATCH 43/78] 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 1dc07d6f640028f8888d06c760ebd05f82325ef9 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 3 Oct 2026 10:58:29 -0700 Subject: [PATCH 44/78] Move the upstream defect fixes out of the hw CLI iteration branch This branch carried three fixes for upstream dev defects alongside the hw command. Each now has its own iteration branch and PR, so this one carries the hw CLI alone and matches hw-cli-only (#16): - fork issue #3, a CLI reply cut to the companion frame with no marker: work/companion-cli-reply-truncation (#27). CompanionMesh.cpp is back to dev. - fork issue #4, the RAK4631 declaring no RTC candidate: work/rak4631-rak12002-rtc. The rak4631 overlay is back to dev. - fork issue #23, the RTC probe adopting a chip whose status read failed: work/rtc-probe-failed-status-read (#30). The probe's adoption rule and the hw rtc docs describe dev's behaviour again. Co-Authored-By: Claude Opus 5.5 --- docs/Repeater_CLI_commands.md | 6 ++-- zephcore/adapters/clock/ZephyrRTCDiscover.c | 28 +++++++------------ zephcore/app/CompanionMesh.cpp | 10 ++----- .../boards/nrf52840/rak4631/board.overlay | 16 ----------- 4 files changed, 15 insertions(+), 45 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; } diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index 866a67979..fea95bd75 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -1949,15 +1949,9 @@ bool CompanionMesh::handleCmdRunCliCommand(const uint8_t *data, size_t len) /* A CommonCLI reply can be longer than one companion frame and the * app protocol has no continuation for this response, so truncate - * rather than drop, and mark the cut so it is not read as complete. */ + * rather than drop. */ size_t rlen = strlen(reply); - if (rlen > MAX_FRAME_SIZE - 1) { - const char kCut[] = "..."; - const size_t kCutLen = sizeof(kCut) - 1; - - rlen = MAX_FRAME_SIZE - 1; - memcpy(reply + rlen - kCutLen, kCut, kCutLen); - } + if (rlen > MAX_FRAME_SIZE - 1) rlen = MAX_FRAME_SIZE - 1; writeFrame(rsp, rlen + 1); } else { writeErrFrame(ERR_ILLEGAL_ARG); diff --git a/zephcore/boards/nrf52840/rak4631/board.overlay b/zephcore/boards/nrf52840/rak4631/board.overlay index 652afff20..a0ada57cf 100644 --- a/zephcore/boards/nrf52840/rak4631/board.overlay +++ b/zephcore/boards/nrf52840/rak4631/board.overlay @@ -215,22 +215,6 @@ }; #include "../../common/sensors-i2c.dtsi" - - /* - * A WisBlock slot can carry a RAK12002, an RV3028 at 0x52, so declare - * that one candidate for the boot-time probe; a board without it just - * fails the probe. Not the whole of rtc-i2c.dtsi: its DS3231 at 0x68 - * shares the address of WisBlock IMUs, and the probe could adopt one as - * the clock and write the time into its registers. - */ - rtc_rv3028: rtc-rv3028@52 { - compatible = "zephcore,rtc-i2c"; - reg = <0x52>; - time-reg = <0x00>; - date-index = <4>; - status-reg = <0x0e>; /* status */ - status-mask = <0x01>; /* PORF -- power-on reset */ - }; }; /* RAK4631 has BME680, not BME280 — override the common DTSI default */ From 7b090db1ad4eacecca8d27bc4a208592db8cd7a7 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 3 Oct 2026 11:11:49 -0700 Subject: [PATCH 45/78] 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 acbacfd515403f3adb2b0f6bfbff0b262813bf22 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 3 Oct 2026 11:18:18 -0700 Subject: [PATCH 46/78] 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 ff9208608f3dabcfb625c35023ed8ab8a6f3601e Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 3 Oct 2026 11:34:33 -0700 Subject: [PATCH 47/78] 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 ad70d150db5a0b384a47e2cb1f0d64da9d72dc64 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 3 Oct 2026 11:48:52 -0700 Subject: [PATCH 48/78] 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 76a27a7263151693a9d0a51ac1e6a6215da4a1b2 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 5 Oct 2026 18:47:35 -0700 Subject: [PATCH 49/78] hw rtc: publish probe outcomes at the end; correct the merged docs A re-probe from zephcore_rtc_save() cleared s_state before its I2C reads, so `hw` on another thread could report absent candidates as unprobed meanwhile. The probe now collects outcomes locally and copies them in at the end, and each renderer reads the unsettled count once. Docs and comments the merge left wrong: ARCHITECTURE.md still pointed at the renamed Repeater_CLI_commands.md; the Hardware Report heading lacked the (ZephCore only) tag the renamed doc now requires; the all-0xFF re-probe happens only when no chip was adopted; -EIO also covers a data NACK and, on nRF, any bus error; and the header no longer says a failed first read means nothing is there. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 2 +- docs/CLI_commands.md | 5 +- zephcore/adapters/clock/ZephyrRTCDiscover.c | 57 ++++++++++++--------- zephcore/adapters/clock/ZephyrRTCDiscover.h | 28 +++++----- zephcore/helpers/HardwareReport.cpp | 12 +++-- 5 files changed, 61 insertions(+), 43 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6710dad44..ae3956af0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -736,7 +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` +Hardware: `hw`, `hw board/rtc/i2c [scan]/gps/sensors/all` — see `CLI_commands.md` Config: `set name/freq/radio/tx/flood.max/password/...`, corresponding getters System (power): `poweroff`/`shutdown`, `get pwrmgt.support/source/bootreason/bootmv` GPS: `gps`, `gps on/off/sync/setloc/advert`, `get gps`, `set gps duty ` diff --git a/docs/CLI_commands.md b/docs/CLI_commands.md index d98851b14..f7d244e5d 100644 --- a/docs/CLI_commands.md +++ b/docs/CLI_commands.md @@ -49,7 +49,7 @@ The text CLI of every ZephCore role. Commands are sent over USB serial (CDC-ACM) --- -## Hardware Report +## Hardware Report *(ZephCore only)* `hw` reports what the firmware is and what hardware it actually found. A release build compiles every `MESH_DEBUG_*` call away, so this is the only way to get these facts off a @@ -133,7 +133,8 @@ Every field is something the firmware *knows*. It never infers: probe identified the chip as this RTC, which includes a chip whose time could not be read. The first chip identified is adopted for write-back (marked `*`). - `all 0xff` means the first read returned 0xFF in every byte: an erased EEPROM, or an RTC - that has not started. The probe skips it, and the first time save probes again. Like + that has not started. The probe skips it. If no RTC was adopted, the first time save + probes once more, which can settle it. Like `unprobed`, it keeps the summary at `none found, N unsettled` rather than `none present`. - A build with no sensor support reports `not compiled in`, which is a different statement from a sensor manager that looked and found nothing. diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.c b/zephcore/adapters/clock/ZephyrRTCDiscover.c index 6f6ab1c29..5d9acf74b 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.c +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.c @@ -204,12 +204,13 @@ static bool rtc_all_ff(const uint8_t blk[7]) } /* Decide whether the device at d is the RTC it declares. A first read that - * ends in an address NACK (-EIO on the nRF and ESP32 drivers) means nothing - * is there; a bus that is not ready, or any other failure, is RTC_UNREAD, - * no evidence either way. A first read that is all 0xFF is an erased EEPROM, - * though a real RTC can power up that way, so it is skipped for now. - * Otherwise the device is ruled out only when two reads each rule it out: a - * failed second read does not, an all-0xFF second read does, and an + * ends in -EIO means nothing is there: that is an address NACK on the nRF and + * ESP32 drivers, though a data NACK, and on nRF any bus error event, read the + * same. A bus that is not ready, or any other failure, such as a timeout, is + * RTC_UNREAD, no evidence either way. A first read that is all 0xFF is an + * erased EEPROM, though a real RTC can power up that way, so it is skipped + * for now. Otherwise the device is ruled out only when two reads each rule it + * out: a failed second read does not, an all-0xFF second read does, and an * unreadable power-loss flag cannot vouch for the fields. RTC_FOUND leaves * the block to decode in blk; RTC_FOUND_GARBLED is this RTC with no clean * read to take a time from. */ @@ -577,20 +578,11 @@ static void rv3028_save_retry_fn(struct k_work *work) #endif /* RTC_RV3028_CFG */ -/* Probe the chips in order; cache the first one found in s_active. Stop at - * the first that holds a sane time, returned via epoch_out. */ -static bool rtc_probe(uint32_t *epoch_out) +/* Probe the chips in order; cache the first one found in s_active and each + * outcome in state[]. Stop at the first that holds a sane time, returned via + * epoch_out. */ +static bool rtc_probe_run(uint32_t *epoch_out, uint8_t state[]) { - /* Clear last run's outcomes. rtc_probe() can run more than once -- - * zephcore_rtc_save() probes if restore never ran, or again after an - * all-0xFF skip -- 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 outcome, 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; - s_skipped_ff = false; for (size_t i = 0; i < ARRAY_SIZE(rtc_descs); i++) { const struct rtc_desc *d = &rtc_descs[i]; @@ -601,20 +593,20 @@ static bool rtc_probe(uint32_t *epoch_out) continue; } if (v == RTC_ERASED) { - s_state[i] = ZEPHCORE_RTC_ALL_FF; + state[i] = ZEPHCORE_RTC_ALL_FF; s_skipped_ff = true; continue; } if (v == RTC_NOT_THIS) { - s_state[i] = ZEPHCORE_RTC_ABSENT; + state[i] = ZEPHCORE_RTC_ABSENT; LOG_INF("%s: device at the address is not this RTC, skipped", d->name); continue; } if (v == RTC_ABSENT) { - s_state[i] = ZEPHCORE_RTC_ABSENT; + state[i] = ZEPHCORE_RTC_ABSENT; continue; } - s_state[i] = ZEPHCORE_RTC_PRESENT; + state[i] = ZEPHCORE_RTC_PRESENT; #if RTC_RV3028_CFG /* Only a clean identification is configured: the store writes to @@ -689,6 +681,25 @@ static bool rtc_probe(uint32_t *epoch_out) return false; } +/* rtc_probe() can run more than once: zephcore_rtc_save() probes if restore + * never ran, or again after an all-0xFF skip. Each run starts from no outcome, + * so candidates it never reached report UNPROBED rather than the previous + * run's answer. The outcomes are collected locally and published at the end, + * so `hw` reading s_state from another thread meanwhile sees the previous + * run's, not a cleared table. */ +static bool rtc_probe(uint32_t *epoch_out) +{ + uint8_t state[ARRAY_SIZE(rtc_descs)]; + bool found; + + memset(state, ZEPHCORE_RTC_UNPROBED, sizeof(state)); + s_active = NULL; + s_skipped_ff = false; + found = rtc_probe_run(epoch_out, state); + memcpy(s_state, state, sizeof(state)); + return found; +} + bool zephcore_rtc_restore(uint32_t *epoch_out) { s_probed = true; diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.h b/zephcore/adapters/clock/ZephyrRTCDiscover.h index 109efd02c..cd3c09e58 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.h +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.h @@ -28,16 +28,16 @@ extern "C" { * Probe the declared RTC chips in order, stopping at the first that holds a * 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. A failed first read means - * nothing is there, and a first read of all 0xFF (an erased EEPROM) is - * skipped. Otherwise a device is passed over only if two reads each show it - * is not that RTC: a bit set that the data sheet shows as 0 (the - * descriptor's zero-mask), seconds, minutes, date or month out of range - * while the power-loss flag does not read as set (an unreadable flag counts - * as not set), or, on the second read, all 0xFF. A failed second read never - * rules a device out. A time is taken only from a clean read whose fields, - * hours and year are each in range (12-hour mode is not decoded). Returns - * false if none present or no trustworthy time is held. + * not) is remembered as the write-back target. A failed first read is skipped + * (nothing is there, or nothing could be read), and so is a first read of all + * 0xFF (an erased EEPROM). Otherwise a device is passed over only if two + * reads each show it is not that RTC: a bit set that the data sheet shows as + * 0 (the descriptor's zero-mask), seconds, minutes, date or month out of + * range while the power-loss flag does not read as set (an unreadable flag + * counts as not set), or, on the second read, all 0xFF. A failed second read + * never rules a device out. A time is taken only from a clean read whose + * fields, hours and year are each in range (12-hour mode is not decoded). + * Returns false if none present or no trustworthy time is held. * * At a descriptor with rv3028-eeprom-config, the time is read once more * with the chip's backup switch flag (BSF) cleared before and checked @@ -95,13 +95,15 @@ enum zephcore_rtc_state { ZEPHCORE_RTC_UNPROBED = 0, /* not probed: discovery stopped before * reaching it, its bus was not ready, or * the read failed other than with -EIO */ - ZEPHCORE_RTC_ABSENT, /* no ACK, or two reads each showed a device - * that is not this RTC */ + ZEPHCORE_RTC_ABSENT, /* the read ended in -EIO (no ACK; on nRF + * also any bus error), or two reads each + * showed a device that is not this RTC */ ZEPHCORE_RTC_PRESENT, /* identified as this RTC, including one * whose time could not be read */ ZEPHCORE_RTC_ALL_FF, /* the first read was all 0xFF: an erased * EEPROM, or an RTC that has not started. - * Skipped; the first save probes again */ + * Skipped; if no chip was adopted, the + * first save probes again */ }; /* A devicetree-declared "zephcore,rtc-i2c" candidate, plus its probe outcome. */ diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index cc0202aae..d28e25ea7 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -413,12 +413,14 @@ void section_rtc(Sink *s, mesh::RTCClock *rtc) } struct zephcore_rtc_entry active; + unsigned unsettled = rtc_unsettled_count(); + if (zephcore_rtc_active(&active)) { sink_line(s, "rtc: %s at 0x%02x on %s", active.name, active.addr, active.bus); - } else if (rtc_unsettled_count() > 0) { + } else if (unsettled > 0) { sink_line(s, "rtc: none found, %u of %u declared unsettled", - rtc_unsettled_count(), (unsigned)declared); + unsettled, (unsigned)declared); } else { sink_line(s, "rtc: none present (%u declared)", (unsigned)declared); } @@ -689,8 +691,10 @@ 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 { - if (rtc_unsettled_count() > 0) { - sink_line(s, "rtc none found, %u unsettled", rtc_unsettled_count()); + unsigned unsettled = rtc_unsettled_count(); + + if (unsettled > 0) { + sink_line(s, "rtc none found, %u unsettled", unsettled); } else { sink_line(s, "rtc none present"); } From 14e69684c65d24a6dc31277d48d8d5df566189f7 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 5 Oct 2026 18:55:33 -0700 Subject: [PATCH 50/78] hw rtc: read discovery's outcome as one snapshot The previous commit staged the states but still set s_active partway through a probe, so `hw rtc` during a re-probe's RV3028 configuration could mark a chip adopted while its line still read "all 0xff". The renderers also read the count, the adopted chip and each line separately, so a run finishing mid-render could mix two runs. The probe now collects the adopted chip locally as well and publishes it with the states under a spinlock. zephcore_rtc_snapshot() copies every entry under the same lock and replaces zephcore_rtc_get() and zephcore_rtc_active(), and both renderers work from that one copy. Co-Authored-By: Claude Opus 5.5 --- zephcore/adapters/clock/ZephyrRTCDiscover.c | 88 ++++++++++----------- zephcore/adapters/clock/ZephyrRTCDiscover.h | 15 ++-- zephcore/helpers/HardwareReport.cpp | 80 +++++++++++-------- 3 files changed, 94 insertions(+), 89 deletions(-) diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.c b/zephcore/adapters/clock/ZephyrRTCDiscover.c index 5d9acf74b..cb5ef36b8 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.c +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.c @@ -110,6 +110,9 @@ static bool s_reprobed; * 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)]; +/* 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. */ +static struct k_spinlock s_report_lock; #define BCD2BIN(x) ((((x) >> 4) & 0x0F) * 10 + ((x) & 0x0F)) #define BIN2BCD(x) ((((x) / 10) << 4) | ((x) % 10)) @@ -578,10 +581,11 @@ static void rv3028_save_retry_fn(struct k_work *work) #endif /* RTC_RV3028_CFG */ -/* Probe the chips in order; cache the first one found in s_active and each +/* Probe the chips in order; return the first one found in *adopted and each * outcome in state[]. Stop at the first that holds a sane time, returned via * epoch_out. */ -static bool rtc_probe_run(uint32_t *epoch_out, uint8_t state[]) +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++) { @@ -619,8 +623,8 @@ static bool rtc_probe_run(uint32_t *epoch_out, uint8_t state[]) clean = false; } #endif - if (s_active == NULL) { - s_active = d; /* RTC => our write-back target */ + if (*adopted == NULL) { + *adopted = d; /* RTC => our write-back target */ #if RTC_RV3028_CFG if (clean) { rv3028_configure(d); @@ -682,21 +686,26 @@ static bool rtc_probe_run(uint32_t *epoch_out, uint8_t state[]) } /* rtc_probe() can run more than once: zephcore_rtc_save() probes if restore - * never ran, or again after an all-0xFF skip. Each run starts from no outcome, - * so candidates it never reached report UNPROBED rather than the previous - * run's answer. The outcomes are collected locally and published at the end, - * so `hw` reading s_state from another thread meanwhile sees the previous - * run's, not a cleared table. */ + * 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. */ static bool rtc_probe(uint32_t *epoch_out) { uint8_t state[ARRAY_SIZE(rtc_descs)]; + const struct rtc_desc *adopted = NULL; bool found; memset(state, ZEPHCORE_RTC_UNPROBED, sizeof(state)); - s_active = NULL; s_skipped_ff = false; - found = rtc_probe_run(epoch_out, state); + found = rtc_probe_run(epoch_out, state, &adopted); + + k_spinlock_key_t key = k_spin_lock(&s_report_lock); + memcpy(s_state, state, sizeof(state)); + s_active = adopted; + k_spin_unlock(&s_report_lock, key); return found; } @@ -748,43 +757,32 @@ 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) +size_t zephcore_rtc_snapshot(struct zephcore_rtc_entry *out, size_t max) { - if (out == NULL || i >= ARRAY_SIZE(rtc_descs)) { - return false; - } - rtc_fill(i, out); - return true; -} + size_t n = MIN(max, ARRAY_SIZE(rtc_descs)); -bool zephcore_rtc_active(struct zephcore_rtc_entry *out) -{ - if (out == NULL || s_active == NULL) { - return false; + if (out == NULL) { + return 0; } - for (size_t i = 0; i < ARRAY_SIZE(rtc_descs); i++) { - if (&rtc_descs[i] == s_active) { - rtc_fill(i, out); - return true; - } + + k_spinlock_key_t key = k_spin_lock(&s_report_lock); + + for (size_t i = 0; i < n; i++) { + const struct rtc_desc *d = &rtc_descs[i]; + + out[i].name = d->name; + out[i].bus = d->bus->name; + out[i].addr = d->addr; + out[i].state = (enum zephcore_rtc_state)s_state[i]; + out[i].active = (s_active == d); } - return false; + k_spin_unlock(&s_report_lock, key); + return n; } bool zephcore_rtc_probed(void) @@ -810,17 +808,11 @@ size_t zephcore_rtc_declared(void) return 0; } -bool zephcore_rtc_get(size_t i, struct zephcore_rtc_entry *out) +size_t zephcore_rtc_snapshot(struct zephcore_rtc_entry *out, size_t max) { - ARG_UNUSED(i); ARG_UNUSED(out); - return false; -} - -bool zephcore_rtc_active(struct zephcore_rtc_entry *out) -{ - ARG_UNUSED(out); - return false; + ARG_UNUSED(max); + return 0; } bool zephcore_rtc_probed(void) diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.h b/zephcore/adapters/clock/ZephyrRTCDiscover.h index cd3c09e58..d6e250b05 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.h +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.h @@ -82,7 +82,8 @@ void zephcore_rtc_save(uint32_t epoch); * 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. + * 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 @@ -119,12 +120,12 @@ struct zephcore_rtc_entry { * 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. */ -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); +/* 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(). */ +size_t zephcore_rtc_snapshot(struct zephcore_rtc_entry *out, size_t max); /* Has boot-time discovery run? If false, every state above is UNPROBED. */ bool zephcore_rtc_probed(void); diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index d28e25ea7..56e6d3153 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -306,21 +306,34 @@ bool declared_names_at(const char *bus, uint16_t addr, char *out, size_t cap) } #endif /* CONFIG_I2C */ -/* Candidates the probe could not settle either way, unprobed or all 0xFF: a - * "none present" is only true when this is zero. */ -unsigned rtc_unsettled_count(void) -{ - unsigned n = 0; - - for (size_t i = 0; i < zephcore_rtc_declared(); i++) { - struct zephcore_rtc_entry e; +/* 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. */ +constexpr size_t kRtcMax = DT_NUM_INST_STATUS_OKAY(zephcore_rtc_i2c) > 0 + ? DT_NUM_INST_STATUS_OKAY(zephcore_rtc_i2c) : 1; + +struct RtcView { + struct zephcore_rtc_entry e[kRtcMax]; + size_t n; + int active; /* index into e, or -1 */ + unsigned unsettled; /* unprobed or all 0xFF: a "none present" is only + * true when this is zero */ +}; - if (zephcore_rtc_get(i, &e) && (e.state == ZEPHCORE_RTC_UNPROBED || - e.state == ZEPHCORE_RTC_ALL_FF)) { - n++; +void rtc_view(RtcView *v) +{ + v->n = zephcore_rtc_snapshot(v->e, kRtcMax); + v->active = -1; + v->unsettled = 0; + for (size_t i = 0; i < v->n; i++) { + if (v->e[i].active) { + v->active = (int)i; + } + if (v->e[i].state == ZEPHCORE_RTC_UNPROBED || + v->e[i].state == ZEPHCORE_RTC_ALL_FF) { + v->unsettled++; } } - return n; } const char *rtc_state_str(enum zephcore_rtc_state st) @@ -412,26 +425,25 @@ void section_rtc(Sink *s, mesh::RTCClock *rtc) return; } - struct zephcore_rtc_entry active; - unsigned unsettled = rtc_unsettled_count(); + RtcView v; + + rtc_view(&v); + if (v.active >= 0) { + const struct zephcore_rtc_entry *a = &v.e[v.active]; - if (zephcore_rtc_active(&active)) { - sink_line(s, "rtc: %s at 0x%02x on %s", active.name, - active.addr, active.bus); - } else if (unsettled > 0) { + sink_line(s, "rtc: %s at 0x%02x on %s", a->name, a->addr, a->bus); + } else if (v.unsettled > 0) { sink_line(s, "rtc: none found, %u of %u declared unsettled", - unsettled, (unsigned)declared); + v.unsettled, (unsigned)declared); } 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 ? " *" : ""); + for (size_t i = 0; i < v.n; i++) { + const struct zephcore_rtc_entry *e = &v.e[i]; + + sink_line(s, " 0x%02x %s %s%s", e->addr, e->name, + rtc_state_str(e->state), e->active ? " *" : ""); } } @@ -672,29 +684,29 @@ void section_summary(Sink *s, mesh::MainBoard *board, CommonCLICallbacks *cb) sink_line(s, "fw %s role %s", cb->getFirmwareVer(), cb->getRole()); } - struct zephcore_rtc_entry active; - if (zephcore_rtc_active(&active)) { + 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. */ - sink_line(s, "rtc %s", active.name); + 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) { sink_line(s, "rtc autodiscovery disabled"); } 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" + /* 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 { - unsigned unsettled = rtc_unsettled_count(); - - if (unsettled > 0) { - sink_line(s, "rtc none found, %u unsettled", unsettled); + if (v.unsettled > 0) { + sink_line(s, "rtc none found, %u unsettled", v.unsettled); } else { sink_line(s, "rtc none present"); } From db69172c66cea391efda4c24b8dc00ce95acad9c Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 8 Oct 2026 06:37:37 -0700 Subject: [PATCH 51/78] 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 52/78] 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 53/78] 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 54/78] 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 55/78] 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 56/78] 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 57/78] 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 58/78] 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 59/78] 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 60/78] 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 61/78] 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 62/78] 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 63/78] 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 64/78] 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 65/78] 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 66/78] 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 67/78] 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 68/78] 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 69/78] 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 70/78] 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 71/78] 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 72/78] 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 73/78] 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 74/78] 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 75/78] 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 76/78] 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 c167178767f2a36a91a4cb9e03afff6edd6e3d02 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 10 Oct 2026 15:39:30 -0700 Subject: [PATCH 77/78] hw: probe the I2C scan by reading, and trim to upstream's rules `hw i2c scan` probed each address with a zero-length write. AGENTS.md forbids writing to a peripheral that was not cleanly identified, so the scan now probes with a one-byte read. Measured against upstream's AGENTS.md (smallest change, comment diet): - comments keep what the code does or the invariant it holds; rationale and history are dropped; - guards for cases that cannot occur are removed (page-index clamp on emit, the cap <= reserve paths, scan name/token/fresh-line fallbacks, the snapshot NULL check); - unused parameters go: `local` and `rtc` from handle(), `cb` and `board` from the sections that ignored them; - the reset-cause block is one helper, and the duplicated HAS_GPS_UART test is dropped: a disabled node is reported from devicetree, a disabled UART as `available: no`; - the resume marker is upstream's `... next:N` line, as `sensor list`; - a sensor is read only when its line is on the page being returned. Docs follow: scan probe, unprobed wording (any error other than -EIO), the GPS and sensors rows, the marker, and the Clock clause in ARCHITECTURE.md for the snapshot accessor. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 2 +- docs/CLI_commands.md | 11 +- zephcore/adapters/clock/ZephyrRTCDiscover.c | 21 +- zephcore/adapters/clock/ZephyrRTCDiscover.h | 23 +- zephcore/helpers/CommonCLI.cpp | 12 +- zephcore/helpers/HardwareReport.cpp | 439 +++++--------------- zephcore/helpers/HardwareReport.h | 43 +- 7 files changed, 136 insertions(+), 415 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..67b5dfebc 100644 --- a/docs/CLI_commands.md +++ b/docs/CLI_commands.md @@ -66,8 +66,8 @@ 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. 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 gps [start]` | GNSS driver compatible, transport, baud, and enable state. A GNSS node disabled in devicetree reports `declared but disabled` (the summary says `disabled`). A node whose UART is disabled reports `available: no`, 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 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, so it reads each sensor. Not the summary, and not `hw i2c scan`, which probes every address | | `hw ` | Usage string | @@ -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, @@ -124,7 +125,7 @@ Every field is something the firmware *knows*. It never infers: 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 + 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..53d72c096 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 @@ -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..4565a0acf 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. */ + /* reply already starts past a local request's "xx|" prefix. */ if (sender_timestamp == 0 && _reply_hdr_used < cap) { 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..0847185ea 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -36,48 +36,24 @@ extern "C" { #define HW_HAS_GPS_HDR 1 #endif -/* 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 +#define HW_GNSS_NODE DT_NODELABEL(gnss) + +/* Candidates declared but compiled out: the discovery stubs report none. */ +#define HW_RTC_DISABLED (!IS_ENABLED(CONFIG_ZEPHCORE_RTC_AUTODISCOVER) && \ + DT_NUM_INST_STATUS_OKAY(zephcore_rtc_i2c) > 0) namespace zephcore_hw { namespace { -/* Room 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 and the parser accepts. */ #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 +64,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,13 +73,22 @@ 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; @@ -117,32 +99,20 @@ void sink_line(Sink *s, const char *fmt, ...) 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 +124,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; - } - - /* 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; + /* 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); } - 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 +144,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,17 +154,7 @@ 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. - */ +/* Every enabled, addressable node on any I2C bus. */ #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), \ @@ -241,28 +163,14 @@ struct I2cDecl { 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) \ @@ -273,17 +181,13 @@ constexpr size_t i2c_decl_count() { return ARRAY_SIZE(i2c_decls) - 1; } 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; @@ -296,7 +200,6 @@ 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. */ out[0] = '\0'; return false; } @@ -306,9 +209,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 +217,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,6 +246,17 @@ 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) @@ -368,14 +279,8 @@ void section_board(Sink *s, mesh::MainBoard *board, CommonCLICallbacks *cb) } 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 { @@ -395,14 +300,9 @@ void section_board(Sink *s, mesh::MainBoard *board, CommonCLICallbacks *cb) } } -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 +311,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 +354,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 +361,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 +379,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 +408,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,38 +431,24 @@ 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 @@ -633,14 +459,11 @@ void section_gps(Sink *s, CommonCLICallbacks *cb) 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. */ int n = env_sensor_count(); if (n <= 0) { @@ -652,6 +475,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,17 +495,12 @@ 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()); @@ -688,65 +510,40 @@ void section_summary(Sink *s, mesh::MainBoard *board, CommonCLICallbacks *cb) 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 +557,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; @@ -798,12 +589,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 +601,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 +618,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 +636,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 */ From 06de0ca22652bd32eb3cbbb3487c14b5c202a2b5 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 10 Oct 2026 15:54:17 -0700 Subject: [PATCH 78/78] hw: drop the remaining impossible guards, reuse toHex, one I2C predicate From the second local review of the rework: - board and callbacks are never null (CommonCLI holds references), and vsnprintf/strtoul/hwinfo cannot return what the removed checks tested; - the __has_include gates were always true; sensors gate on CONFIG_SENSOR; - devid uses mesh::Utils::toHex; - the declared-device and scanned-bus tables share one devicetree filter; - docs: the RTC states come from the last probe run, `hw all` reads only the sensors on its page, and the unbuildable disabled-UART case is gone. Co-Authored-By: Claude Opus 5.5 --- docs/CLI_commands.md | 10 ++-- zephcore/adapters/clock/ZephyrRTCDiscover.h | 2 +- zephcore/helpers/CommonCLI.cpp | 2 +- zephcore/helpers/HardwareReport.cpp | 65 +++++++-------------- 4 files changed, 28 insertions(+), 51 deletions(-) diff --git a/docs/CLI_commands.md b/docs/CLI_commands.md index 67b5dfebc..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 disabled in devicetree reports `declared but disabled` (the summary says `disabled`). A node whose UART is disabled reports `available: no`, since the GPS manager does not use it | +| `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, so it reads each sensor. Not the summary, and not `hw i2c scan`, which probes every address | +| `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). @@ -124,7 +124,7 @@ 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 +- 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 diff --git a/zephcore/adapters/clock/ZephyrRTCDiscover.h b/zephcore/adapters/clock/ZephyrRTCDiscover.h index 53d72c096..3ef8f1af0 100644 --- a/zephcore/adapters/clock/ZephyrRTCDiscover.h +++ b/zephcore/adapters/clock/ZephyrRTCDiscover.h @@ -70,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 */ }; diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index 4565a0acf..90b745555 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -511,7 +511,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch } #endif /* reply already starts past a local request's "xx|" prefix. */ - if (sender_timestamp == 0 && _reply_hdr_used < cap) { + if (sender_timestamp == 0) { cap -= _reply_hdr_used; } zephcore_hw::handle(command, reply, cap, _board, _callbacks); diff --git a/zephcore/helpers/HardwareReport.cpp b/zephcore/helpers/HardwareReport.cpp index 0847185ea..c3dae2851 100644 --- a/zephcore/helpers/HardwareReport.cpp +++ b/zephcore/helpers/HardwareReport.cpp @@ -16,6 +16,7 @@ #include #include +#include #include #include @@ -26,15 +27,8 @@ 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 +#include "../adapters/sensors/ZephyrEnvSensors.h" #define HW_GNSS_NODE DT_NODELABEL(gnss) @@ -47,7 +41,7 @@ namespace { /* Room kept free for the "\n... next:NNNNN" resume marker. */ #define HW_NEXT_RESERVE 16 -/* Largest resume index the marker holds and the parser accepts. */ +/* Largest resume index the marker holds; the parser clamps to it. */ #define HW_PAGE_INDEX_MAX 99999U /* Paged line sink: every section writes through it, so all page alike. */ @@ -95,9 +89,6 @@ void sink_line(Sink *s, const char *fmt, ...) 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)) { memcpy(line + sizeof(line) - 4, "...", 3); } @@ -154,12 +145,13 @@ struct I2cDecl { (uint16_t)DT_REG_ADDR(node_id), \ }, -/* Every enabled, addressable node on any I2C bus. */ -#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) @@ -173,11 +165,7 @@ constexpr size_t i2c_decl_count() { return ARRAY_SIZE(i2c_decls) - 1; } * 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) @@ -199,7 +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) { + if ((size_t)w >= cap - used) { out[0] = '\0'; return false; } @@ -262,19 +250,14 @@ bool reset_causes(uint32_t *cause, char *out, size_t cap) 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); } @@ -291,9 +274,7 @@ 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"); @@ -455,15 +436,13 @@ void section_gps(Sink *s) 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 section_sensors(Sink *s) { -#if defined(HW_HAS_SENSOR_HDR) && IS_ENABLED(CONFIG_SENSOR) +#if IS_ENABLED(CONFIG_SENSOR) int n = env_sensor_count(); if (n <= 0) { @@ -502,9 +481,7 @@ void section_sensors(Sink *s) void section_summary(Sink *s, CommonCLICallbacks *cb) { 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; @@ -576,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; }