diff --git a/README.md b/README.md index d716af8..79c2d71 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ watch-watch — вбудована система на ESP32-S3 для нагл ## Основні можливості - **Керування каналами живлення**: 5 незалежних ліній `EN` (GPIO 2, 4, 5, 18, 19), які можна увімкнути, вимкнути або перемкнути з коду чи CLI. -- **Послідовний автотест**: у `app_main` реалізовано базову логіку — канали вмикаються по черзі з інтервалом 4 с, що дозволяє перевірити всі DC/DC. -- **Світлодіодний індикатор стану**: п’ять WS2812 (GPIO 8) показують роботу каналів — активний канал підсвічується яскраво-зеленим, увімкнені/вимкнені відображаються зеленим/синім, помилки — червоним. +- **Послідовний автотест**: у `app_main` реалізовано базову логіку — канали вмикаються по черзі з інтервалом 3 с, що дозволяє перевірити всі DC/DC без стрибків споживання. +- **Світлодіодний індикатор стану**: п’ять WS2812 (GPIO 8) світяться зеленим під час стартової затримки, після чого кожен канал сигналізує лише про дві події — відсутність VPN (два червоних блимання) та падіння APP (три жовтих блимання). - **Моніторинг навантаження**: датчики INA226 вимірюють напругу, струм та потужність кожного каналу, інформація потрапляє в лог і CLI. - **UART взаємодія з Raspberry Pi**: один UART через мультиплексор (A0/A1/A2) ділиться між п’ятьма Pi, дозволяючи надсилати службові повідомлення або обмінюватися даними. - **Нативний USB-CLI**: ESP32-S3 підключається до Raspberry Pi 5 по USB і стає CDC ACM пристроєм; командний інтерфейс дозволяє керувати каналами та дивитись стан у реальному часі. @@ -47,12 +47,12 @@ watch-watch — вбудована система на ESP32-S3 для нагл ## Світлодіоди стану - IC WS2812 підключений до GPIO 8 (один ланцюг із 5 діодів). -- Модуль `ws2812_status` показує одночасно три аспекти стану: - - **Живлення**: якщо канал вимкнений, сегмент горить тьмяно-синім; увімкнений канал — зеленим/синім залежно від зв’язку. - - **UART-зв’язок**: після кожного опитування Raspberry Pi канал, що відповів (`{"hb":2}`), стає зеленим; якщо відповідей не було, він переходить у синій. Таким чином можна одразу бачити, хто не відповідає. - - **Активний канал / опитування**: канал, який зараз опитує watchdog або проходить тестовий цикл, підсвічується яскравішим тоном; поки триває читання UART, колір м’яко блимає, щоб показати активність, але не приховати власний статус. -- За критичної помилки (наприклад, DCDC не ініціалізувався) всі індикатори стають червоними. -- Колірні алгоритми можна кастомізувати у `main/ws2812_status.c`, там же знаходиться API `ws2812_status_set_ack_state()` для відображення acknowledge. +- Після старту всі п’ять діодів світяться сталим зеленим протягом затримки `heartbeat_start_delay_sec`, щоб показати фазу ініціалізації. +- Після завершення затримки вся стрічка гасне, а кожен канал індикує лише два типи подій: + - VPN=0 — два червоних блимання по 200 мс із паузою між циклами; + - APP=0 — три жовтих блимання по 200 мс. + Якщо активні обидва попередження, вони програються послідовно (спочатку VPN, потім APP) із паузою 2 с між послідовностями. +- При критичній помилці (наприклад, DCDC не ініціалізувався) всі індикатори залишаються червоними. - GPIO, кількість діодів та тактову частоту RMT можна змінити через `idf.py menuconfig` (розділ *Налаштування watch-watch*). ## Моніторинг живлення (INA226) @@ -87,11 +87,29 @@ watch-watch — вбудована система на ESP32-S3 для нагл | `sense` | виміряти загальну напругу/струм/потужність | | `uart send ` | надіслати повідомлення у Raspberry Pi `n` | | `uart read [len]` | прочитати відповідь від Raspberry Pi `n` | -| `uart send ` | відправити текст у Raspberry Pi №n | -| `uart read [len]` | прочитати дані з цільового Pi | +| `config show` | переглянути таймінги heartbeat/DCDC | +| `config set …` | змінити та зберегти таймінги / моніторинг | +| `reset` | м’яке перезавантаження ESP32-S3 | +| `bootloader` | перезавантажити ESP32-S3 у ROM bootloader для `esptool.py` | CLI з’являється після того, як Raspberry Pi встановить DTR (наприклад, через `screen`, `picocom` або власний скрипт). +### Керування таймінгами heartbeat +Команда `config` дозволяє зберігати параметри роботи watchdog та циклу heartbeat у NVS, і вони одразу набувають чинності без перезбирання прошивки: + +1. `hb_period` — інтервал (сек) між опитуваннями/heartbeat у головному циклі. +2. `dcdc_off` — тривалість вимкнення каналу DCDC при автоматичному перезапуску (сек). +3. `hb_start` — затримка перед стартом опитування після завантаження (сек); застосовується як у головному циклі, так і у watchdog-завданні. +4. `hb_monitor` — значення `1` вмикає моніторинг heartbeat (за замовчуванням), `0` вимикає лише контроль/рестарти каналів, але тестові повідомлення heartbeat продовжують відправлятися. +5. `hb_miss` — кількість послідовних запитів без відповіді перед автоматичним перезапуском каналу (за замовчуванням 3). Значення зберігає watchdog та використовується в CLI для відображення статистики пропусків/рестартів. + +### Прошивка без натискання BOOT +1. Під’єднайтесь до CLI та виконайте команду `bootloader`. ESP32-S3 перезавантажиться у ROM bootloader, а на хості з’явиться USB-пристрій `USB JTAG/serial`. +2. На Raspberry Pi запустіть `esptool.py` або `idf.py flash` і вкажіть новий порт (`/dev/ttyACM*` або `/dev/cu.usbmodem*`). Наприклад: + `esptool.py --chip esp32s3 --port /dev/ttyACM0 --before usb_reset --after no_reset write_flash 0x0 build/watch-watch.bin` +3. Після завершення прошивки виконайте `esptool.py --after hard_reset reset` або просто перезавантажте живлення — пристрій вийде з bootloader і повернеться до нормальної роботи. +Таким чином процедура оновлення доступна без натискання кнопки BOOT і може виконуватись безпосередньо з підключеної Raspberry Pi. + ## Збірка та конфігурація 1. Встановіть ESP-IDF v5.5.1 (шлях `IDF_PATH` має вказувати на `/Users/tarassivas/esp/v5.5.1/esp-idf`). 2. Один раз виконайте `idf.py reconfigure`, щоб підвантажити залежності з `idf_component.yml`. diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index baa3e36..0d864f5 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,7 +1,8 @@ -idf_component_register(SRCS "main.c" +idf_component_register(SRCS "ws2812_status.c" "watch_config.c" "usb_cdc_cli.c" "main.c" "dcdc_controller.c" "usb_cdc_log.c" - "ws2812_status.c" + "ina226_monitor.c" "uart_mux.c" - INCLUDE_DIRS ".") + INCLUDE_DIRS "." + REQUIRES nvs_flash driver esp_timer) diff --git a/main/main.c b/main/main.c index f133ab0..4f31b11 100644 --- a/main/main.c +++ b/main/main.c @@ -5,45 +5,80 @@ */ #include +#include +#include #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_log.h" +#include "sdkconfig.h" #include "dcdc_controller.h" #include "ina226_monitor.h" #include "uart_mux.h" +#include "usb_cdc_cli.h" #include "usb_cdc_log.h" +#include "watch_config.h" #include "ws2812_status.h" static const char *TAG = "watch-watch"; -static const char HB_MESSAGE[] = "{\"hb\":1}\r\n"; +static const char HB_MESSAGE[] = "{\"cmd\":\"status\"}\r\n"; + +static int noop_vprintf(const char *fmt, va_list args) +{ + (void)fmt; + (void)args; + return 0; +} void app_main(void) { - if (usb_cdc_log_init() != ESP_OK) { - ESP_LOGW(TAG, "USB CDC лог недоступний"); + esp_log_set_vprintf(noop_vprintf); + + if (watch_config_init() != ESP_OK) { + ESP_LOGE(TAG, "Не вдалося ініціалізувати конфігурацію"); } else { - ESP_LOGI(TAG, "USB CDC лог активовано"); + ESP_LOGI(TAG, "Конфігурацію завантажено"); } - - vTaskDelay(pdMS_TO_TICKS(2000)); // Затримка для стабілізації живлення після перезавантаження + + if (usb_cdc_cli_init() == ESP_OK) { + ESP_LOGI(TAG, "USB CDC CLI активовано"); + } else { + ESP_LOGW(TAG, "Не вдалося запустити USB CLI"); + } + + bool ws_ready = false; + if (ws2812_status_init() == ESP_OK) { + ws_ready = true; + } else { + ESP_LOGW(TAG, "WS2812 статусний індикатор недоступний"); + } + + const watch_config_t *cfg = watch_config_get(); + const uint32_t start_delay_ms = cfg->heartbeat_start_delay_sec * 1000U; + TickType_t start_delay = pdMS_TO_TICKS(start_delay_ms); + if (start_delay > 0) { + ESP_LOGI(TAG, "Очікування %u с перед стартом опитування", cfg->heartbeat_start_delay_sec); + if (ws_ready) { + ws2812_status_set_startup_hold(start_delay_ms); + } + vTaskDelay(start_delay); + if (ws_ready) { + ws2812_status_set_startup_hold(0); + } + } + ESP_LOGI(TAG, "Запуск watch-watch systems"); if (dcdc_init() != ESP_OK) { ESP_LOGE(TAG, "Помилка ініціалізації DCDC контролера"); - ws2812_status_init(); - ws2812_status_set_error(true); + if (ws_ready) { + ws2812_status_set_error(true); + } return; } - if (ws2812_status_init() == ESP_OK) { + if (ws_ready) { ws2812_status_refresh_from_dcdc(); - esp_err_t anim_err = ws2812_status_play_bringup_animation(1, 120); - if (anim_err != ESP_OK) { - ESP_LOGW(TAG, "Анімація WS2812 недоступна: %s", esp_err_to_name(anim_err)); - } - } else { - ESP_LOGW(TAG, "WS2812 статусний індикатор недоступний"); } if (ina226_monitor_init() == ESP_OK) { @@ -59,36 +94,97 @@ void app_main(void) ESP_LOGW(TAG, "UART мультиплексор недоступний"); } - - - ESP_LOGI(TAG, "Початок послідовного ввімкнення каналів з інтервалом 4 с"); - const TickType_t on_time = pdMS_TO_TICKS(4000); + const TickType_t mux_timeout = pdMS_TO_TICKS(600); + TickType_t channel_next_hb[DCDC_CHANNEL_COUNT] = {0}; + bool channel_powered[DCDC_CHANNEL_COUNT] = {false}; + const TickType_t power_on_stagger = pdMS_TO_TICKS(3000); + ESP_LOGI(TAG, "Початок циклічного опитування всіх каналів"); while (true) { - for (size_t ch = 0; ch < dcdc_channel_count(); ++ch) { - if (!dcdc_get_state(ch)) { + cfg = watch_config_get(); + TickType_t hb_period = pdMS_TO_TICKS(cfg->heartbeat_period_sec * 1000U); + if (hb_period == 0) { + hb_period = 1; + } + TickType_t now = xTaskGetTickCount(); + size_t channels = dcdc_channel_count(); + for (size_t ch = 0; ch < channels; ++ch) { + bool powered = dcdc_get_state(ch); + if (!powered) { + channel_powered[ch] = false; ESP_LOGI(TAG, "-> Ввімкнення каналу %d", (int)ch); - dcdc_enable(ch); - ws2812_status_set_channel_state(ch, true); + if (dcdc_enable(ch) == ESP_OK) { + powered = true; + channel_powered[ch] = true; + channel_next_hb[ch] = now + hb_period; + if (ws_ready) { + ws2812_status_set_channel_state(ch, true); + } + vTaskDelay(power_on_stagger); + } else { + ESP_LOGE(TAG, "Не вдалося ввімкнути канал %d", (int)ch); + continue; + } + } + + if (!channel_powered[ch]) { + channel_powered[ch] = true; + channel_next_hb[ch] = now + hb_period; + continue; + } + + now = xTaskGetTickCount(); + if (now < channel_next_hb[ch]) { + continue; } - ws2812_status_mark_active(ch); - vTaskDelay(on_time); ina226_reading_t reading = {0}; - if (ina226_monitor_sample(&reading) == ESP_OK) { + if (ina226_monitor_ready() && ina226_monitor_sample(&reading) == ESP_OK) { ESP_LOGI(TAG, "Живлення: %.2f В, %.1f мА, %.1f мВт", reading.voltage_v, reading.current_ma, reading.power_mw); } if (uart_mux_ready()) { - if (uart_mux_write(ch, - (const uint8_t *)HB_MESSAGE, - sizeof(HB_MESSAGE) - 1, - pdMS_TO_TICKS(100)) != ESP_OK) { - ESP_LOGW(TAG, "Не вдалося надіслати heartbeat на канал %d", (int)ch); + esp_err_t tx_err = uart_mux_write(ch, + (const uint8_t *)HB_MESSAGE, + sizeof(HB_MESSAGE) - 1, + mux_timeout); + if (tx_err != ESP_OK) { + ESP_LOGW(TAG, "Не вдалося надіслати heartbeat на канал %d: %s", + (int)ch, esp_err_to_name(tx_err)); + } else { + uint8_t rx_buffer[CONFIG_WATCH_UART_MUX_DEFAULT_READ_LEN]; + size_t received = 0; + esp_err_t rx_err = uart_mux_read(ch, + rx_buffer, + sizeof(rx_buffer), + &received, + mux_timeout); + if (rx_err == ESP_OK) { + if (received > 0) { + ESP_LOGI(TAG, "RX CH%u (%u байт після heartbeat)", (unsigned)ch, (unsigned)received); + ESP_LOG_BUFFER_HEX_LEVEL(TAG, rx_buffer, received, ESP_LOG_INFO); + uart_mux_process_rx(ch, rx_buffer, received); + } else { + ESP_LOGW(TAG, "Канал %d не відповів даними на heartbeat", (int)ch); + uart_mux_report_miss(ch); + } + } else if (rx_err == ESP_ERR_TIMEOUT) { + ESP_LOGW(TAG, "Час очікування відповіді з каналу %d перевищено", (int)ch); + uart_mux_report_miss(ch); + } else { + ESP_LOGW(TAG, "Помилка читання відповіді CH%u: %s", + (unsigned)ch, esp_err_to_name(rx_err)); + uart_mux_report_miss(ch); + } } + } else { + ESP_LOGW(TAG, "UART мультиплексор недоступний, очікування..."); } + channel_next_hb[ch] = now + hb_period; } + + vTaskDelay(pdMS_TO_TICKS(50)); } } diff --git a/main/uart_mux.c b/main/uart_mux.c index cd7e825..a30ecb7 100644 --- a/main/uart_mux.c +++ b/main/uart_mux.c @@ -6,6 +6,7 @@ #include "uart_mux.h" +#include #include #include @@ -20,6 +21,7 @@ #include "freertos/task.h" #include "sdkconfig.h" #include "ws2812_status.h" +#include "watch_config.h" #ifndef CONFIG_WATCH_UART_MUX_CHANNELS #define CONFIG_WATCH_UART_MUX_CHANNELS 5 @@ -46,6 +48,167 @@ static bool s_initialized; static int64_t s_last_heartbeat_us[UART_MUX_MAX_CHANNELS]; static TaskHandle_t s_watchdog_task; static uint8_t s_consecutive_miss[UART_MUX_MAX_CHANNELS]; +static bool s_watchdog_armed[UART_MUX_MAX_CHANNELS]; +static uint32_t s_total_miss_count[UART_MUX_MAX_CHANNELS]; +static uint32_t s_restart_count[UART_MUX_MAX_CHANNELS]; + +static uint8_t uart_mux_get_miss_limit_from_config(void); +static void uart_mux_restart_channel(size_t channel); +static void uart_mux_record_miss(size_t channel, uint8_t miss_limit); + +static TickType_t uart_mux_restart_delay_ticks(void) +{ + const watch_config_t *cfg = watch_config_get(); + uint32_t sec = cfg ? cfg->dcdc_restart_off_sec : 2U; + if (sec == 0) { + sec = 1; + } + return pdMS_TO_TICKS(sec * 1000U); +} + +static uint8_t uart_mux_get_miss_limit_from_config(void) +{ + const watch_config_t *cfg = watch_config_get(); + uint32_t limit = cfg ? cfg->heartbeat_miss_limit : 3U; + if (limit == 0) { + limit = 3U; + } + if (limit > UINT8_MAX) { + limit = UINT8_MAX; + } + return (uint8_t)limit; +} + +static void uart_mux_restart_channel(size_t channel) +{ + if (channel >= UART_MUX_MAX_CHANNELS) { + return; + } + ESP_LOGW(TAG, "CH%u: перезапуск живлення після відсутності відповіді", (unsigned)channel); + dcdc_disable(channel); + vTaskDelay(uart_mux_restart_delay_ticks()); + dcdc_enable(channel); + s_consecutive_miss[channel] = 0; + s_watchdog_armed[channel] = false; + if (s_restart_count[channel] < UINT32_MAX) { + ++s_restart_count[channel]; + } + s_last_heartbeat_us[channel] = esp_timer_get_time(); +} + +static void uart_mux_record_miss(size_t channel, uint8_t miss_limit) +{ + if (channel >= UART_MUX_MAX_CHANNELS) { + return; + } + if (!dcdc_get_state(channel)) { + return; + } + if (!s_watchdog_armed[channel]) { + s_watchdog_armed[channel] = true; + s_consecutive_miss[channel] = 0; + } + if (s_total_miss_count[channel] < UINT32_MAX) { + ++s_total_miss_count[channel]; + } + if (s_consecutive_miss[channel] < UINT8_MAX) { + ++s_consecutive_miss[channel]; + } + if (miss_limit == 0) { + miss_limit = 1; + } + if (s_consecutive_miss[channel] >= miss_limit) { + uart_mux_restart_channel(channel); + } +} + +static bool uart_mux_extract_numeric_field(const uint8_t *data, + size_t length, + const char *key, + int *out_value) +{ + if (!data || !key || !out_value) { + return false; + } + const size_t key_len = strlen(key); + if (key_len == 0) { + return false; + } + + for (size_t i = 0; i < length; ++i) { + if (data[i] != '"') { + continue; + } + size_t j = i + 1; + if (j + key_len >= length) { + break; + } + if (memcmp(&data[j], key, key_len) != 0) { + continue; + } + j += key_len; + if (j >= length || data[j] != '"') { + continue; + } + ++j; + while (j < length && isspace((unsigned char)data[j])) { + ++j; + } + if (j >= length || data[j] != ':') { + continue; + } + ++j; + while (j < length && isspace((unsigned char)data[j])) { + ++j; + } + bool negative = false; + if (j < length && (data[j] == '-' || data[j] == '+')) { + negative = (data[j] == '-'); + ++j; + } + bool has_digit = false; + int value = 0; + while (j < length && isdigit((unsigned char)data[j])) { + has_digit = true; + value = value * 10 + (data[j] - '0'); + ++j; + } + if (has_digit) { + *out_value = negative ? -value : value; + return true; + } + } + return false; +} + +static void uart_mux_decode_status_payload(const uint8_t *data, + size_t length, + bool *hb_ack, + bool *vpn_ok, + bool *app_ok) +{ + int value = 0; + if (hb_ack) { + bool found = uart_mux_extract_numeric_field(data, length, "hb", &value); + *hb_ack = found && value == 2; + } + if (vpn_ok) { + bool found = uart_mux_extract_numeric_field(data, length, "VPN", &value); + *vpn_ok = found && value != 0; + } + if (app_ok) { + bool found = uart_mux_extract_numeric_field(data, length, "APP", &value); + *app_ok = found && value != 0; + } +} + +// Перевіряє, чи містить буфер відповідь {"hb":2} від Raspberry Pi. +static bool uart_mux_contains_hb_ack(const uint8_t *data, size_t length) +{ + bool ack = false; + uart_mux_decode_status_payload(data, length, &ack, NULL, NULL); + return ack; +} // Перемикає апаратний мультиплексор на вказаний канал під захистом мьютекса, // оновлюючи таймстемп останнього heartbeat для контролю watchdog. @@ -72,51 +235,67 @@ static esp_err_t uart_mux_select_locked(size_t channel) static void uart_mux_watchdog_task(void *arg) { const TickType_t poll_interval = pdMS_TO_TICKS(10000); - const TickType_t read_timeout = pdMS_TO_TICKS(10); + const TickType_t read_timeout = pdMS_TO_TICKS(300); const int64_t timeout_us = (int64_t)CONFIG_WATCH_UART_HEARTBEAT_TIMEOUT_SEC * 1000000LL; uint8_t buffer[CONFIG_WATCH_UART_MUX_DEFAULT_READ_LEN]; + const watch_config_t *cfg = watch_config_get(); + TickType_t start_delay = pdMS_TO_TICKS(cfg->heartbeat_start_delay_sec * 1000U); + if (start_delay > 0) { + vTaskDelay(start_delay); + } while (true) { vTaskDelay(poll_interval); + const watch_config_t *cfg = watch_config_get(); + if (!cfg->heartbeat_monitor_enabled) { + continue; + } + uint8_t miss_limit = uart_mux_get_miss_limit_from_config(); int64_t now = esp_timer_get_time(); for (size_t ch = 0; ch < UART_MUX_MAX_CHANNELS; ++ch) { + if (!dcdc_get_state(ch)) { + s_watchdog_armed[ch] = false; + s_consecutive_miss[ch] = 0; + continue; + } if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(20)) == pdTRUE) { - ws2812_status_indicate_polling(ch, 2000); if (uart_mux_select_locked(ch) == ESP_OK) { int read = uart_read_bytes(CONFIG_WATCH_UART_PORT, buffer, sizeof(buffer), read_timeout); if (read > 0) { - s_last_heartbeat_us[ch] = now; - s_consecutive_miss[ch] = 0; - ws2812_status_set_ack_state(ch, true); - } else if (s_consecutive_miss[ch] < UINT8_MAX) { - s_consecutive_miss[ch]++; - ws2812_status_set_ack_state(ch, false); + bool ack = false; + bool vpn_ok = false; + bool app_ok = false; + uart_mux_decode_status_payload(buffer, read, &ack, &vpn_ok, &app_ok); + ESP_LOGI(TAG, "UART0 RX CH%u (%d байт)%s", + (unsigned)ch, read, ack ? " [HB ACK]" : ""); + ESP_LOG_BUFFER_HEX_LEVEL(TAG, buffer, read, ESP_LOG_INFO); + if (ack) { + s_last_heartbeat_us[ch] = now; + s_consecutive_miss[ch] = 0; + s_watchdog_armed[ch] = true; + ws2812_status_set_service_state(ch, vpn_ok, app_ok); + } else { + uart_mux_record_miss(ch, miss_limit); + } + } else { + uart_mux_record_miss(ch, miss_limit); + ESP_LOGD(TAG, "UART0 RX CH%u: немає відповіді у watchdog (%u)", + (unsigned)ch, (unsigned)s_consecutive_miss[ch]); } } xSemaphoreGive(s_mutex); } - if (dcdc_get_state(ch) && s_consecutive_miss[ch] >= 3) { - ESP_LOGW(TAG, "CH%u: немає відповіді 3 рази поспіль, перезапуск живлення", (unsigned)ch); - ws2812_status_set_ack_state(ch, false); - dcdc_disable(ch); - vTaskDelay(pdMS_TO_TICKS(2000)); - dcdc_enable(ch); - s_consecutive_miss[ch] = 0; - s_last_heartbeat_us[ch] = esp_timer_get_time(); + if (dcdc_get_state(ch) && s_watchdog_armed[ch] && s_consecutive_miss[ch] >= miss_limit) { + uart_mux_restart_channel(ch); } if (dcdc_get_state(ch) && s_last_heartbeat_us[ch] > 0 && (now - s_last_heartbeat_us[ch]) > timeout_us) { - ESP_LOGW(TAG, "Heartbeat каналу %u втрачено, перезавантаження...", (unsigned)ch); - ws2812_status_set_ack_state(ch, false); - dcdc_disable(ch); - vTaskDelay(pdMS_TO_TICKS(3000)); - dcdc_enable(ch); - s_last_heartbeat_us[ch] = esp_timer_get_time(); + uart_mux_restart_channel(ch); } } } @@ -177,6 +356,9 @@ esp_err_t uart_mux_init(void) for (size_t ch = 0; ch < UART_MUX_MAX_CHANNELS; ++ch) { s_last_heartbeat_us[ch] = now; s_consecutive_miss[ch] = 0; + s_watchdog_armed[ch] = false; + s_total_miss_count[ch] = 0; + s_restart_count[ch] = 0; } if (xTaskCreate(uart_mux_watchdog_task, "uart_mux_wd", 4096, NULL, 5, &s_watchdog_task) != pdPASS) { @@ -198,7 +380,6 @@ bool uart_mux_ready(void) return false; #endif } - // Кількість доступних каналів, визначених у конфігурації. size_t uart_mux_channel_count(void) { @@ -274,3 +455,66 @@ esp_err_t uart_mux_read(size_t channel, uint8_t *buffer, size_t buffer_size, siz return err; #endif } + +void uart_mux_process_rx(size_t channel, const uint8_t *data, size_t length) +{ +#if CONFIG_WATCH_UART_MUX_ENABLED + if (!s_initialized || channel >= UART_MUX_MAX_CHANNELS || !data || length == 0) { + return; + } + bool ack = false; + bool vpn_ok = false; + bool app_ok = false; + uart_mux_decode_status_payload(data, length, &ack, &vpn_ok, &app_ok); + int64_t now = esp_timer_get_time(); + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(10)) == pdTRUE) { + s_last_heartbeat_us[channel] = now; + if (ack) { + s_consecutive_miss[channel] = 0; + s_watchdog_armed[channel] = true; + } + xSemaphoreGive(s_mutex); + } + if (ack) { + ws2812_status_set_service_state(channel, vpn_ok, app_ok); + } else { + uart_mux_record_miss(channel, uart_mux_get_miss_limit_from_config()); + } +#else + (void)channel; + (void)data; + (void)length; +#endif +} + +void uart_mux_report_miss(size_t channel) +{ +#if CONFIG_WATCH_UART_MUX_ENABLED + if (!s_initialized) { + return; + } + uart_mux_record_miss(channel, uart_mux_get_miss_limit_from_config()); +#else + (void)channel; +#endif +} + +void uart_mux_get_channel_stats(size_t channel, uart_mux_channel_stats_t *out_stats) +{ + if (!out_stats) { + return; + } +#if CONFIG_WATCH_UART_MUX_ENABLED + if (!s_initialized || channel >= UART_MUX_MAX_CHANNELS) { + out_stats->missed_heartbeats = 0; + out_stats->restart_count = 0; + return; + } + out_stats->missed_heartbeats = s_total_miss_count[channel]; + out_stats->restart_count = s_restart_count[channel]; +#else + (void)channel; + out_stats->missed_heartbeats = 0; + out_stats->restart_count = 0; +#endif +} diff --git a/main/uart_mux.h b/main/uart_mux.h index 15e2d03..a73e2ac 100644 --- a/main/uart_mux.h +++ b/main/uart_mux.h @@ -7,10 +7,16 @@ #pragma once #include +#include #include "freertos/FreeRTOS.h" #include "esp_err.h" +typedef struct { + uint32_t missed_heartbeats; + uint32_t restart_count; +} uart_mux_channel_stats_t; + // Ініціалізує апаратний мультиплексор UART: GPIO, UART драйвер та watchdog. esp_err_t uart_mux_init(void); // Повертає true, якщо драйвер готовий приймати виклики. @@ -21,3 +27,9 @@ size_t uart_mux_channel_count(void); esp_err_t uart_mux_write(size_t channel, const uint8_t *data, size_t length, TickType_t timeout); // Зчитує дані з каналу, повертаючи кількість байтів через out_length. esp_err_t uart_mux_read(size_t channel, uint8_t *buffer, size_t buffer_size, size_t *out_length, TickType_t timeout); +// Повідомляє драйверу про дані, зчитані поза watchdog-ом (для оновлення станів). +void uart_mux_process_rx(size_t channel, const uint8_t *data, size_t length); +// Повідомляє про пропущений heartbeat (використовується основним циклом). +void uart_mux_report_miss(size_t channel); +// Повертає статистику по каналу (кількість пропущених відповідей та рестартів). +void uart_mux_get_channel_stats(size_t channel, uart_mux_channel_stats_t *out_stats); diff --git a/main/usb_cdc_cli.c b/main/usb_cdc_cli.c index 9d68e84..c0dae39 100644 --- a/main/usb_cdc_cli.c +++ b/main/usb_cdc_cli.c @@ -11,6 +11,7 @@ #include "dcdc_controller.h" #include "esp_check.h" #include "esp_log.h" +#include "esp_system.h" #include "freertos/FreeRTOS.h" #include "freertos/queue.h" #include "freertos/task.h" @@ -18,8 +19,12 @@ #include "tinyusb.h" #include "tusb_cdc_acm.h" #include "ws2812_status.h" +#include "watch_config.h" +#include "esp_system.h" #include "ina226_monitor.h" #include "uart_mux.h" +#include "soc/rtc_cntl_reg.h" +#include "soc/soc.h" #define CLI_LINE_MAX_LEN 128 #define CLI_QUEUE_LEN 8 @@ -45,6 +50,10 @@ static bool s_host_ready; static void usb_cli_task(void *arg); static void usb_cli_process_line(char *line); static void usb_cli_prompt(void); +static void usb_cli_enter_bootloader(void); +static void usb_cli_handle_config(char *args); +static void usb_cli_print_config(void); +static void usb_cli_handle_reset(void); static void usb_cli_write_raw(const char *data, size_t len) { @@ -112,10 +121,16 @@ static void usb_cli_print_status(void) usb_cli_printf("\r\nСтан каналів:\r\n"); for (size_t i = 0; i < dcdc_channel_count(); ++i) { dcdc_channel_t ch = (dcdc_channel_t)i; - usb_cli_printf(" - CH%u: %s (GPIO %d)\r\n", + uart_mux_channel_stats_t stats = {0}; + if (uart_mux_ready()) { + uart_mux_get_channel_stats(i, &stats); + } + usb_cli_printf(" - CH%u: %s (GPIO %d) | miss=%u restart=%u\r\n", (unsigned)i, dcdc_get_state(ch) ? "ON" : "OFF", - (int)dcdc_get_gpio(ch)); + (int)dcdc_get_gpio(ch), + (unsigned)stats.missed_heartbeats, + (unsigned)stats.restart_count); } } @@ -124,6 +139,18 @@ static void usb_cli_prompt(void) usb_cli_write_raw(PROMPT, strlen(PROMPT)); } +static void usb_cli_enter_bootloader(void) +{ + usb_cli_printf("\r\nПерехід у режим прошивки esptool...\r\n" + "Після перезавантаження з’явиться ROM-порт USB CDC/Serial.\r\n" + "Запустіть esptool.py або idf.py flash та прошийте пристрій. " + "Для виходу з bootloader виконайте 'esptool.py --after hard_reset reset' або перезавантажте живлення.\r\n"); + vTaskDelay(pdMS_TO_TICKS(100)); + tinyusb_driver_uninstall(); + REG_WRITE(RTC_CNTL_OPTION1_REG, RTC_CNTL_FORCE_DOWNLOAD_BOOT); + esp_restart(); +} + static void usb_cli_handle_switch(const char *cmd, char *args) { dcdc_channel_t channel; @@ -246,14 +273,22 @@ static void usb_cli_process_line(char *line) if (strcasecmp(cmd, "help") == 0) { usb_cli_printf( "\r\nДоступні команди:\r\n" - " help - показати довідку\r\n" - " status - стан всіх каналів\r\n" - " enable - увімкнути канал n\r\n" - " disable - вимкнути канал n\r\n" - " toggle - перемкнути канал n\r\n" - " sense [n] - показати напругу/струм/потужність (опц. канал)\r\n" - " uart send - відправити повідомлення Pi n\r\n" - " uart read [len] - прочитати дані з Pi n\r\n"); + " help - показати цю довідку\r\n" + " status - показати стан усіх каналів DCDC\r\n" + " enable - увімкнути канал n (0..4)\r\n" + " disable - вимкнути канал n\r\n" + " toggle - перемкнути канал n\r\n" + " sense - виміряти напругу/струм/потужність INA226\r\n" + " uart send - надіслати текстове повідомлення в Raspberry Pi n\r\n" + " uart read [len] - прочитати до [len] байт відповіді від Raspberry Pi n\r\n" + " config show - показати збережені таймінги heartbeat/DCDC\r\n" + " config set hb_period <сек> - змінити інтервал відправки heartbeat\r\n" + " config set dcdc_off <сек> - задати тривалість вимкнення DCDC при рестарті\r\n" + " config set hb_start <сек> - налаштувати затримку перед стартом опитування після boot\r\n" + " config set hb_monitor <0|1> - увімкнути/вимкнути контроль відповіді heartbeat\r\n" + " config set hb_miss <шт> - кількість пропусків відповіді до рестарту каналу\r\n" + " reset - м'яко перезавантажити ESP32-S3\r\n" + " bootloader - перезавантажити ESP32-S3 у ROM bootloader для esptool\r\n"); } else if (strcasecmp(cmd, "status") == 0) { usb_cli_print_status(); } else if (strcasecmp(cmd, "enable") == 0 || @@ -264,11 +299,109 @@ static void usb_cli_process_line(char *line) usb_cli_handle_sense(save_ptr); } else if (strcasecmp(cmd, "uart") == 0) { usb_cli_handle_uart(save_ptr); + } else if (strcasecmp(cmd, "config") == 0) { + usb_cli_handle_config(save_ptr); + } else if (strcasecmp(cmd, "reset") == 0) { + usb_cli_handle_reset(); + } else if (strcasecmp(cmd, "bootloader") == 0) { + usb_cli_enter_bootloader(); } else { usb_cli_printf("\r\nНевідома команда '%s'\r\n", cmd); } } +static void usb_cli_print_config(void) +{ + const watch_config_t *cfg = watch_config_get(); + usb_cli_printf( + "\r\nПоточні налаштування:\r\n" + " hb_period: %u с\r\n" + " dcdc_off: %u с\r\n" + " hb_start_delay: %u с\r\n" + " hb_monitor: %s\r\n" + " hb_miss_limit: %u зап.\r\n", + (unsigned)cfg->heartbeat_period_sec, + (unsigned)cfg->dcdc_restart_off_sec, + (unsigned)cfg->heartbeat_start_delay_sec, + cfg->heartbeat_monitor_enabled ? "on" : "off", + (unsigned)cfg->heartbeat_miss_limit); +} + +static void usb_cli_handle_config(char *args) +{ + if (!args || *args == '\0') { + usb_cli_print_config(); + return; + } + + char *ctx = NULL; + char *action = strtok_r(args, " ", &ctx); + if (!action || strcasecmp(action, "show") == 0) { + usb_cli_print_config(); + return; + } + + if (strcasecmp(action, "set") != 0) { + usb_cli_printf("\r\nВикористання: config show | config set <знач>\r\n"); + return; + } + + char *param = strtok_r(NULL, " ", &ctx); + char *value_str = strtok_r(NULL, " ", &ctx); + if (!param || !value_str) { + usb_cli_printf("\r\nВкажіть параметр і значення в секундах\r\n"); + return; + } + + uint32_t value = (uint32_t)strtoul(value_str, NULL, 10); + + watch_config_t new_cfg = *watch_config_get(); + if (strcasecmp(param, "hb_period") == 0) { + if (value == 0) { + usb_cli_printf("\r\nЗначення має бути більше нуля\r\n"); + return; + } + new_cfg.heartbeat_period_sec = value; + } else if (strcasecmp(param, "dcdc_off") == 0) { + if (value == 0) { + usb_cli_printf("\r\nЗначення має бути більше нуля\r\n"); + return; + } + new_cfg.dcdc_restart_off_sec = value; + } else if (strcasecmp(param, "hb_start") == 0 || + strcasecmp(param, "hb_start_delay") == 0) { + if (value == 0) { + usb_cli_printf("\r\nЗначення має бути більше нуля\r\n"); + return; + } + new_cfg.heartbeat_start_delay_sec = value; + } else if (strcasecmp(param, "hb_monitor") == 0) { + if (value != 0 && value != 1) { + usb_cli_printf("\r\nhb_monitor приймає 0 або 1\r\n"); + return; + } + new_cfg.heartbeat_monitor_enabled = (value != 0); + } else if (strcasecmp(param, "hb_miss") == 0 || + strcasecmp(param, "hb_miss_limit") == 0) { + if (value == 0) { + usb_cli_printf("\r\nhb_miss має бути більше нуля\r\n"); + return; + } + new_cfg.heartbeat_miss_limit = value; + } else { + usb_cli_printf("\r\nНевідомий параметр '%s'\r\n", param); + return; + } + + esp_err_t err = watch_config_save(&new_cfg); + if (err == ESP_OK) { + usb_cli_printf("\r\nПараметр оновлено\r\n"); + usb_cli_print_config(); + } else { + usb_cli_printf("\r\nПомилка збереження конфігурації: %s\r\n", esp_err_to_name(err)); + } +} + static void usb_cli_task(void *arg) { usb_cli_msg_t msg; @@ -285,14 +418,17 @@ static void usb_cli_task(void *arg) usb_cli_process_line(line); line_len = 0; } + usb_cli_write_raw("\r\n", 2); usb_cli_prompt(); } else if (ch == 0x7F || ch == '\b') { if (line_len > 0) { line_len--; + usb_cli_write_raw("\b \b", 3); } } else if (isprint((unsigned char)ch)) { if (line_len < CLI_LINE_MAX_LEN - 1) { line[line_len++] = ch; + usb_cli_write_raw(&ch, 1); } else { usb_cli_printf("\r\nРядок занадто довгий\r\n"); line_len = 0; @@ -386,3 +522,9 @@ esp_err_t usb_cdc_cli_init(void) ESP_LOGI(TAG, "USB CDC CLI ініціалізовано"); return ESP_OK; } +static void usb_cli_handle_reset(void) +{ + usb_cli_printf("\r\nПерезавантаження пристрою...\r\n"); + vTaskDelay(pdMS_TO_TICKS(100)); + esp_restart(); +} diff --git a/main/usb_cdc_log.c b/main/usb_cdc_log.c index 714b68e..1c6af23 100644 --- a/main/usb_cdc_log.c +++ b/main/usb_cdc_log.c @@ -1,6 +1,5 @@ #include "usb_cdc_log.h" -#include #include #include "esp_check.h" @@ -13,28 +12,6 @@ static const char *TAG = "usb_log"; static bool s_initialized; static bool s_host_ready; -static int usb_cdc_vprintf(const char *fmt, va_list args) -{ - if (s_host_ready) { - char buffer[256]; - va_list args_copy; - va_copy(args_copy, args); - int len = vsnprintf(buffer, sizeof(buffer), fmt, args_copy); - va_end(args_copy); - if (len > 0) { - if (len >= (int)sizeof(buffer)) { - len = sizeof(buffer) - 1; - } - if (tinyusb_cdcacm_write_queue(TINYUSB_CDC_ACM_0, (const uint8_t *)buffer, len) == ESP_OK) { - tinyusb_cdcacm_write_flush(TINYUSB_CDC_ACM_0, 0); - } - } - return len; - } - - return 0; -} - static void usb_cdc_line_state_changed_callback(int itf, cdcacm_event_t *event) { (void)itf; @@ -83,8 +60,7 @@ esp_err_t usb_cdc_log_init(void) }; ESP_RETURN_ON_ERROR(tusb_cdc_acm_init(&acm_cfg), TAG, "CDC init failed"); - esp_log_set_vprintf(usb_cdc_vprintf); s_initialized = true; - ESP_LOGI(TAG, "ESP_LOG перенаправлено в USB CDC"); + ESP_LOGI(TAG, "USB CDC лог ініціалізовано (перенаправлення ESP_LOG вимкнено)"); return ESP_OK; } diff --git a/main/watch_config.c b/main/watch_config.c new file mode 100644 index 0000000..a3d9838 --- /dev/null +++ b/main/watch_config.c @@ -0,0 +1,141 @@ +#include "watch_config.h" + +#include + +#include "esp_check.h" +#include "esp_log.h" +#include "nvs.h" +#include "nvs_flash.h" + +#define WATCH_CONFIG_NAMESPACE "watchcfg" +#define KEY_HB_PERIOD "hb_period" +#define KEY_DCDC_OFF "dcdc_off" +#define KEY_HB_START "hb_start" +#define KEY_HB_MON "hb_mon" +#define KEY_HB_MISS "hb_miss" + +#define DEFAULT_HB_PERIOD_SEC 4U +#define DEFAULT_DCDC_OFF_SEC 2U +#define DEFAULT_HB_START_SEC 2U +#define DEFAULT_HB_MISS_LIMIT 3U + +static watch_config_t s_config = { + .heartbeat_period_sec = DEFAULT_HB_PERIOD_SEC, + .dcdc_restart_off_sec = DEFAULT_DCDC_OFF_SEC, + .heartbeat_start_delay_sec = DEFAULT_HB_START_SEC, + .heartbeat_monitor_enabled = true, + .heartbeat_miss_limit = DEFAULT_HB_MISS_LIMIT, +}; +static const char *TAG = "watch_cfg"; +static bool s_initialized; + +static void watch_config_apply_defaults(void) +{ + s_config.heartbeat_period_sec = DEFAULT_HB_PERIOD_SEC; + s_config.dcdc_restart_off_sec = DEFAULT_DCDC_OFF_SEC; + s_config.heartbeat_start_delay_sec = DEFAULT_HB_START_SEC; + s_config.heartbeat_monitor_enabled = true; + s_config.heartbeat_miss_limit = DEFAULT_HB_MISS_LIMIT; +} + +static void watch_config_clamp(watch_config_t *cfg) +{ + if (!cfg->heartbeat_period_sec) { + cfg->heartbeat_period_sec = DEFAULT_HB_PERIOD_SEC; + } + if (!cfg->dcdc_restart_off_sec) { + cfg->dcdc_restart_off_sec = DEFAULT_DCDC_OFF_SEC; + } + if (!cfg->heartbeat_start_delay_sec) { + cfg->heartbeat_start_delay_sec = DEFAULT_HB_START_SEC; + } + cfg->heartbeat_monitor_enabled = cfg->heartbeat_monitor_enabled ? true : false; + if (!cfg->heartbeat_miss_limit) { + cfg->heartbeat_miss_limit = DEFAULT_HB_MISS_LIMIT; + } +} + +esp_err_t watch_config_init(void) +{ + if (s_initialized) { + return ESP_OK; + } + + esp_err_t err = nvs_flash_init(); + if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_RETURN_ON_ERROR(nvs_flash_erase(), TAG, "NVS erase failed"); + err = nvs_flash_init(); + } + ESP_RETURN_ON_ERROR(err, TAG, "NVS init failed"); + + watch_config_apply_defaults(); + + nvs_handle_t handle = 0; + err = nvs_open(WATCH_CONFIG_NAMESPACE, NVS_READWRITE, &handle); + ESP_RETURN_ON_ERROR(err, TAG, "NVS open failed"); + + uint32_t value = 0; + if (nvs_get_u32(handle, KEY_HB_PERIOD, &value) == ESP_OK && value > 0) { + s_config.heartbeat_period_sec = value; + } + if (nvs_get_u32(handle, KEY_DCDC_OFF, &value) == ESP_OK && value > 0) { + s_config.dcdc_restart_off_sec = value; + } + if (nvs_get_u32(handle, KEY_HB_START, &value) == ESP_OK && value > 0) { + s_config.heartbeat_start_delay_sec = value; + } + uint8_t hb_mon = 1; + if (nvs_get_u8(handle, KEY_HB_MON, &hb_mon) == ESP_OK) { + s_config.heartbeat_monitor_enabled = (hb_mon != 0); + } + if (nvs_get_u32(handle, KEY_HB_MISS, &value) == ESP_OK && value > 0) { + s_config.heartbeat_miss_limit = value; + } + + nvs_close(handle); + s_initialized = true; + return ESP_OK; +} + +const watch_config_t *watch_config_get(void) +{ + return &s_config; +} + +esp_err_t watch_config_save(const watch_config_t *cfg) +{ + if (!cfg) { + return ESP_ERR_INVALID_ARG; + } + + watch_config_t tmp = *cfg; + watch_config_clamp(&tmp); + + nvs_handle_t handle = 0; + ESP_RETURN_ON_ERROR(nvs_open(WATCH_CONFIG_NAMESPACE, NVS_READWRITE, &handle), + TAG, "NVS open failed"); + + esp_err_t err = nvs_set_u32(handle, KEY_HB_PERIOD, tmp.heartbeat_period_sec); + if (err == ESP_OK) { + err = nvs_set_u32(handle, KEY_DCDC_OFF, tmp.dcdc_restart_off_sec); + } + if (err == ESP_OK) { + err = nvs_set_u32(handle, KEY_HB_START, tmp.heartbeat_start_delay_sec); + } + if (err == ESP_OK) { + err = nvs_set_u8(handle, KEY_HB_MON, tmp.heartbeat_monitor_enabled ? 1 : 0); + } + if (err == ESP_OK) { + err = nvs_set_u32(handle, KEY_HB_MISS, tmp.heartbeat_miss_limit); + } + if (err == ESP_OK) { + err = nvs_commit(handle); + } + nvs_close(handle); + if (err != ESP_OK) { + return err; + } + + s_config = tmp; + return ESP_OK; +} diff --git a/main/watch_config.h b/main/watch_config.h new file mode 100644 index 0000000..0618b1b --- /dev/null +++ b/main/watch_config.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +#include "esp_err.h" + +typedef struct { + uint32_t heartbeat_period_sec; + uint32_t dcdc_restart_off_sec; + uint32_t heartbeat_start_delay_sec; + bool heartbeat_monitor_enabled; + uint32_t heartbeat_miss_limit; +} watch_config_t; + +esp_err_t watch_config_init(void); +const watch_config_t *watch_config_get(void); +esp_err_t watch_config_save(const watch_config_t *cfg); diff --git a/main/ws2812_status.c b/main/ws2812_status.c index 066992e..fa97c5b 100644 --- a/main/ws2812_status.c +++ b/main/ws2812_status.c @@ -22,22 +22,142 @@ #define WS2812_STATUS_GPIO ((gpio_num_t)CONFIG_WATCH_WS2812_GPIO) #define WS2812_STATUS_RESOLUTION_HZ CONFIG_WATCH_WS2812_RMT_RESOLUTION -#define WS2812_POLL_BLINK_PERIOD pdMS_TO_TICKS(250) +#define WS2812_ANIM_REFRESH pdMS_TO_TICKS(100) +#define WS2812_ALERT_BLINK_PERIOD pdMS_TO_TICKS(200) +#define WS2812_ALERT_GAP_PERIOD pdMS_TO_TICKS(2000) +#define WS2812_VPN_ALERT_BLINKS 2 +#define WS2812_APP_ALERT_BLINKS 3 + +#define WS2812_VPN_SECTION_TICKS (WS2812_ALERT_BLINK_PERIOD * 2U * WS2812_VPN_ALERT_BLINKS) +#define WS2812_APP_SECTION_TICKS (WS2812_ALERT_BLINK_PERIOD * 2U * WS2812_APP_ALERT_BLINKS) static const char *TAG = "ws2812"; static led_strip_handle_t s_strip; -static bool s_led_state[WS2812_STATUS_LED_COUNT]; -static bool s_ack_state[WS2812_STATUS_LED_COUNT]; -static bool s_error_state; -static size_t s_active_channel = SIZE_MAX; -static bool s_polling_active[WS2812_STATUS_LED_COUNT]; -static bool s_polling_visible[WS2812_STATUS_LED_COUNT]; -static TickType_t s_polling_expire_tick[WS2812_STATUS_LED_COUNT]; -static TimerHandle_t s_polling_timers[WS2812_STATUS_LED_COUNT]; +static bool s_channel_enabled[WS2812_STATUS_LED_COUNT]; +static bool s_vpn_state[WS2812_STATUS_LED_COUNT]; +static bool s_app_state[WS2812_STATUS_LED_COUNT]; static SemaphoreHandle_t s_ws_lock; +static TimerHandle_t s_animation_timer; +static bool s_error_state; +static bool s_startup_hold; +static TickType_t s_startup_expire_tick; +static TickType_t s_alert_cycle_epoch; +static size_t s_active_vpn_alerts; +static size_t s_active_app_alerts; -static void ws2812_polling_timer_cb(TimerHandle_t timer); +static esp_err_t ws2812_status_apply(void); +static void ws2812_animation_timer_cb(TimerHandle_t timer); +static void ws2812_status_compute_color(size_t index, + TickType_t now_ticks, + uint8_t *r, + uint8_t *g, + uint8_t *b); +static bool ws2812_recalculate_alert_counts(void); + +static void ws2812_animation_timer_cb(TimerHandle_t timer) +{ + (void)timer; + ws2812_status_apply(); +} + +static bool ws2812_startup_hold_active(TickType_t now_ticks) +{ + if (!s_startup_hold) { + return false; + } + if (now_ticks >= s_startup_expire_tick) { + s_startup_hold = false; + return false; + } + return true; +} + +static void ws2812_status_compute_color(size_t index, + TickType_t now_ticks, + uint8_t *r, + uint8_t *g, + uint8_t *b) +{ + if (!r || !g || !b || index >= WS2812_STATUS_LED_COUNT) { + return; + } + + *r = 0; + *g = 0; + *b = 0; + + if (s_error_state) { + *r = 40; + return; + } + + if (ws2812_startup_hold_active(now_ticks)) { + *g = 40; + return; + } + + if (!s_channel_enabled[index]) { + return; + } + + const bool vpn_alert = !s_vpn_state[index]; + const bool app_alert = !s_app_state[index]; + if (!vpn_alert && !app_alert) { + return; + } + + const bool vpn_global = (s_active_vpn_alerts > 0); + const bool app_global = (s_active_app_alerts > 0); + + TickType_t cycle_ticks = 0; + if (vpn_global) { + cycle_ticks += WS2812_VPN_SECTION_TICKS + WS2812_ALERT_GAP_PERIOD; + } + if (app_global) { + cycle_ticks += WS2812_APP_SECTION_TICKS + WS2812_ALERT_GAP_PERIOD; + } + if (cycle_ticks == 0) { + return; + } + + TickType_t blink_period = WS2812_ALERT_BLINK_PERIOD ? WS2812_ALERT_BLINK_PERIOD : 1; + TickType_t cycle_pos = (now_ticks - s_alert_cycle_epoch) % cycle_ticks; + + if (vpn_global) { + if (cycle_pos < WS2812_VPN_SECTION_TICKS) { + if (vpn_alert) { + const bool on = ((cycle_pos / blink_period) % 2U) == 0U; + if (on) { + *r = 90; + } + } + return; + } + cycle_pos -= WS2812_VPN_SECTION_TICKS; + if (cycle_pos < WS2812_ALERT_GAP_PERIOD) { + return; + } + cycle_pos -= WS2812_ALERT_GAP_PERIOD; + } + + if (app_global) { + if (cycle_pos < WS2812_APP_SECTION_TICKS) { + if (app_alert) { + const bool on = ((cycle_pos / blink_period) % 2U) == 0U; + if (on) { + *r = 80; + *g = 80; + } + } + return; + } + cycle_pos -= WS2812_APP_SECTION_TICKS; + if (cycle_pos < WS2812_ALERT_GAP_PERIOD) { + return; + } + } +} static esp_err_t ws2812_status_apply(void) { @@ -47,32 +167,15 @@ static esp_err_t ws2812_status_apply(void) if (!s_ws_lock) { return ESP_ERR_INVALID_STATE; } - if (xSemaphoreTake(s_ws_lock, portMAX_DELAY) != pdTRUE) { return ESP_ERR_TIMEOUT; } + const TickType_t now_ticks = xTaskGetTickCount(); esp_err_t err = ESP_OK; for (size_t i = 0; i < WS2812_STATUS_LED_COUNT && err == ESP_OK; ++i) { uint8_t r = 0, g = 0, b = 0; - if (s_error_state) { - r = 40; - } else if (!s_led_state[i]) { - b = 5; - } else if (s_ack_state[i]) { - g = (s_active_channel == i) ? 60 : 25; - } else { - b = (s_active_channel == i) ? 60 : 25; - } - - if (s_polling_active[i] && !s_error_state && s_led_state[i]) { - if (!s_polling_visible[i]) { - r /= 4; - g /= 4; - b /= 4; - } - } - // Swap R/G to match physical RGB order (driver outputs GRB) + ws2812_status_compute_color(i, now_ticks, &r, &g, &b); err = led_strip_set_pixel(s_strip, i, g, r, b); } if (err == ESP_OK) { @@ -95,7 +198,6 @@ esp_err_t ws2812_status_init(void) .led_model = LED_MODEL_WS2812, .flags.invert_out = false, }; - led_strip_rmt_config_t rmt_cfg = { .clk_src = RMT_CLK_SRC_DEFAULT, .resolution_hz = WS2812_STATUS_RESOLUTION_HZ, @@ -111,204 +213,142 @@ esp_err_t ws2812_status_init(void) s_ws_lock = xSemaphoreCreateMutex(); ESP_RETURN_ON_FALSE(s_ws_lock, ESP_ERR_NO_MEM, TAG, "mutex alloc failed"); + const TickType_t now = xTaskGetTickCount(); for (size_t i = 0; i < WS2812_STATUS_LED_COUNT; ++i) { - s_led_state[i] = false; - s_ack_state[i] = false; - s_polling_active[i] = false; - s_polling_visible[i] = false; - s_polling_expire_tick[i] = 0; - s_polling_timers[i] = NULL; + s_channel_enabled[i] = false; + s_vpn_state[i] = true; + s_app_state[i] = true; } - s_active_channel = SIZE_MAX; s_error_state = false; + s_startup_hold = false; + s_startup_expire_tick = 0; + s_alert_cycle_epoch = now; + (void)ws2812_recalculate_alert_counts(); + if (!s_animation_timer) { + s_animation_timer = xTimerCreate("ws2812_anim", + WS2812_ANIM_REFRESH, + pdTRUE, + NULL, + ws2812_animation_timer_cb); + ESP_RETURN_ON_FALSE(s_animation_timer, ESP_ERR_NO_MEM, TAG, "anim timer alloc failed"); + ESP_RETURN_ON_FALSE(xTimerStart(s_animation_timer, 0) == pdPASS, + ESP_FAIL, + TAG, + "anim timer start failed"); + } + + (void)ws2812_recalculate_alert_counts(); return ws2812_status_apply(); } -static void ws2812_polling_timer_cb(TimerHandle_t timer) -{ - size_t channel = (size_t)pvTimerGetTimerID(timer); - if (channel >= WS2812_STATUS_LED_COUNT) { - return; - } - TickType_t now = xTaskGetTickCount(); - if (now >= s_polling_expire_tick[channel]) { - s_polling_active[channel] = false; - s_polling_visible[channel] = false; - s_polling_expire_tick[channel] = 0; - xTimerStop(timer, 0); - } else { - s_polling_visible[channel] = !s_polling_visible[channel]; - } - ws2812_status_apply(); -} - -esp_err_t ws2812_status_indicate_polling(size_t channel, uint32_t duration_ms) -{ - if (channel >= WS2812_STATUS_LED_COUNT) { - return ESP_ERR_INVALID_ARG; - } - if (!s_strip) { - return ESP_ERR_INVALID_STATE; - } - if (duration_ms == 0) { - duration_ms = 2000; - } - - TickType_t duration_ticks = pdMS_TO_TICKS(duration_ms); - if (duration_ticks == 0) { - duration_ticks = 1; - } - - if (!s_polling_timers[channel]) { - s_polling_timers[channel] = xTimerCreate("ws2812_poll", - WS2812_POLL_BLINK_PERIOD, - pdTRUE, - (void *)channel, - ws2812_polling_timer_cb); - if (!s_polling_timers[channel]) { - return ESP_ERR_NO_MEM; - } - } - - s_polling_active[channel] = true; - s_polling_visible[channel] = true; - s_polling_expire_tick[channel] = xTaskGetTickCount() + duration_ticks; - esp_err_t err = ws2812_status_apply(); - if (err != ESP_OK) { - return err; - } - - BaseType_t res; - if (xTimerIsTimerActive(s_polling_timers[channel]) == pdTRUE) { - res = xTimerReset(s_polling_timers[channel], 0); - } else { - res = xTimerStart(s_polling_timers[channel], 0); - } - return (res == pdPASS) ? ESP_OK : ESP_FAIL; -} - esp_err_t ws2812_status_set_channel_state(size_t channel, bool enabled) { if (channel >= WS2812_STATUS_LED_COUNT) { return ESP_ERR_INVALID_ARG; } - s_led_state[channel] = enabled; + if (!s_strip) { + return ESP_ERR_INVALID_STATE; + } + s_channel_enabled[channel] = enabled; if (!enabled) { - s_ack_state[channel] = false; + s_vpn_state[channel] = true; + s_app_state[channel] = true; } return ws2812_status_apply(); } -esp_err_t ws2812_status_mark_active(size_t channel) -{ - if (channel >= WS2812_STATUS_LED_COUNT) { - return ESP_ERR_INVALID_ARG; - } - s_active_channel = channel; - return ws2812_status_apply(); -} - -esp_err_t ws2812_status_clear_active(void) -{ - s_active_channel = SIZE_MAX; - return ws2812_status_apply(); -} - esp_err_t ws2812_status_set_error(bool has_error) { - s_error_state = has_error; - if (has_error) { - s_active_channel = SIZE_MAX; + if (!s_strip) { + return ESP_ERR_INVALID_STATE; } + s_error_state = has_error; return ws2812_status_apply(); } -esp_err_t ws2812_status_set_ack_state(size_t channel, bool received) +esp_err_t ws2812_status_set_service_state(size_t channel, bool vpn_ok, bool app_ok) { if (channel >= WS2812_STATUS_LED_COUNT) { return ESP_ERR_INVALID_ARG; } - s_ack_state[channel] = received; + if (!s_strip) { + return ESP_ERR_INVALID_STATE; + } + bool changed = false; + if (s_vpn_state[channel] != vpn_ok) { + s_vpn_state[channel] = vpn_ok; + changed = true; + } + if (s_app_state[channel] != app_ok) { + s_app_state[channel] = app_ok; + changed = true; + } + if (changed) { + (void)ws2812_recalculate_alert_counts(); + s_alert_cycle_epoch = xTaskGetTickCount(); + } return ws2812_status_apply(); } esp_err_t ws2812_status_refresh_from_dcdc(void) { + if (!s_strip) { + return ESP_ERR_INVALID_STATE; + } const size_t available_channels = dcdc_channel_count(); const size_t count = available_channels < WS2812_STATUS_LED_COUNT ? available_channels : WS2812_STATUS_LED_COUNT; - for (size_t i = 0; i < count; ++i) { - bool enabled = dcdc_get_state(i); - s_led_state[i] = enabled; - if (!enabled) { - s_ack_state[i] = false; - } + s_channel_enabled[i] = dcdc_get_state(i); } for (size_t i = count; i < WS2812_STATUS_LED_COUNT; ++i) { - s_led_state[i] = false; - s_ack_state[i] = false; + s_channel_enabled[i] = false; + s_vpn_state[i] = true; + s_app_state[i] = true; } + (void)ws2812_recalculate_alert_counts(); + s_alert_cycle_epoch = xTaskGetTickCount(); return ws2812_status_apply(); } -esp_err_t ws2812_status_play_bringup_animation(size_t cycles, uint32_t step_delay_ms) +esp_err_t ws2812_status_set_startup_hold(uint32_t duration_ms) { if (!s_strip) { return ESP_ERR_INVALID_STATE; } - - if (cycles == 0) { - cycles = 1; - } - if (step_delay_ms == 0) { - step_delay_ms = 150; + if (duration_ms == 0) { + s_startup_hold = false; + s_startup_expire_tick = 0; + return ws2812_status_apply(); } - bool saved_led_state[WS2812_STATUS_LED_COUNT]; + TickType_t duration_ticks = pdMS_TO_TICKS(duration_ms); + if (duration_ticks == 0) { + duration_ticks = 1; + } + s_startup_hold = true; + s_startup_expire_tick = xTaskGetTickCount() + duration_ticks; + return ws2812_status_apply(); +} +static bool ws2812_recalculate_alert_counts(void) +{ + size_t vpn = 0; + size_t app = 0; for (size_t i = 0; i < WS2812_STATUS_LED_COUNT; ++i) { - saved_led_state[i] = s_led_state[i]; - } - const bool saved_error = s_error_state; - const size_t saved_active = s_active_channel; - - s_error_state = false; - s_active_channel = SIZE_MAX; - - const TickType_t delay_ticks = pdMS_TO_TICKS(step_delay_ms); - esp_err_t err = ESP_OK; - - for (size_t cycle = 0; cycle < cycles && err == ESP_OK; ++cycle) { - for (size_t led = 0; led < WS2812_STATUS_LED_COUNT; ++led) { - for (size_t idx = 0; idx < WS2812_STATUS_LED_COUNT; ++idx) { - const uint8_t g = (idx == led) ? 35 : 0; - err = led_strip_set_pixel(s_strip, idx, 0, g, 0); - if (err != ESP_OK) { - break; - } - } - if (err != ESP_OK) { - break; - } - err = led_strip_refresh(s_strip); - if (err != ESP_OK) { - break; - } - vTaskDelay(delay_ticks); + if (!s_channel_enabled[i]) { + continue; + } + if (!s_vpn_state[i]) { + ++vpn; + } + if (!s_app_state[i]) { + ++app; } } - - for (size_t i = 0; i < WS2812_STATUS_LED_COUNT; ++i) { - s_led_state[i] = saved_led_state[i]; - } - s_error_state = saved_error; - s_active_channel = saved_active; - - esp_err_t restore_err = ws2812_status_apply(); - if (err != ESP_OK) { - return err; - } - return restore_err; + const bool changed = (vpn != s_active_vpn_alerts) || (app != s_active_app_alerts); + s_active_vpn_alerts = vpn; + s_active_app_alerts = app; + return changed; } diff --git a/main/ws2812_status.h b/main/ws2812_status.h index fb467e9..f6b42ce 100644 --- a/main/ws2812_status.h +++ b/main/ws2812_status.h @@ -15,10 +15,7 @@ esp_err_t ws2812_status_init(void); esp_err_t ws2812_status_set_channel_state(size_t channel, bool enabled); -esp_err_t ws2812_status_mark_active(size_t channel); -esp_err_t ws2812_status_clear_active(void); esp_err_t ws2812_status_set_error(bool has_error); -esp_err_t ws2812_status_set_ack_state(size_t channel, bool received); -esp_err_t ws2812_status_indicate_polling(size_t channel, uint32_t duration_ms); +esp_err_t ws2812_status_set_service_state(size_t channel, bool vpn_ok, bool app_ok); +esp_err_t ws2812_status_set_startup_hold(uint32_t duration_ms); esp_err_t ws2812_status_refresh_from_dcdc(void); -esp_err_t ws2812_status_play_bringup_animation(size_t cycles, uint32_t step_delay_ms);