From 949c13c7b1d3779a50b2036f517681fd04fe443a Mon Sep 17 00:00:00 2001 From: James L Date: Sun, 8 Mar 2026 13:59:13 -0400 Subject: [PATCH] feat: add LED indicators and headless mode support for V3/V4 - Detect missing OLED at boot, set headless_mode flag - LED solid: normal operation (radio online) - LED fast blink: button held >5s (entering WCC config mode) - LED slow breathe: WiFi Captive Configure portal active - Allow 1-3s button press in WCC mode to power off - Next boot after WCC power-off skips config portal (unless unconfigured) - LED indicators active on both V3 and V4, with or without display - Clean up LED PWM on deep sleep --- BoundaryConfig.h | 4 +++ Config.h | 1 + Input.h | 3 ++ RNode_Firmware.ino | 64 +++++++++++++++++++++++++++++++++++++- Utilities.h | 77 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 148 insertions(+), 1 deletion(-) diff --git a/BoundaryConfig.h b/BoundaryConfig.h index abbe080..a757d30 100644 --- a/BoundaryConfig.h +++ b/BoundaryConfig.h @@ -557,6 +557,10 @@ void config_portal_start() { display.display(); } #endif + // Headless: LED ramp will be driven from the WCC portal loop + if (headless_mode) { + Serial.println("[Config] Headless mode — LED will breathe during config portal"); + } } // ─── Stop Config Portal ────────────────────────────────────────────────────── diff --git a/Config.h b/Config.h index ccf447e..29ff2a0 100755 --- a/Config.h +++ b/Config.h @@ -145,6 +145,7 @@ bool hw_ready = false; bool radio_error = false; bool disp_ready = false; + bool headless_mode = false; bool pmu_ready = false; bool promisc = false; bool implicit = false; diff --git a/Input.h b/Input.h index c08d9fd..3eae259 100755 --- a/Input.h +++ b/Input.h @@ -96,6 +96,9 @@ display.display(); } #endif + headless_led_fast_blink(); + } else if (display_lock_white) { + headless_led_fast_blink(); } } } diff --git a/RNode_Firmware.ino b/RNode_Firmware.ino index 9dd1968..89e5154 100755 --- a/RNode_Firmware.ino +++ b/RNode_Firmware.ino @@ -243,6 +243,9 @@ TcpInterface* local_tcp_interface_ptr = nullptr; // RTC memory flag — survives software reset but not power cycle RTC_NOINIT_ATTR uint32_t boundary_config_request; #define BOUNDARY_CONFIG_MAGIC 0xC0F19A7E +// RTC flag to skip config portal on next boot (set when user powers off from WCC) +RTC_NOINIT_ATTR uint32_t boundary_skip_config; +#define BOUNDARY_SKIP_MAGIC 0x5E1FC0F0 // Bootloop detection: count rapid reboots in RTC memory. // After BOOTLOOP_THRESHOLD consecutive reboots within BOOTLOOP_WINDOW_MS, @@ -473,7 +476,17 @@ void setup() { display_unblank(); disp_ready = display_init(); - update_display(); + if (disp_ready) { + update_display(); + } else { + headless_mode = true; + Serial.println("[Headless] No display detected — running in headless mode"); + } + #endif + + // LED solid on at boot for V3/V4 boards (with or without display) + #if BOARD_MODEL == BOARD_HELTEC32_V4 || BOARD_MODEL == BOARD_HELTEC32_V3 + headless_led_solid(); #endif // ── Boundary Mode: check if config portal is needed ── @@ -514,7 +527,16 @@ void setup() { // OR bootloop detected bool need_config = boundary_needs_config(); bool config_requested = (boundary_config_request == BOUNDARY_CONFIG_MAGIC); + bool skip_config = (boundary_skip_config == BOUNDARY_SKIP_MAGIC); boundary_config_request = 0; // Clear flag immediately + boundary_skip_config = 0; // Clear skip flag immediately + + // Skip flag only suppresses a button-triggered re-entry, not a genuinely + // unconfigured device. If there's no config saved, always show the portal. + if (skip_config && config_requested) { + Serial.println("[Boundary] Skipping config portal — user requested normal boot"); + config_requested = false; + } if (need_config || config_requested || bootloop_detected) { if (bootloop_detected) { @@ -526,8 +548,39 @@ void setup() { } config_portal_start(); // Block here: only run the config portal until user saves and device reboots + // Track button state for "off" action (1-3s press = sleep) + bool wcc_btn_down = false; + uint32_t wcc_btn_down_at = 0; while (config_portal_is_active()) { config_portal_loop(); + + // Headless LED: slow ramp breathe effect during WCC mode + headless_led_ramp(); + + // Button handling: allow 1-3s press to turn off (deep sleep) + // Next power-on boots to normal mode since boundary_config_request is cleared + #if HAS_INPUT + { + int btn = digitalRead(pin_btn_usr1); + if (btn == LOW && !wcc_btn_down) { + wcc_btn_down = true; + wcc_btn_down_at = millis(); + } else if (btn == HIGH && wcc_btn_down) { + uint32_t held = millis() - wcc_btn_down_at; + wcc_btn_down = false; + if (held >= 700 && held <= 5000) { + Serial.println("[Boundary] Button press in WCC mode — powering off"); + boundary_skip_config = BOUNDARY_SKIP_MAGIC; // Skip config on next boot + headless_led_off(); + config_portal_stop(); + #if HAS_SLEEP + sleep_now(); + #endif + } + } + } + #endif + #if MCU_VARIANT == MCU_ESP32 esp_task_wdt_reset(); #endif @@ -2511,6 +2564,13 @@ void loop() { if (disp_ready && !display_updating) update_display(); #endif + // LED solid when operational on V3/V4 boards (yield to fast blink during white screen) + #if BOARD_MODEL == BOARD_HELTEC32_V4 || BOARD_MODEL == BOARD_HELTEC32_V3 + if (radio_online && !display_lock_white) { + headless_led_solid(); + } + #endif + #if HAS_PMU if (pmu_ready) update_pmu(); #endif @@ -2558,6 +2618,8 @@ void sleep_now() { #endif #endif #if BOARD_MODEL == BOARD_HELTEC32_V4 + headless_led_off(); + headless_led_detach_pwm(); digitalWrite(LORA_PA_CPS, LOW); digitalWrite(LORA_PA_CSD, LOW); digitalWrite(LORA_PA_PWR_EN, LOW); diff --git a/Utilities.h b/Utilities.h index 2563d46..f141f08 100755 --- a/Utilities.h +++ b/Utilities.h @@ -72,6 +72,10 @@ uint8_t eeprom_read(uint32_t mapped_addr); #endif #if HAS_INPUT == true + // Forward declarations for headless LED functions (defined later in this file) + void headless_led_fast_blink(); + void headless_led_ramp(); + void headless_led_off(); #include "Input.h" #endif @@ -382,6 +386,79 @@ extern RNS::Reticulum reticulum; #endif #endif +// ── Headless LED indicators (for Heltec V4 without OLED) ───────────────── +// Uses LEDC PWM for smooth ramp effects on pin_led_tx (GPIO 35) +#if BOARD_MODEL == BOARD_HELTEC32_V4 || BOARD_MODEL == BOARD_HELTEC32_V3 + #define HEADLESS_LED_CHANNEL 0 + bool headless_led_pwm_attached = false; + + void headless_led_ensure_pwm() { + if (!headless_led_pwm_attached) { + ledcSetup(HEADLESS_LED_CHANNEL, 5000, 8); // channel 0, 5kHz, 8-bit + ledcAttachPin(pin_led_tx, HEADLESS_LED_CHANNEL); + headless_led_pwm_attached = true; + } + } + + void headless_led_detach_pwm() { + if (headless_led_pwm_attached) { + ledcDetachPin(pin_led_tx); + headless_led_pwm_attached = false; + pinMode(pin_led_tx, OUTPUT); + } + } + + // Solid ON — normal headless operation + void headless_led_solid() { + headless_led_ensure_pwm(); + ledcWrite(HEADLESS_LED_CHANNEL, 255); + } + + // Fast blink — replaces "white screen" indicator (non-blocking, call from loop) + void headless_led_fast_blink() { + headless_led_ensure_pwm(); + static uint32_t last_toggle = 0; + static bool on = false; + uint32_t now = millis(); + if (now - last_toggle >= 100) { // 5Hz blink + last_toggle = now; + on = !on; + ledcWrite(HEADLESS_LED_CHANNEL, on ? 255 : 0); + } + } + + // Slow ramp in/out — breathe effect for WiFi Captive Configure mode + void headless_led_ramp() { + headless_led_ensure_pwm(); + static uint32_t last_step = 0; + static uint8_t brightness = 0; + static int8_t direction = 1; + uint32_t now = millis(); + if (now - last_step >= 10) { // ~100 steps/sec, full cycle ~5 seconds + last_step = now; + brightness += direction; + if (brightness >= 255) { brightness = 255; direction = -1; } + if (brightness == 0) { direction = 1; } + ledcWrite(HEADLESS_LED_CHANNEL, brightness); + } + } + + void headless_led_off() { + if (headless_led_pwm_attached) { + ledcWrite(HEADLESS_LED_CHANNEL, 0); + } else { + digitalWrite(pin_led_tx, LOW); + } + } +#else + void headless_led_ensure_pwm() {} + void headless_led_detach_pwm() {} + void headless_led_solid() {} + void headless_led_fast_blink() {} + void headless_led_ramp() {} + void headless_led_off() {} +#endif + void hard_reset(void) { #if MCU_VARIANT == MCU_1284P || MCU_VARIANT == MCU_2560 wdt_enable(WDTO_15MS);