feat: add DiscordApp for voice control integration with UDP communication

This commit is contained in:
GW_MC
2026-01-24 13:22:17 +08:00
parent dd1702e3e9
commit 89cc04951f
4 changed files with 772 additions and 1 deletions

View File

@@ -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) 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} idf_component_register(SRCS ${SRCS}
PRIV_REQUIRES ${requires} PRIV_REQUIRES ${requires}

View File

@@ -20,6 +20,7 @@
#include "ui/app_registry.h" #include "ui/app_registry.h"
#include "ui/apps/demo_app.h" #include "ui/apps/demo_app.h"
#include "ui/apps/shutdown_app.h" #include "ui/apps/shutdown_app.h"
#include "ui/apps/discord_app.h"
#include <tick/lv_tick.h> #include <tick/lv_tick.h>
#include "esp_lvgl_port.h" #include "esp_lvgl_port.h"
#include "lvgl.h" #include "lvgl.h"
@@ -125,6 +126,7 @@ void app_main(void) {
// Each descriptor will create and register the app instance // Each descriptor will create and register the app instance
DemoAppDescriptor* demo_descriptor = new DemoAppDescriptor(); DemoAppDescriptor* demo_descriptor = new DemoAppDescriptor();
ShutdownAppDescriptor* shutdown_descriptor = new ShutdownAppDescriptor(); ShutdownAppDescriptor* shutdown_descriptor = new ShutdownAppDescriptor();
DiscordAppDescriptor::instance(); // Use singleton pattern for Discord app
ESP_LOGI(TAG, "Apps registered with AppRegistry\n"); ESP_LOGI(TAG, "Apps registered with AppRegistry\n");
// Initialize UI Handler (will render app icons from registry) // Initialize UI Handler (will render app icons from registry)

View File

@@ -0,0 +1,628 @@
#include "discord_app.h"
#include "esp_log.h"
#include "network/network.h"
#include <sstream>
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<DiscordApp*>(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<DiscordApp*>(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<DiscordApp*>(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<DiscordApp*>(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<DiscordApp*>(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);
}

123
main/ui/apps/discord_app.h Normal file
View File

@@ -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 <string>
/**
* @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();
};