diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 7e072c4..981069d 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,5 +1,23 @@ set(requires esp-tls spi_flash nvs_flash esp_event esp_netif esp_http_client esp_wifi esp_psram esp_lvgl_port) -file(GLOB SRCS "main.cpp" "*.cpp" "*.c" "**/*.cpp" "**/*.c") +file(GLOB SRCS "main.cpp" "*.cpp" "*.c" "**/*.cpp" "**/*.cpp" "ui/**/*.cpp" "ui/**/*.c") +# Explicitly list all source files to ensure build system picks them up +# set(SRCS +# "main.cpp" +# "display/display.cpp" +# "display/eink_display_handler.cpp" +# "info/info.cpp" +# "io/nvs_handler.cpp" +# "network/http_handler.cpp" +# "network/network.cpp" +# "network/udp_client.cpp" +# "network/wifi_handler.cpp" +# "ui/page_stack.cpp" +# "ui/root_layout.cpp" +# "ui/ui_handler.cpp" +# "ui/apps/demo_app.cpp" +# "ui/apps/discord_app.cpp" +# "ui/apps/shutdown_app.cpp" +# ) idf_component_register(SRCS ${SRCS} PRIV_REQUIRES ${requires} diff --git a/main/main.cpp b/main/main.cpp index 0630673..92f37d7 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -20,6 +20,7 @@ #include "ui/app_registry.h" #include "ui/apps/demo_app.h" #include "ui/apps/shutdown_app.h" +#include "ui/apps/discord_app.h" #include #include "esp_lvgl_port.h" #include "lvgl.h" @@ -125,6 +126,7 @@ void app_main(void) { // Each descriptor will create and register the app instance DemoAppDescriptor* demo_descriptor = new DemoAppDescriptor(); ShutdownAppDescriptor* shutdown_descriptor = new ShutdownAppDescriptor(); + DiscordAppDescriptor::instance(); // Use singleton pattern for Discord app ESP_LOGI(TAG, "Apps registered with AppRegistry\n"); // Initialize UI Handler (will render app icons from registry) diff --git a/main/ui/apps/discord_app.cpp b/main/ui/apps/discord_app.cpp new file mode 100644 index 0000000..c416747 --- /dev/null +++ b/main/ui/apps/discord_app.cpp @@ -0,0 +1,628 @@ +#include "discord_app.h" +#include "esp_log.h" +#include "network/network.h" +#include + +static const char* TAG = "DiscordApp"; + +// ============================================================================ +// DiscordApp Implementation +// ============================================================================ + +DiscordApp::DiscordApp() + : page_stack_(nullptr) + , status_icon_label_(nullptr) + , status_text_label_(nullptr) + , mute_button_(nullptr) + , error_notification_(nullptr) + , ip_textarea_(nullptr) + , port_textarea_(nullptr) + , test_result_label_(nullptr) + , remote_port_(0) + , settings_configured_(false) + , current_state_(VoiceState::UNKNOWN) + , state_mutex_(nullptr) + , poll_task_handle_(nullptr) + , stop_polling_(false) + , consecutive_failures_(0) + , storage_(nullptr) { + + // Create mutex for thread-safe state access + state_mutex_ = xSemaphoreCreateMutex(); + + // Initialize storage + storage_ = new NVSStorageHandler(NVS_NAMESPACE); +} + +DiscordApp::~DiscordApp() { + stop_polling_task(); + + if (state_mutex_) { + vSemaphoreDelete(state_mutex_); + } + + if (storage_) { + delete storage_; + } +} + +esp_err_t DiscordApp::init(lv_obj_t* container) { + ESP_LOGI(TAG, "Initializing Discord app"); + + _container = container; + + // Initialize storage + storage_->init(nullptr); + + // Load saved settings + load_settings(); + + // Initialize UDP client + udp_client_.init(); + + // Configure UDP if settings are available + if (settings_configured_) { + udp_client_.configure(remote_ip_, remote_port_); + } + + // Create page stack + page_stack_ = new PageStack(container); + + // Build main page + page_stack_->push([this](lv_obj_t* page) { + build_main_page(page); + }); + + // Start polling task + start_polling_task(); + + return ESP_OK; +} + +esp_err_t DiscordApp::deinit() { + ESP_LOGI(TAG, "Deinitializing Discord app"); + + // Stop polling + stop_polling_task(); + + // Clean up page stack + if (page_stack_) { + delete page_stack_; + page_stack_ = nullptr; + } + + // Close UDP client + udp_client_.close(); + + // Reset widget pointers + status_icon_label_ = nullptr; + status_text_label_ = nullptr; + mute_button_ = nullptr; + error_notification_ = nullptr; + ip_textarea_ = nullptr; + port_textarea_ = nullptr; + test_result_label_ = nullptr; + + return ESP_OK; +} + +void DiscordApp::handle_event(uint32_t event_type, void* event_data) { + // Handle system events if needed +} + +bool DiscordApp::on_back_button_pressed() { + // If on settings page, go back to main page + if (page_stack_ && page_stack_->depth() > 1) { + page_stack_->pop(); + return true; + } + + // Let system handle back (return to app icons) + return false; +} + +// ============================================================================ +// Main Page UI +// ============================================================================ + +void DiscordApp::build_main_page(lv_obj_t* page) { + // Status icon (large, centered) + status_icon_label_ = lv_label_create(page); + lv_label_set_text(status_icon_label_, LV_SYMBOL_MUTE); + // Using default font (only montserrat_14 is enabled) + lv_obj_align(status_icon_label_, LV_ALIGN_CENTER, 0, -80); + + // Status text + status_text_label_ = lv_label_create(page); + lv_label_set_text(status_text_label_, "Unknown Status"); + // Using default font + lv_obj_align(status_text_label_, LV_ALIGN_CENTER, 0, -20); + + // Mute button + mute_button_ = lv_btn_create(page); + lv_obj_set_size(mute_button_, 200, 60); + lv_obj_align(mute_button_, LV_ALIGN_CENTER, 0, 50); + lv_obj_add_event_cb(mute_button_, on_mute_button_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* mute_label = lv_label_create(mute_button_); + lv_label_set_text(mute_label, "MUTE"); + // Using default font + lv_obj_center(mute_label); + + // Settings button (gear icon in corner) + lv_obj_t* settings_btn = lv_btn_create(page); + lv_obj_set_size(settings_btn, 60, 60); + lv_obj_align(settings_btn, LV_ALIGN_BOTTOM_RIGHT, -10, -10); + lv_obj_add_event_cb(settings_btn, on_settings_button_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* settings_icon = lv_label_create(settings_btn); + lv_label_set_text(settings_icon, LV_SYMBOL_SETTINGS); + // Using default font + lv_obj_center(settings_icon); + + // Error notification (hidden by default) + error_notification_ = lv_obj_create(page); + lv_obj_set_size(error_notification_, 250, 50); + lv_obj_align(error_notification_, LV_ALIGN_TOP_MID, 0, 10); + lv_obj_set_style_bg_color(error_notification_, lv_color_hex(0xFF0000), 0); + lv_obj_set_style_bg_opa(error_notification_, LV_OPA_70, 0); + lv_obj_add_flag(error_notification_, LV_OBJ_FLAG_HIDDEN); + + lv_obj_t* error_label = lv_label_create(error_notification_); + lv_label_set_text(error_label, LV_SYMBOL_WARNING " Connection Lost"); + lv_obj_set_style_text_color(error_label, lv_color_white(), 0); + lv_obj_center(error_label); + + // Show config prompt if not configured + if (!settings_configured_) { + lv_obj_t* config_prompt = lv_label_create(page); + lv_label_set_text(config_prompt, "Tap " LV_SYMBOL_SETTINGS " to configure"); + // Using default font + lv_obj_set_style_text_color(config_prompt, lv_color_hex(0x888888), 0); + lv_obj_align(config_prompt, LV_ALIGN_BOTTOM_LEFT, 10, -10); + } + + // Update display with current state + update_status_display(); +} + +// ============================================================================ +// Settings Page UI +// ============================================================================ + +void DiscordApp::build_settings_page(lv_obj_t* page) { + // Title + lv_obj_t* title = lv_label_create(page); + lv_label_set_text(title, "Discord Bridge Settings"); + // Using default font + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 20); + + // IP address label + lv_obj_t* ip_label = lv_label_create(page); + lv_label_set_text(ip_label, "Bridge IP Address:"); + lv_obj_align(ip_label, LV_ALIGN_TOP_LEFT, 20, 70); + + // IP address textarea + ip_textarea_ = lv_textarea_create(page); + lv_obj_set_size(ip_textarea_, 300, 50); + lv_obj_align(ip_textarea_, LV_ALIGN_TOP_LEFT, 20, 100); + lv_textarea_set_one_line(ip_textarea_, true); + lv_textarea_set_placeholder_text(ip_textarea_, "e.g., 192.168.1.100"); + + if (!remote_ip_.empty()) { + lv_textarea_set_text(ip_textarea_, remote_ip_.c_str()); + } + + // Port label + lv_obj_t* port_label = lv_label_create(page); + lv_label_set_text(port_label, "Bridge Port:"); + lv_obj_align(port_label, LV_ALIGN_TOP_LEFT, 20, 170); + + // Port textarea + port_textarea_ = lv_textarea_create(page); + lv_obj_set_size(port_textarea_, 150, 50); + lv_obj_align(port_textarea_, LV_ALIGN_TOP_LEFT, 20, 200); + lv_textarea_set_one_line(port_textarea_, true); + lv_textarea_set_placeholder_text(port_textarea_, "e.g., 4211"); + lv_textarea_set_accepted_chars(port_textarea_, "0123456789"); + lv_textarea_set_max_length(port_textarea_, 5); + + if (remote_port_ > 0) { + char port_str[8]; + snprintf(port_str, sizeof(port_str), "%u", remote_port_); + lv_textarea_set_text(port_textarea_, port_str); + } + + // Test connection button + lv_obj_t* test_btn = lv_btn_create(page); + lv_obj_set_size(test_btn, 200, 50); + lv_obj_align(test_btn, LV_ALIGN_TOP_MID, 0, 270); + lv_obj_add_event_cb(test_btn, on_test_connection_clicked, LV_EVENT_CLICKED, this); + + lv_obj_t* test_label = lv_label_create(test_btn); + lv_label_set_text(test_label, "Test Connection"); + lv_obj_center(test_label); + + // Test result label + test_result_label_ = lv_label_create(page); + lv_label_set_text(test_result_label_, ""); + lv_obj_align(test_result_label_, LV_ALIGN_TOP_MID, 0, 330); + + // Save button + lv_obj_t* save_btn = lv_btn_create(page); + lv_obj_set_size(save_btn, 150, 50); + lv_obj_align(save_btn, LV_ALIGN_BOTTOM_MID, 0, -20); + lv_obj_add_event_cb(save_btn, on_save_settings_clicked, LV_EVENT_CLICKED, this); + lv_obj_set_style_bg_color(save_btn, lv_color_hex(0x00AA00), 0); + + lv_obj_t* save_label = lv_label_create(save_btn); + lv_label_set_text(save_label, LV_SYMBOL_SAVE " Save"); + lv_obj_set_style_text_color(save_label, lv_color_white(), 0); + lv_obj_center(save_label); +} + +void DiscordApp::show_settings_page() { + page_stack_->push([this](lv_obj_t* page) { + build_settings_page(page); + }); +} + +// ============================================================================ +// Event Callbacks +// ============================================================================ + +void DiscordApp::on_mute_button_clicked(lv_event_t* e) { + DiscordApp* app = static_cast(lv_event_get_user_data(e)); + if (app) { + app->send_mute_command(); + } +} + +void DiscordApp::on_settings_button_clicked(lv_event_t* e) { + DiscordApp* app = static_cast(lv_event_get_user_data(e)); + if (app) { + app->show_settings_page(); + } +} + +void DiscordApp::on_save_settings_clicked(lv_event_t* e) { + DiscordApp* app = static_cast(lv_event_get_user_data(e)); + if (app) { + app->save_settings(); + + // Go back to main page + if (app->page_stack_->depth() > 1) { + app->page_stack_->pop(); + } + } +} + +void DiscordApp::on_test_connection_clicked(lv_event_t* e) { + DiscordApp* app = static_cast(lv_event_get_user_data(e)); + if (!app || !app->test_result_label_) return; + + // Get values from textareas + const char* ip = lv_textarea_get_text(app->ip_textarea_); + const char* port_str = lv_textarea_get_text(app->port_textarea_); + + if (strlen(ip) == 0 || strlen(port_str) == 0) { + lv_label_set_text(app->test_result_label_, LV_SYMBOL_CLOSE " Please fill all fields"); + lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0xFF0000), 0); + return; + } + + uint16_t port = atoi(port_str); + if (port == 0) { + lv_label_set_text(app->test_result_label_, LV_SYMBOL_CLOSE " Invalid port"); + lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0xFF0000), 0); + return; + } + + // Configure UDP temporarily + UDPClient test_client; + test_client.init(); + esp_err_t err = test_client.configure(ip, port); + + if (err != ESP_OK) { + lv_label_set_text(app->test_result_label_, LV_SYMBOL_CLOSE " Invalid IP address"); + lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0xFF0000), 0); + return; + } + + lv_label_set_text(app->test_result_label_, "Testing..."); + lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0x0000FF), 0); + + // Send STATUS command + err = test_client.send_command("STATUS"); + if (err != ESP_OK) { + lv_label_set_text(app->test_result_label_, LV_SYMBOL_CLOSE " Failed to send"); + lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0xFF0000), 0); + return; + } + + // Wait for response + std::string response; + err = test_client.receive_response(response, 3000); + + if (err == ESP_OK && (response == "MUTED" || response == "UNMUTED")) { + lv_label_set_text(app->test_result_label_, LV_SYMBOL_OK " Connection successful!"); + lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0x00AA00), 0); + } else { + lv_label_set_text(app->test_result_label_, LV_SYMBOL_CLOSE " No response from bridge"); + lv_obj_set_style_text_color(app->test_result_label_, lv_color_hex(0xFF0000), 0); + } +} + +// ============================================================================ +// UDP Communication +// ============================================================================ + +void DiscordApp::send_mute_command() { + if (!settings_configured_) { + ESP_LOGW(TAG, "Cannot send command: not configured"); + return; + } + + esp_err_t err = udp_client_.send_command("MUTE"); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to send MUTE command"); + } +} + +bool DiscordApp::test_connection() { + if (!settings_configured_) { + return false; + } + + esp_err_t err = udp_client_.send_command("STATUS"); + if (err != ESP_OK) { + return false; + } + + std::string response; + err = udp_client_.receive_response(response, RESPONSE_TIMEOUT_MS); + + return (err == ESP_OK && (response == "MUTED" || response == "UNMUTED")); +} + +void DiscordApp::update_status_display() { + if (!status_icon_label_ || !status_text_label_) { + return; + } + + // Thread-safe state access + VoiceState state; + if (xSemaphoreTake(state_mutex_, pdMS_TO_TICKS(100)) == pdTRUE) { + state = current_state_; + xSemaphoreGive(state_mutex_); + } else { + return; + } + + switch (state) { + case VoiceState::MUTED: + lv_label_set_text(status_icon_label_, LV_SYMBOL_MUTE); + lv_label_set_text(status_text_label_, "Muted"); + lv_obj_set_style_text_color(status_icon_label_, lv_color_hex(0xFF0000), 0); + break; + + case VoiceState::UNMUTED: + lv_label_set_text(status_icon_label_, LV_SYMBOL_VOLUME_MAX); + lv_label_set_text(status_text_label_, "Unmuted"); + lv_obj_set_style_text_color(status_icon_label_, lv_color_hex(0x00AA00), 0); + break; + + case VoiceState::ERROR: + lv_label_set_text(status_icon_label_, LV_SYMBOL_WARNING); + lv_label_set_text(status_text_label_, "Connection Error"); + lv_obj_set_style_text_color(status_icon_label_, lv_color_hex(0xFF8800), 0); + break; + + case VoiceState::UNKNOWN: + default: + lv_label_set_text(status_icon_label_, LV_SYMBOL_BLUETOOTH); + lv_label_set_text(status_text_label_, "Unknown Status"); + lv_obj_set_style_text_color(status_icon_label_, lv_color_hex(0x888888), 0); + break; + } +} + +void DiscordApp::show_error_notification(bool show) { + if (error_notification_) { + if (show) { + lv_obj_clear_flag(error_notification_, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(error_notification_, LV_OBJ_FLAG_HIDDEN); + } + } +} + +// ============================================================================ +// Settings Management +// ============================================================================ + +void DiscordApp::load_settings() { + remote_ip_ = storage_->get(NVS_KEY_IP); + std::string port_str = storage_->get(NVS_KEY_PORT); + + if (!remote_ip_.empty() && !port_str.empty()) { + remote_port_ = atoi(port_str.c_str()); + settings_configured_ = (remote_port_ > 0); + ESP_LOGI(TAG, "Loaded settings: %s:%u", remote_ip_.c_str(), remote_port_); + } else { + settings_configured_ = false; + ESP_LOGI(TAG, "No settings found, user setup required"); + } +} + +void DiscordApp::save_settings() { + if (!ip_textarea_ || !port_textarea_) { + return; + } + + const char* ip = lv_textarea_get_text(ip_textarea_); + const char* port_str = lv_textarea_get_text(port_textarea_); + + if (strlen(ip) == 0 || strlen(port_str) == 0) { + ESP_LOGW(TAG, "Cannot save: empty fields"); + return; + } + + uint16_t port = atoi(port_str); + if (port == 0) { + ESP_LOGW(TAG, "Cannot save: invalid port"); + return; + } + + // Save to NVS + storage_->put(NVS_KEY_IP, ip); + storage_->put(NVS_KEY_PORT, port_str); + + // Update local config + remote_ip_ = ip; + remote_port_ = port; + settings_configured_ = true; + + // Reconfigure UDP client + udp_client_.configure(remote_ip_, remote_port_); + + // Reset failure counter + consecutive_failures_ = 0; + + ESP_LOGI(TAG, "Settings saved: %s:%u", remote_ip_.c_str(), remote_port_); +} + +// ============================================================================ +// Polling Task +// ============================================================================ + +void DiscordApp::poll_task(void* param) { + DiscordApp* app = static_cast(param); + + ESP_LOGI(TAG, "Polling task started"); + + while (!app->stop_polling_) { + app->poll_status(); + + // Use longer interval if in error state + int interval = (app->consecutive_failures_ >= MAX_FAILURES_BEFORE_ERROR) + ? ERROR_POLL_INTERVAL_MS + : POLL_INTERVAL_MS; + + vTaskDelay(pdMS_TO_TICKS(interval)); + } + + ESP_LOGI(TAG, "Polling task stopped"); + app->poll_task_handle_ = nullptr; + vTaskDelete(nullptr); +} + +void DiscordApp::start_polling_task() { + if (poll_task_handle_) { + ESP_LOGW(TAG, "Polling task already running"); + return; + } + + stop_polling_ = false; + xTaskCreate(poll_task, "discord_poll", 4096, this, 5, &poll_task_handle_); +} + +void DiscordApp::stop_polling_task() { + if (!poll_task_handle_) { + return; + } + + ESP_LOGI(TAG, "Stopping polling task"); + stop_polling_ = true; + + // Wait for task to finish (max 2 seconds) + int wait_count = 0; + while (poll_task_handle_ && wait_count < 20) { + vTaskDelay(pdMS_TO_TICKS(100)); + wait_count++; + } + + if (poll_task_handle_) { + ESP_LOGW(TAG, "Force deleting polling task"); + vTaskDelete(poll_task_handle_); + poll_task_handle_ = nullptr; + } +} + +void DiscordApp::poll_status() { + if (!settings_configured_) { + // Don't poll if not configured + return; + } + + // Send STATUS command + esp_err_t err = udp_client_.send_command("STATUS"); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to send STATUS command"); + consecutive_failures_++; + + if (consecutive_failures_ >= MAX_FAILURES_BEFORE_ERROR) { + if (xSemaphoreTake(state_mutex_, pdMS_TO_TICKS(100)) == pdTRUE) { + current_state_ = VoiceState::ERROR; + xSemaphoreGive(state_mutex_); + } + show_error_notification(true); + } + return; + } + + // Wait for response + std::string response; + err = udp_client_.receive_response(response, RESPONSE_TIMEOUT_MS); + + if (err == ESP_OK) { + // Success - reset failure counter + consecutive_failures_ = 0; + show_error_notification(false); + + // Update state + VoiceState new_state = VoiceState::UNKNOWN; + if (response == "MUTED") { + new_state = VoiceState::MUTED; + } else if (response == "UNMUTED") { + new_state = VoiceState::UNMUTED; + } + + if (xSemaphoreTake(state_mutex_, pdMS_TO_TICKS(100)) == pdTRUE) { + current_state_ = new_state; + xSemaphoreGive(state_mutex_); + } + + update_status_display(); + + } else { + // Timeout or error + consecutive_failures_++; + ESP_LOGW(TAG, "No response (failures: %d)", consecutive_failures_); + + if (consecutive_failures_ >= MAX_FAILURES_BEFORE_ERROR) { + if (xSemaphoreTake(state_mutex_, pdMS_TO_TICKS(100)) == pdTRUE) { + current_state_ = VoiceState::ERROR; + xSemaphoreGive(state_mutex_); + } + update_status_display(); + show_error_notification(true); + } + } +} + +// ============================================================================ +// DiscordAppDescriptor Implementation +// ============================================================================ + +DiscordAppDescriptor::DiscordAppDescriptor() + : AppDescriptor("Discord", new DiscordApp()) { + // Auto-register on construction + AppRegistry::instance().register_app(this); +} + +void DiscordAppDescriptor::draw_icon(lv_obj_t* parent) { + lv_obj_t* icon = lv_label_create(parent); + lv_label_set_text(icon, LV_SYMBOL_CALL); + lv_obj_center(icon); +} diff --git a/main/ui/apps/discord_app.h b/main/ui/apps/discord_app.h new file mode 100644 index 0000000..d1e5f6e --- /dev/null +++ b/main/ui/apps/discord_app.h @@ -0,0 +1,123 @@ +#pragma once + +#include "ui/ui_app.h" +#include "ui/page_stack.h" +#include "ui/app_registry.h" +#include "network/udp_client.h" +#include "io/nvs_handler.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/semphr.h" +#include + +/** + * @brief Discord voice control app + * + * Allows control of Discord voice settings (mute/unmute) via UDP communication + * with the IotDis Node.js bridge. Features: + * - Main page: Status icon + mute button + * - Settings page: IP/port configuration with connection test + * - Periodic status polling with automatic retry + * - Error notification when remote is unreachable + */ +class DiscordApp : public UIApp { +public: + DiscordApp(); + ~DiscordApp() override; + + // UIApp interface + esp_err_t init(lv_obj_t* container) override; + esp_err_t deinit() override; + std::string get_name() const override { return "Discord"; } + void handle_event(uint32_t event_type, void* event_data = nullptr) override; + bool on_back_button_pressed() override; + +private: + // Voice state enum + enum class VoiceState { + UNKNOWN, + MUTED, + UNMUTED, + ERROR + }; + + // Page management + PageStack* page_stack_; + void build_main_page(lv_obj_t* page); + void build_settings_page(lv_obj_t* page); + void show_settings_page(); + + // Main page widgets + lv_obj_t* status_icon_label_; + lv_obj_t* status_text_label_; + lv_obj_t* mute_button_; + lv_obj_t* error_notification_; + + // Settings page widgets + lv_obj_t* ip_textarea_; + lv_obj_t* port_textarea_; + lv_obj_t* test_result_label_; + + // UDP client and configuration + UDPClient udp_client_; + std::string remote_ip_; + uint16_t remote_port_; + bool settings_configured_; + + // Voice state + VoiceState current_state_; + SemaphoreHandle_t state_mutex_; + + // Polling task + TaskHandle_t poll_task_handle_; + bool stop_polling_; + int consecutive_failures_; + static constexpr int MAX_FAILURES_BEFORE_ERROR = 3; + static constexpr int POLL_INTERVAL_MS = 5000; + static constexpr int ERROR_POLL_INTERVAL_MS = 15000; + static constexpr int RESPONSE_TIMEOUT_MS = 2000; + + // NVS storage + NVSStorageHandler* storage_; + static constexpr const char* NVS_NAMESPACE = "discord"; + static constexpr const char* NVS_KEY_IP = "remote_ip"; + static constexpr const char* NVS_KEY_PORT = "remote_port"; + + // Event callbacks + static void on_mute_button_clicked(lv_event_t* e); + static void on_settings_button_clicked(lv_event_t* e); + static void on_save_settings_clicked(lv_event_t* e); + static void on_test_connection_clicked(lv_event_t* e); + + // UDP communication + void send_mute_command(); + bool test_connection(); + void update_status_display(); + void show_error_notification(bool show); + + // Settings management + void load_settings(); + void save_settings(); + + // Polling task + static void poll_task(void* param); + void start_polling_task(); + void stop_polling_task(); + void poll_status(); +}; + +/** + * @brief Discord app descriptor for registration + */ +class DiscordAppDescriptor : public AppDescriptor { +public: + static DiscordAppDescriptor& instance() { + static DiscordAppDescriptor instance; + return instance; + } + + void draw_icon(lv_obj_t* parent) override; + +private: + DiscordAppDescriptor(); +};