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/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" }, { diff --git a/tests/unit/prefs_codec_tests.cpp b/tests/unit/prefs_codec_tests.cpp index c8f26d80..2ae96990 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), @@ -30,10 +30,10 @@ const Field kCompanion[] = { F(158, cad_busycap), F(159, adc_multiplier), F(163, extra_sf), F(166, v_contact_flags), F(167, fem_rxgain), F(168, display_rotate), F(169, input_rotate), F(170, cad_base), F(171, tz_offset), F(172, leds_radio_mode), F(173, leds_hb_mode), F(174, wifi_enabled), - F(175, wifi_ssid), F(208, wifi_pwd), + F(175, wifi_ssid), F(208, wifi_pwd), F(272, led_brightness), }; -// 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, 208, 272, 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/adapters/board/ZephyrBoard.cpp b/zephcore/adapters/board/ZephyrBoard.cpp index 7310826f..b00645a8 100644 --- a/zephcore/adapters/board/ZephyrBoard.cpp +++ b/zephcore/adapters/board/ZephyrBoard.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -57,22 +58,24 @@ #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 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 +/* 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); +#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 - * 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 @@ -80,10 +83,26 @@ 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 +/* 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 #else #define ZEPHCORE_LED_PIN_SHARED 0 #endif +#if HAS_TX_LED_PWM +static inline void tx_led_write(bool on) +{ + zephcore_led_pwm_write(&tx_led_pwm, on); +} +#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); @@ -139,8 +158,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 @@ -165,7 +184,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 @@ -173,9 +192,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; } @@ -364,12 +389,12 @@ 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 || HAS_TX_LED uint8_t mode = zephcore_leds_radio_mode(); if (!zephcore_leds_disabled() && (mode == LEDS_RADIO_TX || mode == LEDS_RADIO_ALL)) { @@ -380,16 +405,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 @@ -398,7 +423,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)) { @@ -414,7 +439,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/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/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_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; 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/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..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 @@ -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,21 @@ }; }; + /* 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: 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"; + + 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 +249,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/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/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..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)) @@ -982,8 +984,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,6 +994,8 @@ 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) { + 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 @@ -1263,10 +1267,24 @@ 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) { + 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->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 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. */ + * 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"); diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index c5e2b289..89139f92 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; // 100%, 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..9e8b74f5 100644 --- a/zephcore/helpers/PrefsCodec.cpp +++ b/zephcore/helpers/PrefsCodec.cpp @@ -109,6 +109,7 @@ size_t companionPrefsEncode(const NodePrefs &p, uint8_t *buf, size_t cap) w.put(&p.wifi_enabled, 1); w.put(p.wifi_ssid, sizeof(p.wifi_ssid)); w.put(p.wifi_pwd, sizeof(p.wifi_pwd)); + w.put(&p.led_brightness, 1); return w.off; } @@ -190,6 +191,8 @@ bool companionPrefsDecode(NodePrefs &prefs, const uint8_t *buf, size_t len) r.take(&p.wifi_enabled, 1); r.take(p.wifi_ssid, sizeof(p.wifi_ssid)); r.take(p.wifi_pwd, sizeof(p.wifi_pwd)); + /* led_brightness at offset 272, after wifi_pwd: a 272-byte file leaves it at the initNodePrefs() default. */ + r.take(&p.led_brightness, 1); /* The one validator: bounds, NaNs, and the unterminated char fields */ sanitizeNodePrefs(&p); @@ -280,6 +283,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 at offset 311. */ + w.put(&prefs.led_brightness, sizeof(prefs.led_brightness)); return w.off; } @@ -355,6 +360,8 @@ void serverPrefsDecode(NodePrefs &prefs, const uint8_t *buf, size_t len) r.get(&prefs.tz_offset, sizeof(prefs.tz_offset)); r.get(&prefs.leds_radio_mode, sizeof(prefs.leds_radio_mode)); r.get(&prefs.leds_hb_mode, sizeof(prefs.leds_hb_mode)); + /* led_brightness at offset 311: a shorter file leaves the initNodePrefs() default. */ + r.get(&prefs.led_brightness, sizeof(prefs.led_brightness)); /* Only the explicit "off" magic disables LEDs; a legacy AGC interval or an * unwritten byte both mean "on". */ diff --git a/zephcore/helpers/PrefsCodec.h b/zephcore/helpers/PrefsCodec.h index 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), 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/helpers/led_gate.c b/zephcore/helpers/led_gate.c index 610bbb68..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 @@ -74,3 +75,26 @@ void zephcore_led_radio_hold_pin(bool held) { atomic_set(&s_radio_holds_pin, held ? 1 : 0); } + +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); +} + +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 726dfacf..eeb8c9ee 100644 --- a/zephcore/helpers/led_gate.h +++ b/zephcore/helpers/led_gate.h @@ -34,6 +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 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 ca7865d8..a54f9067 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,52 @@ 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 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)); +#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 +#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) +{ + zephcore_led_pwm_write(&s_heartbeat_led_pwm, on); +} +#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 +135,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 +175,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 +197,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 +211,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 +222,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 +245,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 +271,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 +306,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 +322,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/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"); 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); }