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-10-08 18:26:59 +02:00
parent 290b0c26b0
commit 2eb8a8e5b7
8 changed files with 210 additions and 27 deletions
+16
View File
@@ -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,
+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>
@@ -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));
}