feat: add DiscordApp for voice control integration with UDP communication
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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 <tick/lv_tick.h>
|
||||
#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)
|
||||
|
||||
628
main/ui/apps/discord_app.cpp
Normal file
628
main/ui/apps/discord_app.cpp
Normal 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
123
main/ui/apps/discord_app.h
Normal 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();
|
||||
};
|
||||
Reference in New Issue
Block a user