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
This commit is contained in:
James L
2026-03-08 13:59:13 -04:00
parent 8db47d4d01
commit 949c13c7b1
5 changed files with 148 additions and 1 deletions

View File

@@ -557,6 +557,10 @@ void config_portal_start() {
display.display(); display.display();
} }
#endif #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 ────────────────────────────────────────────────────── // ─── Stop Config Portal ──────────────────────────────────────────────────────

View File

@@ -145,6 +145,7 @@
bool hw_ready = false; bool hw_ready = false;
bool radio_error = false; bool radio_error = false;
bool disp_ready = false; bool disp_ready = false;
bool headless_mode = false;
bool pmu_ready = false; bool pmu_ready = false;
bool promisc = false; bool promisc = false;
bool implicit = false; bool implicit = false;

View File

@@ -96,6 +96,9 @@
display.display(); display.display();
} }
#endif #endif
headless_led_fast_blink();
} else if (display_lock_white) {
headless_led_fast_blink();
} }
} }
} }

View File

@@ -243,6 +243,9 @@ TcpInterface* local_tcp_interface_ptr = nullptr;
// RTC memory flag — survives software reset but not power cycle // RTC memory flag — survives software reset but not power cycle
RTC_NOINIT_ATTR uint32_t boundary_config_request; RTC_NOINIT_ATTR uint32_t boundary_config_request;
#define BOUNDARY_CONFIG_MAGIC 0xC0F19A7E #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. // Bootloop detection: count rapid reboots in RTC memory.
// After BOOTLOOP_THRESHOLD consecutive reboots within BOOTLOOP_WINDOW_MS, // After BOOTLOOP_THRESHOLD consecutive reboots within BOOTLOOP_WINDOW_MS,
@@ -473,7 +476,17 @@ void setup() {
display_unblank(); display_unblank();
disp_ready = display_init(); 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 #endif
// ── Boundary Mode: check if config portal is needed ── // ── Boundary Mode: check if config portal is needed ──
@@ -514,7 +527,16 @@ void setup() {
// OR bootloop detected // OR bootloop detected
bool need_config = boundary_needs_config(); bool need_config = boundary_needs_config();
bool config_requested = (boundary_config_request == BOUNDARY_CONFIG_MAGIC); 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_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 (need_config || config_requested || bootloop_detected) {
if (bootloop_detected) { if (bootloop_detected) {
@@ -526,8 +548,39 @@ void setup() {
} }
config_portal_start(); config_portal_start();
// Block here: only run the config portal until user saves and device reboots // 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()) { while (config_portal_is_active()) {
config_portal_loop(); 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 #if MCU_VARIANT == MCU_ESP32
esp_task_wdt_reset(); esp_task_wdt_reset();
#endif #endif
@@ -2511,6 +2564,13 @@ void loop() {
if (disp_ready && !display_updating) update_display(); if (disp_ready && !display_updating) update_display();
#endif #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 HAS_PMU
if (pmu_ready) update_pmu(); if (pmu_ready) update_pmu();
#endif #endif
@@ -2558,6 +2618,8 @@ void sleep_now() {
#endif #endif
#endif #endif
#if BOARD_MODEL == BOARD_HELTEC32_V4 #if BOARD_MODEL == BOARD_HELTEC32_V4
headless_led_off();
headless_led_detach_pwm();
digitalWrite(LORA_PA_CPS, LOW); digitalWrite(LORA_PA_CPS, LOW);
digitalWrite(LORA_PA_CSD, LOW); digitalWrite(LORA_PA_CSD, LOW);
digitalWrite(LORA_PA_PWR_EN, LOW); digitalWrite(LORA_PA_PWR_EN, LOW);

View File

@@ -72,6 +72,10 @@ uint8_t eeprom_read(uint32_t mapped_addr);
#endif #endif
#if HAS_INPUT == true #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" #include "Input.h"
#endif #endif
@@ -382,6 +386,79 @@ extern RNS::Reticulum reticulum;
#endif #endif
#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) { void hard_reset(void) {
#if MCU_VARIANT == MCU_1284P || MCU_VARIANT == MCU_2560 #if MCU_VARIANT == MCU_1284P || MCU_VARIANT == MCU_2560
wdt_enable(WDTO_15MS); wdt_enable(WDTO_15MS);