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.
This commit is contained in:
ZephCore T096
2026-09-28 21:58:26 +02:00
parent 3c7a7c830a
commit f32cda2b97
8 changed files with 210 additions and 27 deletions
+41 -6
View File
@@ -12,6 +12,7 @@
#include <zephyr/sys/reboot.h>
#include <zephyr/drivers/sensor.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/drivers/pwm.h>
#include <stdio.h>
#include <string.h>
@@ -99,14 +100,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
@@ -385,12 +406,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)) {
@@ -408,7 +441,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
@@ -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 = <NRF_PSEL(PWM_OUT0, 0, 28)>;
};
};
pwm0_sleep: pwm0_sleep {
group1 {
psels = <NRF_PSEL(PWM_OUT0, 0, 28)>;
low-power-enable;
};
};
};
@@ -15,6 +15,7 @@
#include <zephyr/dt-bindings/lora/sx126x.h>
#include <zephyr/dt-bindings/adc/nrf-saadc.h>
#include <zephyr/dt-bindings/input/input-event-codes.h>
#include <zephyr/dt-bindings/pwm/pwm.h>
#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";
@@ -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
+16
View File
@@ -1060,6 +1060,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);
@@ -1353,6 +1358,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,
+18
View File
@@ -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);
}
+13
View File
@@ -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 <pct>" 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. */
+70 -21
View File
@@ -21,6 +21,7 @@
#include "led_gate.h" /* shared with the LoRa TX LED */
#include <zephyr/drivers/gpio.h>
#include <zephyr/drivers/pwm.h>
#include <zephyr/dt-bindings/input/input-event-codes.h>
#include <zephyr/kernel.h>
#include <string.h>
@@ -86,21 +87,63 @@ void ui_play_startup_chime(void)
* only when msg count > 0, giving a visual unread-message reminder.
*/
#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);
@@ -113,7 +156,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;
@@ -158,9 +201,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);
@@ -178,7 +223,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
@@ -192,7 +237,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
@@ -203,9 +248,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);
@@ -222,15 +271,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
@@ -248,18 +297,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);
}
}
@@ -283,11 +332,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
@@ -299,12 +348,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));
}