#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) { // Set up main page with flex column layout lv_obj_set_flex_flow(page, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(page, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_all(page, 10, 0); // === Top Section: Error Notification === error_notification_ = lv_obj_create(page); lv_obj_set_width(error_notification_, LV_PCT(90)); lv_obj_set_height(error_notification_, LV_SIZE_CONTENT); 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_set_style_pad_all(error_notification_, 10, 0); lv_obj_set_style_radius(error_notification_, 8, 0); lv_obj_add_flag(error_notification_, LV_OBJ_FLAG_HIDDEN); lv_obj_set_flex_flow(error_notification_, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(error_notification_, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); 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); // === Center Section: Main Content === lv_obj_t* center_container = lv_obj_create(page); lv_obj_set_size(center_container, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(center_container, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(center_container, 0, 0); lv_obj_set_style_pad_all(center_container, 0, 0); lv_obj_set_flex_flow(center_container, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(center_container, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_row(center_container, 15, 0); lv_obj_set_flex_grow(center_container, 1); // Status icon (large, centered) status_icon_label_ = lv_label_create(center_container); lv_label_set_text(status_icon_label_, LV_SYMBOL_MUTE); // Status text status_text_label_ = lv_label_create(center_container); lv_label_set_text(status_text_label_, "Unknown Status"); // Mute button mute_button_ = lv_btn_create(center_container); lv_obj_set_size(mute_button_, 200, 60); 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"); lv_obj_center(mute_label); // === Bottom Section: Settings and Config Prompt === lv_obj_t* bottom_container = lv_obj_create(page); lv_obj_set_size(bottom_container, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_style_bg_opa(bottom_container, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(bottom_container, 0, 0); lv_obj_set_style_pad_all(bottom_container, 0, 0); lv_obj_set_flex_flow(bottom_container, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(bottom_container, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); // Config prompt (left side) if (!settings_configured_) { lv_obj_t* config_prompt = lv_label_create(bottom_container); lv_label_set_text(config_prompt, "Tap " LV_SYMBOL_SETTINGS " to configure"); lv_obj_set_style_text_color(config_prompt, lv_color_hex(0x888888), 0); } else { // Empty spacer if configured lv_obj_t* spacer = lv_obj_create(bottom_container); lv_obj_set_size(spacer, 0, 0); lv_obj_set_style_bg_opa(spacer, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(spacer, 0, 0); } // Settings button (right side) lv_obj_t* settings_btn = lv_btn_create(bottom_container); lv_obj_set_size(settings_btn, 60, 60); 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); lv_obj_center(settings_icon); // 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); }