From 2eb8a8e5b7bcf75e02bf2ba67bc678437cb59a4f Mon Sep 17 00:00:00 2001 From: ZephCore T096 Date: Wed, 26 Aug 2026 19:45:45 +0200 Subject: [PATCH 01/15] 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 7310826f..d1eb11d4 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 530c5989..7deeabff 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 9be10802..a7ae4872 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 98c300e8..22dd9147 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 47db72f3..be3f7311 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 610bbb68..a9c06a3e 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 726dfacf..b7577429 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 ca7865d8..3c5a9cbb 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 02/15] 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 be3f7311..c1c09272 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 03/15] 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 c1c09272..47656f9b 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 04/15] 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 00000000..97f8f3c5 --- /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 15c912d8..04c3cd3f 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 ef959e52..36bb17a3 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 05/15] 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 47656f9b..37f76175 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 c5e2b289..151f2c6d 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 a22984d2..aaa881f8 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 b5bdfa30..960d19c3 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 cb90e19f..85ab415b 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 d86b4a74..c2ad2566 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 06/15] 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 d1eb11d4..18ba6e1f 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 07/15] 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 47e523fe..ae2235f4 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 08/15] 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 151f2c6d..89139f92 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 b7577429..69918628 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 09/15] 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 c8f26d80..7b7864d1 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 aaa881f8..8a02beeb 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 f65745c2..a92953fe 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 10/15] 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 3f456a59..4d5cd48a 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 11/15] 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 7b7864d1..2ae96990 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 cc654097..c8804a89 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 ce2e875d..08634ffd 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 8a02beeb..df9a1d60 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 12/15] 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 37f76175..e61234fe 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 13/15] 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 18ba6e1f..b00645a8 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 e61234fe..1d8f6218 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 df9a1d60..9e8b74f5 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 a9c06a3e..ce8e9b1f 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 69918628..eeb8c9ee 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 3c5a9cbb..a54f9067 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 14/15] 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 97f8f3c5..00000000 --- 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 36bb17a3..a56894bf 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 15/15] 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 78498992..f094afc2 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 f83e6d9d..7f133aeb 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 573f4e3c..c5a572c5 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 cf482d97..706e26dc 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 1d8f6218..af12f3ce 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 1ea9351e..81068f5e 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); }