diff --git a/docs/led-menu-guide.pdf b/docs/led-menu-guide.pdf new file mode 100644 index 00000000..71b38e9b Binary files /dev/null and b/docs/led-menu-guide.pdf differ diff --git a/zephcore/adapters/board/ZephyrBoard.cpp b/zephcore/adapters/board/ZephyrBoard.cpp index 48320792..4ba758c8 100644 --- a/zephcore/adapters/board/ZephyrBoard.cpp +++ b/zephcore/adapters/board/ZephyrBoard.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -97,19 +98,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 +/* 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); +#define HAS_TX_LED_PWM 0 #define HAS_TX_LED 1 #else +#define HAS_TX_LED_PWM 0 #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). 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. */ @@ -120,10 +136,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); @@ -173,8 +215,8 @@ static const struct device *const fuel_gauge_dev = #define HAS_FUEL_GAUGE 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 @@ -187,7 +229,9 @@ static const struct device *const fuel_gauge_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; @@ -199,7 +243,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 @@ -207,9 +251,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; } @@ -359,12 +409,14 @@ 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. */ + * 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)) { @@ -375,16 +427,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 +#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 @@ -393,7 +445,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)) { @@ -409,7 +461,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)); diff --git a/zephcore/adapters/datastore/ZephyrDataStore.cpp b/zephcore/adapters/datastore/ZephyrDataStore.cpp index f87330ff..c991381e 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.cpp +++ b/zephcore/adapters/datastore/ZephyrDataStore.cpp @@ -907,6 +907,14 @@ void ZephyrDataStore::loadPrefs(NodePrefs &prefs) if (off < len) { prefs.leds_hb_mode = buf[off++]; } + /* Offset 174: led_brightness (ZephCore extension, since 1.17.4). Absent + * in pre-existing files -> stays at the initNodePrefs() default of + * ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT (10%), same value a fresh node + * already showed before this field was persisted. Range re-checked by + * sanitizeNodePrefs() below. */ + if (off < len) { + prefs.led_brightness = buf[off++]; + } sanitizeNodePrefs(&prefs); } @@ -1022,7 +1030,9 @@ void ZephyrDataStore::savePrefs(const NodePrefs &prefs) /* Offset 172-173: leds_radio_mode / leds_hb_mode (ZephCore extension). */ buf[off++] = prefs.leds_radio_mode; buf[off++] = prefs.leds_hb_mode; - /* Total: 174 bytes */ + /* Offset 174: led_brightness (ZephCore extension, since 1.17.4). */ + buf[off++] = prefs.led_brightness; + /* Total: 175 bytes */ bool ok = atomicReplaceFile(PREFS_FILE, buf, off); LOG_DBG("savePrefs: wrote %s, ok=%d (%d bytes), name='%.16s'", diff --git a/zephcore/app/RepeaterDataStore.cpp b/zephcore/app/RepeaterDataStore.cpp index 6dce7530..9c0d5ae4 100644 --- a/zephcore/app/RepeaterDataStore.cpp +++ b/zephcore/app/RepeaterDataStore.cpp @@ -301,6 +301,11 @@ bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) { * (activity LED on transmit, heartbeat with unread indication). */ fs_read(&file, &prefs.leds_radio_mode, sizeof(prefs.leds_radio_mode)); fs_read(&file, &prefs.leds_hb_mode, sizeof(prefs.leds_hb_mode)); + /* LED brightness, offset 311 (ZephCore extension, since 1.17.4). Absent + * in <312-byte files; leaves the initNodePrefs() default of + * ZEPHCORE_LED_DEFAULT_BRIGHTNESS_PCT (10%), same value a fresh node + * already showed before this field was persisted. */ + fs_read(&file, &prefs.led_brightness, sizeof(prefs.led_brightness)); fs_close(&file); @@ -467,6 +472,8 @@ bool RepeaterDataStore::savePrefs(const NodePrefs& prefs) { /* LED activity/heartbeat modes (offsets 309-310) */ fs_write(&file, &prefs.leds_radio_mode, sizeof(prefs.leds_radio_mode)); fs_write(&file, &prefs.leds_hb_mode, sizeof(prefs.leds_hb_mode)); + /* LED brightness (offset 311, since 1.17.4) */ + fs_write(&file, &prefs.led_brightness, sizeof(prefs.led_brightness)); ret = fs_sync(&file); fs_close(&file); diff --git a/zephcore/app/main_observer.cpp b/zephcore/app/main_observer.cpp index bf78338f..fe41819c 100644 --- a/zephcore/app/main_observer.cpp +++ b/zephcore/app/main_observer.cpp @@ -385,6 +385,7 @@ int main(void) zephcore_leds_set_disabled(leds_off); zephcore_leds_set_radio_mode(prefs->leds_radio_mode); zephcore_leds_set_hb_mode(prefs->leds_hb_mode); + zephcore_led_set_brightness_pct(prefs->led_brightness); LOG_INF("LEDs: %s (from prefs)", leds_off ? "disabled" : "enabled"); } 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_wifi_lora32_v43/heltec_wifi_lora32_v43_procpu.dts b/zephcore/boards/esp32/heltec_wifi_lora32_v43/heltec_wifi_lora32_v43_procpu.dts index 8539360e..3a8e266b 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; 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 3a02f53e..f3baadf8 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 = ; 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 38bdc1cd..183a5599 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"; @@ -279,6 +304,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 b99b9e54..d9b83022 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -422,8 +422,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch } else if (memcmp(config, "int.thresh", 10) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->interference_threshold); /* 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. */ } else if (memcmp(config, "leds.radio", 10) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %s%s", cliModeName(LEDS_RADIO_NAMES, 4, _prefs->leds_radio_mode), @@ -432,6 +432,14 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch 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. 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 @@ -741,18 +749,52 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch snprintf(reply, CLI_REPLY_SIZE, "OK%s", 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 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); + 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 { - _prefs->leds_disabled = on ? 0 : 1; - zephcore_leds_set_disabled(_prefs->leds_disabled != 0); + _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) { + /* 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); + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error: must be on or off"); } #ifndef ZEPHCORE_REPEATER } else if (memcmp(config, "buzzer ", 7) == 0) { diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index 2779a81a..85eb3166 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -95,6 +95,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; // GPS settings @@ -284,6 +285,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 @@ -386,6 +388,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; // 100%, new node prefs->powersaving_enabled = 0; prefs->gps_enabled = 0; prefs->gps_interval = 300; // 5 minutes 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 020ce72a..29ec69b8 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)). + * 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); + /* 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-button/ui_pages.c b/zephcore/helpers/ui-button/ui_pages.c index 0822baf2..fdb566cb 100644 --- a/zephcore/helpers/ui-button/ui_pages.c +++ b/zephcore/helpers/ui-button/ui_pages.c @@ -18,6 +18,7 @@ #include "ui_task.h" #include "display.h" #include +#include #include #include @@ -1303,36 +1304,148 @@ static void render_buzzer(void) draw_centered(y + 8, buf); } +/* Step size (percentage points) for the LEDs submenu's three brightness + * rows (1/2/3) — row 0 is the plain on/off toggle, no step. Mirrors + * leds_menu_step() in ui_task.c, which applies the actual +/-step on + * tap/2-tap; kept as a separate copy since that file owns input handling + * and this one owns rendering, matching this codebase's per-file split. */ +static inline uint8_t leds_menu_row_step(uint8_t row) +{ + switch (row) { + case 1: return 20; + case 2: return 5; + default: return 1; + } +} + static void render_leds_mono(void) { char buf[24]; + + if (state.leds_menu_level == 0) { + int y = CONTENT_Y; + + snprintf(buf, sizeof(buf), "LEDs: %s", + state.leds_disabled ? "OFF" : "ON"); + mc_display_text(0, y, buf, false); + y += LINE_H; + + snprintf(buf, sizeof(buf), "Brightness: %u%%", + zephcore_led_brightness_pct()); + mc_display_text(0, y, buf, false); + y += LINE_H; + + draw_centered(y + 8, "Long press: menu"); + return; + } + + if (state.leds_menu_level == 1) { + uint8_t brightness = zephcore_led_brightness_pct(); + int y = CONTENT_Y; + + for (uint8_t row = 0; row < 4; row++) { + const char *cur = (state.leds_menu_row == row) ? "> " : " "; + + if (row == 0) { + snprintf(buf, sizeof(buf), "%sLEDs: %s", cur, + state.leds_disabled ? "OFF" : "ON"); + } else { + snprintf(buf, sizeof(buf), "%sBright +/-%02u%% %u%%", cur, + leds_menu_row_step(row), brightness); + } + mc_display_text(0, y, buf, false); + y += LINE_H; + } + return; + } + + /* leds_menu_level == 2: value edit for the selected brightness row. + * Title flush at the top (matches every other page's anchor), then + * the three tap/hold hints with aligned colons -- no blank line + * (4 lines total, same budget as the row-select list). */ + uint8_t step = leds_menu_row_step(state.leds_menu_row); + uint8_t brightness = zephcore_led_brightness_pct(); int y = CONTENT_Y; - snprintf(buf, sizeof(buf), "LEDs: %s", - state.leds_disabled ? "off" : "on"); + snprintf(buf, sizeof(buf), "Bright +/-%02u%% %u%%", step, brightness); mc_display_text(0, y, buf, false); y += LINE_H; - draw_centered(y + 8, - state.leds_disabled ? "Press to Enable" : "Press to Disable"); + mc_display_text(0, y, "1x TAP : +", false); + y += LINE_H; + mc_display_text(0, y, "2x TAPs : -", false); + y += LINE_H; + mc_display_text(0, y, "Hold : OK", false); } #if MC_DISPLAY_COLOR_PANEL static void render_leds_color(void) { + char buf[28]; + + if (state.leds_menu_level == 0) { + int y = CONTENT_Y; + + draw_badge(0, y, "LEDs", state.leds_disabled ? UI_COLOR_DISABLED + : UI_COLOR_OK); + mc_display_color_text(32, y, + state.leds_disabled ? "OFF" : "ON", + state.leds_disabled ? UI_COLOR_DISABLED + : UI_COLOR_OK); + y += LINE_H + 4; + snprintf(buf, sizeof(buf), "Brightness: %u%%", + zephcore_led_brightness_pct()); + draw_centered_color(y, buf, UI_COLOR_VALUE); + y += LINE_H; + draw_centered_color(y, "Long press: menu", UI_COLOR_LABEL); + return; + } + + if (state.leds_menu_level == 1) { + uint8_t brightness = zephcore_led_brightness_pct(); + int y = CONTENT_Y; + + for (uint8_t row = 0; row < 4; row++) { + bool sel = (state.leds_menu_row == row); + const char *cur = sel ? "> " : " "; + /* Every row, including on/off, follows the same rule: + * selected = highlight color, otherwise plain label + * color — no special-casing the on/off status here, + * so green only ever means "cursor is here", matching + * rows 1-3 (avoids implying the cursor sits on row 0 + * when it doesn't). */ + uint16_t color = sel ? UI_COLOR_OK : UI_COLOR_LABEL; + + if (row == 0) { + snprintf(buf, sizeof(buf), "%sLEDs: %s", cur, + state.leds_disabled ? "OFF" : "ON"); + } else { + snprintf(buf, sizeof(buf), "%sBright. +/-%02u%% %u%%", cur, + leds_menu_row_step(row), brightness); + } + mc_display_color_text(0, y, buf, color); + y += LINE_H; + } + return; + } + + /* leds_menu_level == 2: value edit for the selected brightness row. + * Title flush at the top (matches every other page's anchor), then + * the three tap/hold hints with aligned colons -- no blank line + * (4 lines total, same budget as the row-select list). */ + uint8_t step = leds_menu_row_step(state.leds_menu_row); + uint8_t brightness = zephcore_led_brightness_pct(); int y = CONTENT_Y; - draw_badge(0, y, "LED", state.leds_disabled ? UI_COLOR_DISABLED - : UI_COLOR_OK); - mc_display_color_text(32, y, - state.leds_disabled ? "off" : "on", - state.leds_disabled ? UI_COLOR_DISABLED - : UI_COLOR_OK); - y += LINE_H + 4; - draw_centered_color(y, - state.leds_disabled ? "Press to enable" - : "Press to disable", - UI_COLOR_VALUE); + snprintf(buf, sizeof(buf), "Bright. +/-%02u%% %u%%", step, brightness); + mc_display_color_text(0, y, buf, UI_COLOR_VALUE); + y += LINE_H; + + mc_display_color_text(0, y, "1x TAP : +", UI_COLOR_LABEL); + y += LINE_H; + mc_display_color_text(0, y, "2x TAPs : -", UI_COLOR_LABEL); + y += LINE_H; + mc_display_color_text(0, y, "Hold : OK", UI_COLOR_LABEL); } #endif /* MC_DISPLAY_COLOR_PANEL */ diff --git a/zephcore/helpers/ui-button/ui_pages.h b/zephcore/helpers/ui-button/ui_pages.h index b924f22c..2a2191d5 100644 --- a/zephcore/helpers/ui-button/ui_pages.h +++ b/zephcore/helpers/ui-button/ui_pages.h @@ -97,6 +97,14 @@ struct ui_state { /* LEDs page */ bool leds_disabled; /* true = LEDs off */ + /* On-screen LEDs submenu (long press to enter, see action_page_enter() + * and action_page_next/prev() in ui_task.c): + * 0 = not in the submenu (plain "LEDs: on/off" page) + * 1 = row select — leds_menu_row picks 0=on/off, 1/2/3=brightness step + * 2 = value edit — leds_menu_row's row is being adjusted + * Always reset to 0 when leaving the submenu; never persisted. */ + uint8_t leds_menu_level; + uint8_t leds_menu_row; /* Sensors page */ int16_t temperature_c10; /* centi-degrees C */ diff --git a/zephcore/helpers/ui-button/ui_task.c b/zephcore/helpers/ui-button/ui_task.c index 969cbe87..e85e752c 100644 --- a/zephcore/helpers/ui-button/ui_task.c +++ b/zephcore/helpers/ui-button/ui_task.c @@ -35,6 +35,7 @@ #include "ui_task.h" #include "ui_pages.h" #include +#include #include #ifdef CONFIG_ZEPHCORE_UI_BUZZER @@ -237,9 +238,43 @@ static void schedule_render(void) /* ========== Button Action Functions ========== */ /* Each action checks capabilities internally — no #ifdef in the switch. */ +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY +/* Step size (percentage points) for each of the LEDs page's three + * brightness rows — see render_leds()/render_leds_mono()/render_leds_color() + * in ui_pages.c for the on-screen "Bright +/-N%" labels. Row 0 is the + * plain on/off toggle and has no step. */ +static inline uint8_t leds_menu_step(uint8_t row) +{ + switch (row) { + case 1: return 20; + case 2: return 5; + default: return 1; + } +} +#endif + static void action_page_next(void) { #ifdef CONFIG_ZEPHCORE_UI_DISPLAY + struct ui_state *s = get_state(); + + if (s->leds_menu_level == 1) { + /* Row select (level 1): single tap cycles to the next row */ + s->leds_menu_row = (uint8_t)((s->leds_menu_row + 1) % 4); + schedule_render(); + return; + } + if (s->leds_menu_level == 2) { + /* Value edit (level 2): single tap = +step, clamped at 100 */ + uint8_t step = leds_menu_step(s->leds_menu_row); + uint8_t cur = zephcore_led_brightness_pct(); + uint8_t next = (uint8_t)((cur + step > 100) ? 100 : cur + step); + + zephcore_led_set_brightness_pct(next); + mesh_set_led_brightness_pct(next); + schedule_render(); + return; + } ui_pages_next(); #endif schedule_render(); @@ -248,6 +283,27 @@ static void action_page_next(void) static void action_page_prev(void) { #ifdef CONFIG_ZEPHCORE_UI_DISPLAY + struct ui_state *s = get_state(); + + if (s->leds_menu_level == 1) { + /* Row select (level 1): "2 taps" backs out of the menu one + * level at a time, same as everywhere else in this UI. */ + s->leds_menu_level = 0; + schedule_render(); + return; + } + if (s->leds_menu_level == 2) { + /* Value edit (level 2): "2 taps" = -step, clamped at 0 — + * the mirror of the single-tap +step above. */ + uint8_t step = leds_menu_step(s->leds_menu_row); + uint8_t cur = zephcore_led_brightness_pct(); + uint8_t next = (uint8_t)((cur < step) ? 0 : cur - step); + + zephcore_led_set_brightness_pct(next); + mesh_set_led_brightness_pct(next); + schedule_render(); + return; + } ui_pages_prev(); #endif schedule_render(); @@ -300,10 +356,38 @@ static void action_page_enter(void) action_buzzer_toggle(); break; - case UI_PAGE_LEDS: - /* Toggle LED on/off */ - action_leds_toggle(); + case UI_PAGE_LEDS: { + /* 3-level menu: level 0 (not shown here, see the leds_menu_level + * checks in action_page_next/prev above) is the plain "LEDs page, + * long press" state. Long press: + * level 0 → 1: enter the menu (row select), cursor on row 0 + * level 1 → row 0 selected: toggle on/off, stay in row select + * level 1 → row 1/2/3 selected: enter value edit for that row + * level 2 → confirm, back to row select (one level, not exit) */ + struct ui_state *s_led = get_state(); + + switch (s_led->leds_menu_level) { + case 0: + s_led->leds_menu_level = 1; + s_led->leds_menu_row = 0; + schedule_render(); + break; + case 1: + if (s_led->leds_menu_row == 0) { + action_leds_toggle(); + } else { + s_led->leds_menu_level = 2; + schedule_render(); + } + break; + case 2: + default: + s_led->leds_menu_level = 1; + schedule_render(); + break; + } break; + } case UI_PAGE_OFFGRID: { /* Double-press confirmation (CONFIRM_WINDOW_MS window) */ diff --git a/zephcore/helpers/ui/ui_common.c b/zephcore/helpers/ui/ui_common.c index 46d6171b..a880c1bd 100644 --- a/zephcore/helpers/ui/ui_common.c +++ b/zephcore/helpers/ui/ui_common.c @@ -22,6 +22,7 @@ #include "led_gate.h" /* shared with the LoRa TX LED */ #include +#include #include #include #include @@ -95,21 +96,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); @@ -122,7 +165,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; @@ -167,9 +210,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); @@ -187,7 +232,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 @@ -201,7 +246,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 @@ -212,9 +257,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); @@ -231,15 +280,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 @@ -257,18 +306,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); } } @@ -292,11 +341,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 @@ -308,12 +357,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)); } diff --git a/zephcore/helpers/ui/ui_mesh_actions.cpp b/zephcore/helpers/ui/ui_mesh_actions.cpp index 21acdc7e..911de9c6 100644 --- a/zephcore/helpers/ui/ui_mesh_actions.cpp +++ b/zephcore/helpers/ui/ui_mesh_actions.cpp @@ -48,6 +48,7 @@ LOG_MODULE_REGISTER(zephcore_ui_actions, CONFIG_ZEPHCORE_UI_ACTIONS_LOG_LEVEL); #define UI_ACTION_GPS_DUTY_SAVE BIT(12) #define UI_ACTION_DISPLAY_ROTATE_SAVE BIT(13) #define UI_ACTION_INPUT_ROTATE_SAVE BIT(14) +#define UI_ACTION_LED_BRIGHTNESS_SAVE BIT(15) /* Module-local pointers, set by init */ static CompanionMesh *s_mesh; @@ -70,6 +71,7 @@ static atomic_t pending_offgrid_enabled; static atomic_t pending_leds_disabled; static atomic_t pending_ble_disabled; static atomic_t pending_brightness; +static atomic_t pending_led_brightness; static atomic_t pending_wake_on_msg; static atomic_t pending_screen_off_secs; static atomic_t pending_path_hash_mode; @@ -208,6 +210,18 @@ extern "C" void mesh_set_leds_disabled(bool disabled) k_event_post(s_mesh_events, s_mesh_event_ui_action); } +/* LED PWM brightness (0-100), distinct from prefs.display_brightness (screen + * backlight, see mesh_save_brightness). Caller applies the live effect via + * zephcore_led_set_brightness_pct() directly (lock-free atomic, same as the + * CLI path in CommonCLI::handleCommand) before calling this — this only + * defers the flash write to the mesh thread. */ +extern "C" void mesh_set_led_brightness_pct(uint8_t pct) +{ + atomic_set(&pending_led_brightness, (atomic_val_t)pct); + atomic_or(&pending_ui_actions, UI_ACTION_LED_BRIGHTNESS_SAVE); + k_event_post(s_mesh_events, s_mesh_event_ui_action); +} + /* Disable power regulators for System OFF. * Only touches sensor power and buzzer power-gate regulators. * GPS is handled separately by gps_power_off_for_shutdown(). @@ -288,6 +302,13 @@ extern "C" void mesh_handle_ui_actions(void) need_save = true; } + if (actions & UI_ACTION_LED_BRIGHTNESS_SAVE) { + uint8_t br = (uint8_t)atomic_get(&pending_led_brightness); + s_mesh->prefs.led_brightness = br; + LOG_INF("led_brightness=%u%% (button)", br); + need_save = true; + } + if (actions & UI_ACTION_BLE_TOGGLE) { bool bd = atomic_get(&pending_ble_disabled) != 0; s_mesh->prefs.ble_disabled = bd ? 1 : 0; diff --git a/zephcore/helpers/ui/ui_mesh_actions.h b/zephcore/helpers/ui/ui_mesh_actions.h index 032b499b..1adf7667 100644 --- a/zephcore/helpers/ui/ui_mesh_actions.h +++ b/zephcore/helpers/ui/ui_mesh_actions.h @@ -58,6 +58,9 @@ void mesh_ble_set_enabled(bool enable); void mesh_set_buzzer_mode(uint8_t mode); void mesh_set_offgrid_mode(bool enable); void mesh_set_leds_disabled(bool disabled); +/* LED PWM brightness (0-100). Caller must apply the live effect itself via + * zephcore_led_set_brightness_pct() before calling this — it only persists. */ +void mesh_set_led_brightness_pct(uint8_t pct); void mesh_disable_power_regulators(void); void mesh_reboot_to_ota_dfu(void); void mesh_save_brightness(uint8_t brightness); diff --git a/zephcore/helpers/ui/ui_mesh_actions_stubs.c b/zephcore/helpers/ui/ui_mesh_actions_stubs.c index 555a13e0..5313923c 100644 --- a/zephcore/helpers/ui/ui_mesh_actions_stubs.c +++ b/zephcore/helpers/ui/ui_mesh_actions_stubs.c @@ -20,6 +20,7 @@ __attribute__((weak)) void mesh_ble_set_enabled(bool enable) { ARG_UNUSED(enable __attribute__((weak)) void mesh_set_buzzer_mode(uint8_t mode) { ARG_UNUSED(mode); } __attribute__((weak)) void mesh_set_offgrid_mode(bool enable) { ARG_UNUSED(enable); } __attribute__((weak)) void mesh_set_leds_disabled(bool disabled) { ARG_UNUSED(disabled); } +__attribute__((weak)) void mesh_set_led_brightness_pct(uint8_t pct) { ARG_UNUSED(pct); } __attribute__((weak)) void mesh_disable_power_regulators(void) {} __attribute__((weak)) void mesh_reboot_to_ota_dfu(void) {} /* Repeater builds persist orientation through RepeaterDataStore in diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index a347485e..607cb588 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -1603,6 +1603,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"); diff --git a/zephcore/src/main_repeater.cpp b/zephcore/src/main_repeater.cpp index a6ab9e7f..8a66f7e1 100644 --- a/zephcore/src/main_repeater.cpp +++ b/zephcore/src/main_repeater.cpp @@ -775,6 +775,7 @@ int main(void) zephcore_leds_set_disabled(leds_off); zephcore_leds_set_radio_mode(lp->leds_radio_mode); zephcore_leds_set_hb_mode(lp->leds_hb_mode); + zephcore_led_set_brightness_pct(lp->led_brightness); LOG_INF("LEDs: %s (from prefs)", leds_off ? "disabled" : "enabled"); } diff --git a/zephcore/src/main_room_server.cpp b/zephcore/src/main_room_server.cpp index 0c7718e2..18f20bb2 100644 --- a/zephcore/src/main_room_server.cpp +++ b/zephcore/src/main_room_server.cpp @@ -660,6 +660,7 @@ int main(void) zephcore_leds_set_disabled(leds_off); zephcore_leds_set_radio_mode(lp->leds_radio_mode); zephcore_leds_set_hb_mode(lp->leds_hb_mode); + zephcore_led_set_brightness_pct(lp->led_brightness); LOG_INF("LEDs: %s (from prefs)", leds_off ? "disabled" : "enabled"); }