From c2b4ed4f5e1517d4a81978a7576cb4abfe86e40a Mon Sep 17 00:00:00 2001 From: Mitch Ross Date: Sat, 11 Jul 2026 00:55:02 -0400 Subject: [PATCH 1/2] fix(led): stop WS2812 refresh when frame is unchanged so 'off' stays dark The LED task rewrote an identical frame to the WS2812 strip every 20ms, keeping the data line permanently active. The Zigbee + BLE sensor push every 10 seconds (radio TX burst, with DFS enabled) can corrupt one of those frames, so the pixels briefly latch a random color - visible as a blink every ~10s even with brightness set to off. Only write to the strip when the frame actually changes. After a change, repeat the frame for 500ms so a corrupted transition write self-heals. While visibly lit, refresh once per second as a safety net. Once settled at off, keep the data line fully idle so nothing can flash at night. Co-Authored-By: Claude Fable 5 --- firmware/main/led.c | 76 ++++++++++++++++++++++++++++----------------- 1 file changed, 48 insertions(+), 28 deletions(-) diff --git a/firmware/main/led.c b/firmware/main/led.c index a17e370..0be3c66 100644 --- a/firmware/main/led.c +++ b/firmware/main/led.c @@ -39,50 +39,70 @@ static struct led_state led_new_state = {0}; */ static void led_task(void *pvParameters) { + // Rewriting an unchanged frame 50x/s keeps the WS2812 data line busy at all + // times, so a frame corrupted by a coincident radio TX burst (Zigbee + BLE + // push every 10 s) briefly latches a random color - visible as a blink even + // when the LEDs are set to off. Only write when the frame changes; after a + // change, repeat the frame briefly so a corrupted transition write self-heals; + // when lit, refresh slowly as a safety net; when off, keep the line idle. + uint32_t last_written_color = 0xFFFFFFFF; // Sentinel: force first write + TickType_t last_write_ticks = 0; + TickType_t last_change_ticks = 0; + while (1) { + bool have_color = false; + uint32_t current_color = LED_COLOR_OFF; + float current_intensity = 0.0f; + // Take mutex to safely read LED color and intensity if (led_mutex != NULL && xSemaphoreTake(led_mutex, pdMS_TO_TICKS(100)) == pdTRUE) { // Read current LED color and intensity safely - uint32_t current_color = led_color; - float current_intensity = led_intensity; + current_color = led_color; + current_intensity = led_intensity; xSemaphoreGive(led_mutex); - + have_color = true; + } else if (led_mutex == NULL) { + // Fallback mode if mutex is not available + ESP_LOGW("led", "LED mutex not available, using direct access"); + current_color = led_color; + current_intensity = led_intensity; + have_color = true; + } else { + ESP_LOGW("led", "Failed to take LED mutex - skipping update"); + } + + if (have_color) { // Apply intensity to color uint32_t final_color = apply_color_intensity(current_color, current_intensity); - - // Set all 3 LEDs to the same color - for (int i = 0; i < NUM_CONTROLLED_LEDS; i++) { - led_new_state.leds[i] = final_color; - } - - // Set remaining LEDs to off - for (int i = NUM_CONTROLLED_LEDS; i < NUM_LEDS; i++) { - led_new_state.leds[i] = LED_COLOR_OFF; - } - - // Update WS2812 LEDs - ws2812_write_leds(led_new_state); - } else { - // Fallback mode if mutex is not available - if (led_mutex == NULL) { - ESP_LOGW("led", "LED mutex not available, using direct access"); - // Apply intensity to color - uint32_t final_color = apply_color_intensity(led_color, led_intensity); - + + TickType_t now = xTaskGetTickCount(); + bool changed = (final_color != last_written_color); + // Repeat writes for 500ms after a change so a corrupted frame can't stick + bool settling = (now - last_change_ticks) < pdMS_TO_TICKS(500); + // Slow refresh while visibly lit; fully idle once settled at off + bool refresh_due = (final_color != LED_COLOR_OFF) && + (now - last_write_ticks) >= pdMS_TO_TICKS(1000); + + if (changed || settling || refresh_due) { + if (changed) { + last_change_ticks = now; + } + // Set all 3 LEDs to the same color for (int i = 0; i < NUM_CONTROLLED_LEDS; i++) { led_new_state.leds[i] = final_color; } - + // Set remaining LEDs to off for (int i = NUM_CONTROLLED_LEDS; i < NUM_LEDS; i++) { led_new_state.leds[i] = LED_COLOR_OFF; } - + // Update WS2812 LEDs - ws2812_write_leds(led_new_state); - } else { - ESP_LOGW("led", "Failed to take LED mutex - skipping update"); + if (ws2812_write_leds(led_new_state) == ESP_OK) { + last_written_color = final_color; + last_write_ticks = now; + } } } From 77f881c9926ae131e288d261a038aff4e1caa530 Mon Sep 17 00:00:00 2001 From: Mitch Ross Date: Sat, 11 Jul 2026 02:26:57 -0400 Subject: [PATCH 2/2] fix(zigbee): stop reboot loop when coordinator is unreachable A commissioned device that cannot reach its coordinator (powered off, out of range, network gone) failed initialization 5 times and then called esp_restart() - which changes nothing, so the device boot-looped every ~17 seconds forever. Each boot also flashed the LED before the saved brightness loaded from NVS, so a device with brightness set to off blinked all night. - Reboot at most once per power cycle (skip when the last reset was already our own SW restart), then keep retrying initialization with exponential backoff capped at REJOIN_BACKOFF_MAX_MS (5 min). - Start led_intensity at 0 so boots stay dark until button_init() loads the saved brightness. Co-Authored-By: Claude Fable 5 --- firmware/main/led.c | 5 ++++- firmware/main/zigbee.c | 33 +++++++++++++++++++++++++++------ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/firmware/main/led.c b/firmware/main/led.c index 0be3c66..3cf00f3 100644 --- a/firmware/main/led.c +++ b/firmware/main/led.c @@ -23,7 +23,10 @@ static SemaphoreHandle_t led_mutex = NULL; // Global LED color and intensity variables (GRB format for WS2812 LEDs) static uint32_t led_color = LED_COLOR_OFF; // Current LED color -static float led_intensity = 0.6f; // Current LED intensity (0.0 to 1.0) +// Start at 0 so the LEDs stay dark until button_init() loads the saved +// brightness from NVS - otherwise every boot flashes the startup color even +// when the user has set brightness to off. +static float led_intensity = 0.0f; // Current LED intensity (0.0 to 1.0) // LED state structure for WS2812 driver static struct led_state led_new_state = {0}; diff --git a/firmware/main/zigbee.c b/firmware/main/zigbee.c index 596f335..dba7557 100644 --- a/firmware/main/zigbee.c +++ b/firmware/main/zigbee.c @@ -287,16 +287,37 @@ void esp_zb_app_signal_handler(esp_zb_app_signal_t *signal_struct) 0, STARTUP_REPORT_DELAY_MS); } } else { - s_init_fail_count++; - if (s_init_fail_count >= INIT_FAIL_MAX) { - ESP_LOGE(TAG, "Zigbee init failed %d times – rebooting to reset radio", + if (s_init_fail_count < 250) { + s_init_fail_count++; + } + /* A commissioned device whose coordinator is unreachable + * (powered off, out of range, network gone) fails init forever. + * Reboot at most once to clear a genuinely wedged radio - if we + * already rebooted for this (ESP_RST_SW) it didn't help, so keep + * retrying with exponential backoff instead of boot-looping, + * which flashes the LED on every restart. */ + if (s_init_fail_count == INIT_FAIL_MAX && + esp_reset_reason() != ESP_RST_SW) { + ESP_LOGE(TAG, "Zigbee init failed %d times – rebooting once to reset radio", s_init_fail_count); esp_restart(); } - ESP_LOGI(TAG, "Waiting for coordinator (%s), attempt %d/%d, retrying", - esp_err_to_name(err_status), s_init_fail_count, INIT_FAIL_MAX); + uint32_t delay_ms = REJOIN_BACKOFF_INIT_MS; + if (s_init_fail_count > INIT_FAIL_MAX) { + uint8_t doublings = s_init_fail_count - INIT_FAIL_MAX; + if (doublings > 9) { + doublings = 9; + } + delay_ms <<= doublings; + if (delay_ms > REJOIN_BACKOFF_MAX_MS) { + delay_ms = REJOIN_BACKOFF_MAX_MS; + } + } + ESP_LOGI(TAG, "Waiting for coordinator (%s), attempt %d, retrying in %lu ms", + esp_err_to_name(err_status), s_init_fail_count, + (unsigned long)delay_ms); esp_zb_scheduler_alarm((esp_zb_callback_t)bdb_start_top_level_commissioning_cb, - ESP_ZB_BDB_MODE_INITIALIZATION, 1000); + ESP_ZB_BDB_MODE_INITIALIZATION, delay_ms); } break;