From 4e880df81aea670b0507b1ad062c75b562f1bc00 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:27:10 +0200 Subject: [PATCH] set leds on/off --- zephcore/ARCHITECTURE.md | 13 ++++++- zephcore/CMakeLists.txt | 3 ++ zephcore/Repeater_CLI_commands.md | 2 ++ zephcore/adapters/board/ZephyrBoard.cpp | 9 ++++- zephcore/app/RepeaterDataStore.cpp | 16 +++++++-- zephcore/app/main_observer.cpp | 10 ++++++ zephcore/helpers/CommonCLI.cpp | 39 +++++++++++++++++++-- zephcore/helpers/NodePrefs.h | 24 +++++++++---- zephcore/helpers/led_gate.c | 36 ++++++++++++++++++++ zephcore/helpers/led_gate.h | 45 +++++++++++++++++++++++++ zephcore/helpers/ui/ui_common.c | 31 ++++++++++++----- zephcore/helpers/ui/ui_headless_stubs.c | 5 ++- zephcore/src/main_companion.cpp | 7 ++-- zephcore/src/main_repeater.cpp | 21 ++++++++++++ zephcore/src/main_room_server.cpp | 21 ++++++++++++ 15 files changed, 259 insertions(+), 23 deletions(-) create mode 100644 zephcore/helpers/led_gate.c create mode 100644 zephcore/helpers/led_gate.h diff --git a/zephcore/ARCHITECTURE.md b/zephcore/ARCHITECTURE.md index cc2b3b7..b362e9f 100644 --- a/zephcore/ARCHITECTURE.md +++ b/zephcore/ARCHITECTURE.md @@ -687,9 +687,18 @@ Repeaters and room servers default to `CONFIG_ZEPHCORE_REPEATER_GPS_INTERVAL_SEC - Battery ADC with optional regulator-gated voltage divider, 8-sample average (boards with `zephyr,user` ADC node; MG24 has no battery divider, ADC disabled) - UF2 bootloader entry via GPREGRET magic (0x57 = UF2, 0xA8 = BLE DFU) -- TX LED bracketing for LoRa transmissions +- TX LED bracketing for LoRa transmissions (gated by the LED master switch below) - Bootloader version detection via flash memory scan +**LED master switch** (`helpers/led_gate.{c,h}`, `set leds on|off`, all roles): one process-wide +flag every LED driver consults — heartbeat and unread-message LEDs in `helpers/ui/ui_common.c`, the +`lora-tx-led` in `ZephyrBoard::onBeforeTransmit()`, and the message/shutdown flashes. It lives +outside the UI layer because `ui_common.c` is only compiled when a UI is enabled, while a headless +repeater still blinks on every transmit. `ui_common.c` overrides the weak `zephcore_leds_ui_sync()` +hook so a CLI change also stops a lit heartbeat and refreshes the UI's LEDs page. Persisted in +`NodePrefs.leds_disabled` (companion offset 93; repeater offset 120, magic-encoded — see §13). +Does not cover the display backlight, which has its own UI brightness setting (`display_brightness`). + ### 7.6 WiFi / MQTT / TCP Transports - **`adapters/wifi/ZephyrWiFiStation.c`**: WiFi STA client (ESP32) used by observer and repeater uplink @@ -991,6 +1000,8 @@ meshtimesync(151). **Repeater/room-server `/lfs/repeater/prefs` (297 bytes)** — `app/RepeaterDataStore.cpp` `loadPrefs()`/`savePrefs()` (same field order as `helpers/CommonCLI.cpp`; offset comments inline). Key ranges: name(4-36), radio(72-119), adaptive-delay(80-111, ignored at runtime), +leds_disabled(120, magic-encoded `0xA0`/`0xA1` — the byte formerly held `agc_reset_interval`, which +stored seconds/4, so any other value is a legacy interval and decodes to "LEDs on"), Arduino-bridge(127-151, read+discarded), GPS(156-161), owner_info(170-290), rx_boost/duty(290-291), reserved(292-293, was APC), flood_max_unscoped/advert(294-295), meshtimesync(296). Older shorter files load cleanly — reads past EOF are no-ops, so newer fields keep their defaults and a one-time diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index 3eec222..5f7731e 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -568,6 +568,9 @@ target_sources(app PRIVATE helpers/MeshTimeSync.cpp helpers/oled_power.c helpers/fatal_reboot.c + # LED master gate ("set leds on|off"). Always compiled: helpers/ui/ui_common.c + # only exists in UI builds, but a headless repeater still drives lora-tx-led. + helpers/led_gate.c ) # Boot-time hardware-RTC auto-discovery (compact raw-I2C). Always compiled so diff --git a/zephcore/Repeater_CLI_commands.md b/zephcore/Repeater_CLI_commands.md index 3ffc021..5f85250 100644 --- a/zephcore/Repeater_CLI_commands.md +++ b/zephcore/Repeater_CLI_commands.md @@ -208,6 +208,7 @@ All `set uplink.*` changes are saved immediately and only applied after reboot. | `get guest.password` | Guest access password | | `get owner.info` | Owner/contact info (pipes `\|` display as newlines) | | `get int.thresh` | Interference threshold | +| `get leds` | LED master switch: `on` or `off` | | `get agc.reset.interval` | Removed - replies `use rxduty instead` | | `get multi.acks` | Extra ACK transmit count (`0` or `1`) | | `get path.hash.mode` | Path hashing algorithm: `0`, `1`, or `2` | @@ -254,6 +255,7 @@ Changes are persisted immediately unless noted. Some require a reboot. | `set guest.password ` | | Set guest access password | | `set owner.info ` | Use `\|` for newlines | Owner/contact information | | `set int.thresh ` | | Interference detection threshold | +| `set leds ` | default **on** | 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 agc.reset.interval ` | Accepted, ignored | Removed - replies `use rxduty instead` | | `set multi.acks <0\|1>` | | Enable extra ACK transmits | | `set path.hash.mode ` | 0, 1, or 2 | Path hashing algorithm | diff --git a/zephcore/adapters/board/ZephyrBoard.cpp b/zephcore/adapters/board/ZephyrBoard.cpp index 7a5dd16..870b840 100644 --- a/zephcore/adapters/board/ZephyrBoard.cpp +++ b/zephcore/adapters/board/ZephyrBoard.cpp @@ -4,6 +4,7 @@ #include "ZephyrBoard.h" #include "battery_curve.h" +#include "led_gate.h" #include #include #include @@ -246,7 +247,13 @@ const char *ZephyrBoard::getManufacturerName() const void ZephyrBoard::onBeforeTransmit() { #if HAS_TX_LED - gpio_pin_set_dt(&tx_led, 1); + /* Honour the LED master gate ("set leds off"). On a headless repeater this + * is the only LED that ever lights, so the gate has to be checked here and + * not just in the UI layer. onAfterTransmit() still clears the pin + * unconditionally, so a gate flipped mid-transmit can't strand it lit. */ + if (!zephcore_leds_disabled()) { + gpio_pin_set_dt(&tx_led, 1); + } #endif } diff --git a/zephcore/app/RepeaterDataStore.cpp b/zephcore/app/RepeaterDataStore.cpp index 349675e..536434f 100644 --- a/zephcore/app/RepeaterDataStore.cpp +++ b/zephcore/app/RepeaterDataStore.cpp @@ -173,7 +173,11 @@ bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) { fs_read(&file, &prefs.allow_read_only, sizeof(prefs.allow_read_only)); fs_read(&file, &prefs.multi_acks, sizeof(prefs.multi_acks)); fs_read(&file, &prefs.bw, sizeof(prefs.bw)); - fs_read(&file, &prefs.agc_reset_interval, sizeof(prefs.agc_reset_interval)); + /* 120: leds_disabled, magic-encoded. Formerly agc_reset_interval — see the + * LEDS_PREF_* comment in NodePrefs.h for why this is not a bare 0/1. + * leds_byte stays 0 (→ LEDs on) if the file is short. */ + uint8_t leds_byte = 0; + fs_read(&file, &leds_byte, sizeof(leds_byte)); fs_read(&file, &prefs.path_hash_mode, sizeof(prefs.path_hash_mode)); fs_read(&file, &prefs.loop_detect, sizeof(prefs.loop_detect)); fs_read(&file, pad, 1); @@ -215,6 +219,10 @@ bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) { fs_close(&file); + /* Only the explicit "off" magic disables LEDs; a legacy AGC interval or an + * unwritten byte both mean "on". */ + prefs.leds_disabled = (leds_byte == LEDS_PREF_OFF) ? 1 : 0; + /* Migrate uninitialized backoff_multiplier (0.0 or NaN) to default */ if (prefs.backoff_multiplier == 0.0f || prefs.backoff_multiplier != prefs.backoff_multiplier) { prefs.backoff_multiplier = 0.2f; @@ -318,7 +326,11 @@ bool RepeaterDataStore::savePrefs(const NodePrefs& prefs) { fs_write(&file, &prefs.allow_read_only, sizeof(prefs.allow_read_only)); fs_write(&file, &prefs.multi_acks, sizeof(prefs.multi_acks)); fs_write(&file, &prefs.bw, sizeof(prefs.bw)); - fs_write(&file, &prefs.agc_reset_interval, sizeof(prefs.agc_reset_interval)); + /* 120: leds_disabled, magic-encoded (was agc_reset_interval). */ + { + uint8_t leds_byte = prefs.leds_disabled ? LEDS_PREF_OFF : LEDS_PREF_ON; + fs_write(&file, &leds_byte, sizeof(leds_byte)); + } fs_write(&file, &prefs.path_hash_mode, sizeof(prefs.path_hash_mode)); fs_write(&file, &prefs.loop_detect, sizeof(prefs.loop_detect)); fs_write(&file, pad, 1); diff --git a/zephcore/app/main_observer.cpp b/zephcore/app/main_observer.cpp index 003777a..eaecd96 100644 --- a/zephcore/app/main_observer.cpp +++ b/zephcore/app/main_observer.cpp @@ -38,6 +38,7 @@ LOG_MODULE_REGISTER(zephcore_observer_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #include #include #include "observer_creds.h" +#include /* ========== LED (optional) ========== */ @@ -357,6 +358,15 @@ int main(void) data_store.savePrefs(*prefs); } + /* Apply the persisted LED master switch. The observer has no CLI of its own + * to change it, but a unit reflashed from a repeater build keeps the setting + * — and it still drives lora-tx-led on TX-capable boards. */ + { + bool leds_off = prefs->leds_disabled != 0; + zephcore_leds_set_disabled(leds_off); + LOG_INF("LEDs: %s (from prefs)", leds_off ? "disabled" : "enabled"); + } + /* Initialize USB serial for CLI */ #if ZEPHCORE_USB_STACK && DT_HAS_COMPAT_STATUS_OKAY(zephyr_cdc_acm_uart) usb_dev = DEVICE_DT_GET_ONE(zephyr_cdc_acm_uart); diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index 17bd502..10a7238 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -5,6 +5,7 @@ #include "CommonCLI.h" #include "battery_curve.h" +#include "led_gate.h" #include #include #include @@ -67,6 +68,7 @@ void CommonCLI::loadPrefs(const char* path) { } uint8_t pad[8]; + uint8_t leds_byte = 0; bool ok = true; /* Read fields in Arduino-compatible binary order. @@ -93,7 +95,9 @@ void CommonCLI::loadPrefs(const char* path) { ok = ok && prefs_read(&file, &_prefs->allow_read_only, sizeof(_prefs->allow_read_only)); // 114 ok = ok && prefs_read(&file, &_prefs->multi_acks, sizeof(_prefs->multi_acks)); // 115 ok = ok && prefs_read(&file, &_prefs->bw, sizeof(_prefs->bw)); // 116 - ok = ok && prefs_read(&file, &_prefs->agc_reset_interval, sizeof(_prefs->agc_reset_interval)); // 120 + /* 120: leds_disabled, magic-encoded. Formerly agc_reset_interval — see the + * LEDS_PREF_* comment in NodePrefs.h for why this is not a bare 0/1. */ + ok = ok && prefs_read(&file, &leds_byte, sizeof(leds_byte)); // 120 ok = ok && prefs_read(&file, &_prefs->path_hash_mode, sizeof(_prefs->path_hash_mode)); // 121 ok = ok && prefs_read(&file, &_prefs->loop_detect, sizeof(_prefs->loop_detect)); // 122 ok = ok && prefs_read(&file, pad, 1); // 123 @@ -135,6 +139,10 @@ void CommonCLI::loadPrefs(const char* path) { fs_close(&file); + /* Only the explicit "off" magic disables LEDs; a legacy AGC interval, an + * unwritten byte, or a truncated file all mean "on". */ + _prefs->leds_disabled = (leds_byte == LEDS_PREF_OFF) ? 1 : 0; + // Sanitise bad pref values _prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0.0f, 20.0f); _prefs->tx_delay_factor = constrain(_prefs->tx_delay_factor, 0.0f, 2.0f); @@ -218,7 +226,11 @@ void CommonCLI::savePrefs(const char* path) { fs_write(&file, &_prefs->allow_read_only, sizeof(_prefs->allow_read_only)); fs_write(&file, &_prefs->multi_acks, sizeof(_prefs->multi_acks)); fs_write(&file, &_prefs->bw, sizeof(_prefs->bw)); - fs_write(&file, &_prefs->agc_reset_interval, sizeof(_prefs->agc_reset_interval)); + /* 120: leds_disabled, magic-encoded (was agc_reset_interval). */ + { + uint8_t leds_byte = _prefs->leds_disabled ? LEDS_PREF_OFF : LEDS_PREF_ON; + fs_write(&file, &leds_byte, sizeof(leds_byte)); + } fs_write(&file, &_prefs->path_hash_mode, sizeof(_prefs->path_hash_mode)); fs_write(&file, &_prefs->loop_detect, sizeof(_prefs->loop_detect)); fs_write(&file, pad, 1); @@ -457,6 +469,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch snprintf(reply, CLI_REPLY_SIZE, "> %.2f", (double)_prefs->airtime_factor); } else if (memcmp(config, "int.thresh", 10) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->interference_threshold); + } else if (memcmp(config, "leds", 4) == 0) { + snprintf(reply, CLI_REPLY_SIZE, "> %s", _prefs->leds_disabled ? "off" : "on"); } else if (memcmp(config, "agc.reset.interval", 18) == 0) { strcpy(reply, "Removed - use rxduty instead"); } else if (memcmp(config, "multi.acks", 10) == 0) { @@ -629,6 +643,27 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch savePrefs(); strcpy(reply, "OK"); } + } 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. */ + const char* val = &config[5]; + int on; + if (memcmp(val, "on", 2) == 0 || val[0] == '1') { + on = 1; + } else if (memcmp(val, "off", 3) == 0 || val[0] == '0') { + on = 0; + } else { + on = -1; + } + if (on < 0) { + strcpy(reply, "Error: must be on or off"); + } else { + _prefs->leds_disabled = on ? 0 : 1; + zephcore_leds_set_disabled(_prefs->leds_disabled != 0); + savePrefs(); + strcpy(reply, "OK"); + } } else if (memcmp(config, "agc.reset.interval ", 19) == 0) { /* Periodic AGC recalibration was removed: it reset the noise floor * to its unseeded sentinel on every fire, forcing a fresh seed and diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index 2b4908b..0dc7457 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -36,6 +36,22 @@ #define CAD_OFFSET_MIN (-8) #define CAD_OFFSET_MAX 12 +/* leds_disabled, as stored in the repeater/room-server/observer prefs layout. + * + * It occupies the byte that used to hold agc_reset_interval (offset 120), + * retired when periodic AGC recalibration was removed. That byte is NOT + * reusable as a plain 0/1 boolean: the old command stored seconds/4, so a node + * upgrading from a build that had it configured has an arbitrary small integer + * sitting there, and a bare non-zero test would silently kill its LEDs. Hence + * a magic encoding — anything that is not one of these two values is a legacy + * AGC interval and decodes to the default (LEDs on). The first savePrefs() + * claims the byte for good. + * + * The companion layout is unaffected: it has always stored leds_disabled as a + * plain 0/1 at its own offset 93. */ +#define LEDS_PREF_ON 0xA0 +#define LEDS_PREF_OFF 0xA1 + struct NodePrefs { /* ---- Common fields (both roles) ---- */ float airtime_factor; @@ -62,10 +78,7 @@ struct NodePrefs { uint8_t flood_max_unscoped; // hop limit for un-scoped (ROUTE_TYPE_FLOOD) floods uint8_t flood_max_advert; // hop limit for ADVERT floods (curbs advert churn) uint8_t interference_threshold; - uint8_t agc_reset_interval; // RETIRED: read/written for on-disk layout - // compatibility only, never acted on. - // "set agc.reset.interval" replies - // "use rxduty instead". + uint8_t leds_disabled; // 1 = all LEDs off (heartbeat, unread, LoRa TX) // Power saving uint8_t powersaving_enabled; // GPS settings @@ -107,7 +120,6 @@ struct NodePrefs { uint8_t path_hash_mode; // path mode 0-2 uint8_t autoadd_max_hops; // 0 = no limit, N = up to N-1 hops uint8_t loop_detect; // LOOP_DETECT_{OFF,MINIMAL,MODERATE,STRICT} - uint8_t leds_disabled; // 1 = LEDs off char default_scope_name[31]; // companion: default flood scope region name ("" = null) uint8_t default_scope_key[16]; // companion: default flood scope TransportKey uint8_t ble_disabled; // 1 = BLE advertising off @@ -155,7 +167,7 @@ static inline void initNodePrefs(NodePrefs* prefs) { prefs->flood_max_unscoped = 64; // un-scoped flood hop limit (defaults to flood_max) prefs->flood_max_advert = 8; // ADVERT flood hop limit (upstream default) prefs->interference_threshold = 0; - prefs->agc_reset_interval = 0; + prefs->leds_disabled = 0; // LEDs on 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 new file mode 100644 index 0000000..ca3815e --- /dev/null +++ b/zephcore/helpers/led_gate.c @@ -0,0 +1,36 @@ +/* + * ZephCore - LED master gate + * Copyright (c) 2025 ZephCore + * SPDX-License-Identifier: MIT + */ + +#include "led_gate.h" + +#include +#include + +/* Atomic because the readers are not all on one thread: the heartbeat work + * handler runs on the system work queue, while the TX LED is driven from the + * dispatcher's transmit path. Writers are the main thread (boot / CLI) and the + * UI task. */ +static atomic_t s_leds_disabled; + +/* + * Weak: overridden by helpers/ui/ui_common.c in builds that have a UI, so a + * change made from the CLI also extinguishes a lit heartbeat LED immediately + * and syncs the UI's LED page. No-op in headless builds, where ui_common.c + * isn't compiled at all — which is exactly why the gate lives here and not + * there. + */ +__weak void zephcore_leds_ui_sync(bool disabled) { ARG_UNUSED(disabled); } + +bool zephcore_leds_disabled(void) +{ + return atomic_get(&s_leds_disabled) != 0; +} + +void zephcore_leds_set_disabled(bool disabled) +{ + atomic_set(&s_leds_disabled, disabled ? 1 : 0); + zephcore_leds_ui_sync(disabled); +} diff --git a/zephcore/helpers/led_gate.h b/zephcore/helpers/led_gate.h new file mode 100644 index 0000000..b993628 --- /dev/null +++ b/zephcore/helpers/led_gate.h @@ -0,0 +1,45 @@ +/* + * ZephCore - LED master gate + * Copyright (c) 2025 ZephCore + * SPDX-License-Identifier: MIT + * + * One process-wide "are LEDs allowed" flag, consulted by every LED driver in + * the firmware: + * - heartbeat / unread-message LEDs (helpers/ui/ui_common.c, UI builds only) + * - LoRa TX activity LED (adapters/board/ZephyrBoard.cpp, every role) + * + * It lives here rather than in ui_common.c because ui_common.c is only + * compiled when a UI is enabled, while a repeater with no display still has a + * blinking lora-tx-led that users want to be able to shut off. + * + * Set from persisted prefs at boot, and live via "set leds on|off" (all roles) + * or the UI LED toggle page (companions with buttons/joystick). + */ + +#ifndef ZEPHCORE_LED_GATE_H +#define ZEPHCORE_LED_GATE_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* true = every LED stays dark, including message and shutdown flashes. */ +bool zephcore_leds_disabled(void); + +/* Set the gate. Any LED currently lit is dealt with by zephcore_leds_ui_sync() + * below; the momentary TX LED clears itself at the end of the transmit in + * progress. Safe to call from any role, with or without a UI. */ +void zephcore_leds_set_disabled(bool disabled); + +/* 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. */ +void zephcore_leds_ui_sync(bool disabled); + +#ifdef __cplusplus +} +#endif + +#endif /* ZEPHCORE_LED_GATE_H */ diff --git a/zephcore/helpers/ui/ui_common.c b/zephcore/helpers/ui/ui_common.c index 24ac044..d011dd6 100644 --- a/zephcore/helpers/ui/ui_common.c +++ b/zephcore/helpers/ui/ui_common.c @@ -19,6 +19,7 @@ #include /* gps_power_off_for_shutdown */ #include "ui_mesh_actions.h" /* mesh_disable_power_regulators (weak) */ +#include "led_gate.h" /* shared with the LoRa TX LED */ #include #include @@ -87,7 +88,6 @@ static const struct gpio_dt_spec s_msg_led = #if HAS_HEARTBEAT_LED static struct k_work_delayable s_led_on_work; static struct k_work_delayable s_led_off_work; -static bool s_leds_disabled; /* * Weak: returns current unread message count for pulse-width adaptation. @@ -114,7 +114,7 @@ static void led_on_work_handler(struct k_work *work) uint16_t mc = ui_led_get_msg_count(); uint16_t on_ms = (mc > 0) ? LED_ON_MSG_MS : LED_ON_MS; - if (!s_leds_disabled) { + if (!zephcore_leds_disabled()) { gpio_pin_set_dt(&s_heartbeat_led, 1); #if HAS_MSG_LED if (mc > 0) { @@ -155,7 +155,7 @@ void ui_led_heartbeat_init(void) void ui_set_heartbeat_led(bool enabled) { #if HAS_HEARTBEAT_LED - if (enabled && !s_leds_disabled) { + if (enabled && !zephcore_leds_disabled()) { if (gpio_is_ready_dt(&s_heartbeat_led)) { k_work_reschedule(&s_led_on_work, K_NO_WAIT); } @@ -172,10 +172,15 @@ void ui_set_heartbeat_led(bool enabled) #endif } -void ui_set_leds_disabled(bool disabled) +/* + * Strong override of the weak hook in led_gate.c: react to a gate change from + * anywhere (UI toggle, "set leds", boot). Stops or restarts the heartbeat cycle + * and refreshes the UI's LED page. The gate flag itself is already set by the + * time we get here — do NOT call back into ui_set_leds_disabled() from here. + */ +void zephcore_leds_ui_sync(bool disabled) { #if HAS_HEARTBEAT_LED - s_leds_disabled = disabled; if (disabled) { k_work_cancel_delayable(&s_led_on_work); k_work_cancel_delayable(&s_led_off_work); @@ -196,6 +201,14 @@ void ui_set_leds_disabled(bool disabled) ui_led_on_disabled_changed(disabled); } +/* UI-facing spelling of the same thing. Kept because the UI toggle pages and + * the companion boot path call it by this name; the gate is what actually + * governs every LED. */ +void ui_set_leds_disabled(bool disabled) +{ + zephcore_leds_set_disabled(disabled); +} + /* Flash the heartbeat LED immediately on message receipt. * Cancels the current cycle, pulses at LED_ON_MSG_MS width, then the * work chain resumes the normal heartbeat automatically. @@ -203,7 +216,7 @@ void ui_set_leds_disabled(bool disabled) void ui_led_flash_msg(void) { #if HAS_HEARTBEAT_LED - if (!s_leds_disabled && gpio_is_ready_dt(&s_heartbeat_led)) { + if (!zephcore_leds_disabled() && gpio_is_ready_dt(&s_heartbeat_led)) { k_work_cancel_delayable(&s_led_on_work); k_work_cancel_delayable(&s_led_off_work); gpio_pin_set_dt(&s_heartbeat_led, 1); @@ -213,11 +226,13 @@ void ui_led_flash_msg(void) } /* Flash the heartbeat LED 3 times on shutdown. - * Used as a visual power-off indicator when the buzzer is muted. */ + * Used as a visual power-off indicator when the buzzer is muted. + * Suppressed by "set leds off" — a node the user asked to keep dark stays dark + * even at power-off. */ void ui_led_flash_shutdown(void) { #if HAS_HEARTBEAT_LED - if (gpio_is_ready_dt(&s_heartbeat_led)) { + if (!zephcore_leds_disabled() && gpio_is_ready_dt(&s_heartbeat_led)) { for (int i = 0; i < 3; i++) { gpio_pin_set_dt(&s_heartbeat_led, 1); k_sleep(K_MSEC(100)); diff --git a/zephcore/helpers/ui/ui_headless_stubs.c b/zephcore/helpers/ui/ui_headless_stubs.c index 76fa5f8..b32d052 100644 --- a/zephcore/helpers/ui/ui_headless_stubs.c +++ b/zephcore/helpers/ui/ui_headless_stubs.c @@ -19,6 +19,7 @@ #include #include "ui_task.h" +#include "led_gate.h" #define WEAK __attribute__((weak)) @@ -119,9 +120,11 @@ WEAK void ui_set_buzzer_quiet(bool quiet) ARG_UNUSED(quiet); } +/* Not a no-op: a headless build still has the LoRa TX LED, and the gate that + * governs it lives outside the UI layer precisely so this case works. */ WEAK void ui_set_leds_disabled(bool disabled) { - ARG_UNUSED(disabled); + zephcore_leds_set_disabled(disabled); } WEAK void ui_set_heartbeat_led(bool enabled) diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index 8af9b21..d53b61e 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -30,6 +30,7 @@ LOG_MODULE_REGISTER(zephcore_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #include "ui_task.h" #include "ui_mesh_actions.h" #include "oled_power.h" +#include "led_gate.h" #if IS_ENABLED(CONFIG_ZEPHCORE_UI_BUZZER) #include "buzzer.h" #endif @@ -1518,9 +1519,11 @@ int main(void) #endif /* Restore LED enabled/disabled state from persisted prefs. - * If LEDs were disabled, stop the heartbeat LED cycle. */ + * If LEDs were disabled, stop the heartbeat LED cycle. Straight to the gate + * rather than via ui_set_leds_disabled(): the gate also governs the LoRa TX + * LED and is linked into every build, UI or not. */ bool leds_off = companion_mesh.prefs.leds_disabled != 0; - ui_set_leds_disabled(leds_off); + zephcore_leds_set_disabled(leds_off); 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 f608737..6c6ffbc 100644 --- a/zephcore/src/main_repeater.cpp +++ b/zephcore/src/main_repeater.cpp @@ -22,6 +22,7 @@ LOG_MODULE_REGISTER(zephcore_repeater_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #include #include #include "oled_power.h" +#include "led_gate.h" /* BLE controller assert handler — BT is compiled even for repeater (via zephcore_common.conf) */ #if IS_ENABLED(CONFIG_BT_CTLR_ASSERT_HANDLER) @@ -438,6 +439,17 @@ static mesh::SimpleMeshTables mesh_tables; /* RepeaterMesh requires: board, radio, ms_clock, rng, rtc, tables */ static RepeaterMesh repeater_mesh(zephyr_board, lora_radio, ms_clock, zephyr_rng, rtc_clock, mesh_tables); +/* Strong override of the weak stub in ui_mesh_actions_stubs.c: keep the prefs + * copy in step when the LEDs page toggles, so "get leds" reports what the node + * is actually doing. RAM only — a repeater has no deferred-save path off the UI + * thread, so a UI toggle lasts until reboot; "set leds" is what persists. */ +extern "C" void mesh_set_leds_disabled(bool disabled) +{ + if (repeater_mesh_ptr) { + repeater_mesh_ptr->getNodePrefs()->leds_disabled = disabled ? 1 : 0; + } +} + static void refresh_repeater_ui_radio_state(void) { if (!repeater_mesh_ptr) { @@ -659,6 +671,15 @@ int main(void) oled_sleep(); #endif + /* Apply the persisted LED master switch ("set leds on|off"). After ui_init() + * so the heartbeat cycle exists to be stopped; before the radio starts so the + * first transmit already honours it. */ + { + bool leds_off = repeater_mesh.getNodePrefs()->leds_disabled != 0; + zephcore_leds_set_disabled(leds_off); + LOG_INF("LEDs: %s (from prefs)", leds_off ? "disabled" : "enabled"); + } + /* Log environment sensor availability */ if (env_sensors_available()) { LOG_INF("Environment sensors available"); diff --git a/zephcore/src/main_room_server.cpp b/zephcore/src/main_room_server.cpp index 92596aa..c1ff980 100644 --- a/zephcore/src/main_room_server.cpp +++ b/zephcore/src/main_room_server.cpp @@ -22,6 +22,7 @@ LOG_MODULE_REGISTER(zephcore_room_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #include #include #include "oled_power.h" +#include "led_gate.h" /* BLE controller assert handler — BT is compiled even for repeater (via zephcore_common.conf) */ #if IS_ENABLED(CONFIG_BT_CTLR_ASSERT_HANDLER) @@ -373,6 +374,17 @@ static mesh::SimpleMeshTables mesh_tables; static RoomServerMesh room_mesh(zephyr_board, lora_radio, ms_clock, zephyr_rng, rtc_clock, mesh_tables); #endif +/* Strong override of the weak stub in ui_mesh_actions_stubs.c: keep the prefs + * copy in step when the LEDs page toggles, so "get leds" reports what the node + * is actually doing. RAM only — there is no deferred-save path off the UI + * thread, so a UI toggle lasts until reboot; "set leds" is what persists. */ +extern "C" void mesh_set_leds_disabled(bool disabled) +{ + if (room_mesh_ptr) { + room_mesh_ptr->getNodePrefs()->leds_disabled = disabled ? 1 : 0; + } +} + /* Repeater event loop */ static void room_event_loop(void) { @@ -548,6 +560,15 @@ int main(void) oled_sleep(); #endif + /* Apply the persisted LED master switch ("set leds on|off"). After ui_init() + * so the heartbeat cycle exists to be stopped; before the radio starts so the + * first transmit already honours it. */ + { + bool leds_off = room_mesh.getNodePrefs()->leds_disabled != 0; + zephcore_leds_set_disabled(leds_off); + LOG_INF("LEDs: %s (from prefs)", leds_off ? "disabled" : "enabled"); + } + /* Log environment sensor availability */ if (env_sensors_available()) { LOG_INF("Environment sensors available");