diff --git a/components/lightsnapcast/CMakeLists.txt b/components/lightsnapcast/CMakeLists.txt index b2842c01..b2279a20 100644 --- a/components/lightsnapcast/CMakeLists.txt +++ b/components/lightsnapcast/CMakeLists.txt @@ -1,3 +1,3 @@ idf_component_register(SRCS "snapcast.c" "player.c" INCLUDE_DIRS "include" - REQUIRES libbuffer json libmedian timefilter esp_wifi driver esp_timer) + REQUIRES libbuffer json libmedian timefilter esp_wifi driver esp_timer network_interface) diff --git a/components/lightsnapcast/player.c b/components/lightsnapcast/player.c index 209de796..15e548a4 100644 --- a/components/lightsnapcast/player.c +++ b/components/lightsnapcast/player.c @@ -36,6 +36,7 @@ #include "driver/i2s_std.h" #include "player.h" #include "snapcast.h" +#include "network_interface.h" #define USE_SAMPLE_INSERTION CONFIG_USE_SAMPLE_INSERTION @@ -119,6 +120,7 @@ static void player_task(void *pvParameters); bool gotSettings = false; bool playerstarted = false; +static bool player_shutdown_in_progress = false; // Guards against restart during shutdown extern void audio_set_mute(bool mute); extern void audio_dac_enable(bool enabled); @@ -519,12 +521,18 @@ int start_player(snapcastSetting_t *setting) { if (playerstarted){ return -1; } + // Clear shutdown flag - we're starting a new session + player_shutdown_in_progress = false; playerstarted = true; + if (network_playback_started() != ESP_OK) { + ESP_LOGW(TAG, "Failed to signal playback started to network layer"); + } int ret = 0; ret = player_setup_i2s(setting); if (ret < 0) { ESP_LOGE(TAG, "player_setup_i2s failed: %d", ret); + network_playback_stopped(); playerstarted = false; return -1; } @@ -537,31 +545,40 @@ int start_player(snapcastSetting_t *setting) { while(reset_latency_buffer()<0) { vTaskDelay(pdMS_TO_TICKS(10)); } - + esp_pm_lock_acquire(player_pm_lock_handle); #endif // create message queue to inform task of changed settings snapcastSettingQueueHandle = xQueueCreate(1, sizeof(uint8_t)); - + if (pcmChkQHdl == NULL) { snapcastSetting_t scSet; memset(&scSet, 0, sizeof(snapcastSetting_t)); player_get_snapcast_settings(&scSet); - - // ensure we don't have a divide by zero situation - uint32_t chkInFrames = scSet.chkInFrames; - if (chkInFrames == 0) { - chkInFrames = 1152; // choose a good default for now + + // Guard against divide-by-zero when chkInFrames hasn't been set yet + // (can happen during reconnection before first wire chunk is received) + if (scSet.chkInFrames == 0) { + ESP_LOGW(TAG, "chkInFrames is 0, cannot create queue yet"); + vQueueDelete(snapcastSettingQueueHandle); + snapcastSettingQueueHandle = NULL; +#if CONFIG_PM_ENABLE + esp_pm_lock_release(player_pm_lock_handle); +#endif + tg0_timer_deinit(); + network_playback_stopped(); + playerstarted = false; + return -1; } - - int entries = ceil(((float)scSet.sr / (float)chkInFrames) * + + int entries = ceil(((float)scSet.sr / (float)scSet.chkInFrames) * ((float)scSet.buf_ms / 1000)); // some chunks are placed in DMA buffer // so we can save a little RAM here - entries -= ((i2sDmaBufMaxLen * i2sDmaBufCnt) / chkInFrames); + entries -= (i2sDmaBufMaxLen * i2sDmaBufCnt) / scSet.chkInFrames; pcmChkQHdl = xQueueCreate(entries, sizeof(pcm_chunk_message_t *)); @@ -1359,6 +1376,13 @@ int32_t insert_pcm_chunk(pcm_chunk_message_t *pcmChunk) { free_pcm_chunk(pcmChunk); + // Don't try to restart player if shutdown is in progress (prevents race condition + // where we try to start player while it's still cleaning up) + if (player_shutdown_in_progress) { + ESP_LOGD(TAG, "Player shutdown in progress, not restarting"); + return -2; + } + snapcastSetting_t curSet; player_get_snapcast_settings(&curSet); if (!curSet.muted && gotSettings) { @@ -2121,6 +2145,10 @@ static void player_task(void *pvParameters) { } ret = 0; + // Set shutdown flag BEFORE destroying resources to prevent insert_pcm_chunk + // from trying to restart the player during cleanup + player_shutdown_in_progress = true; + xSemaphoreTake(snapcastSettingsMux, portMAX_DELAY); // delete the queue vQueueDelete(snapcastSettingQueueHandle); @@ -2135,7 +2163,17 @@ static void player_task(void *pvParameters) { tg0_timer_deinit(); playerstarted = false; + /* Notify network layer that playback stopped so pending Ethernet takeover + * can proceed if one was waiting. + */ + if (network_playback_stopped() != ESP_OK) { + ESP_LOGW(TAG, "Failed to signal playback stopped to network layer"); + } ESP_LOGI(TAG, "stop player done"); + + // Cleanup complete - clear shutdown flag so player can restart + player_shutdown_in_progress = false; + playerTaskHandle = NULL; vTaskDelete(NULL); } diff --git a/components/network_interface/CMakeLists.txt b/components/network_interface/CMakeLists.txt index cd5560c4..2f64ea4f 100644 --- a/components/network_interface/CMakeLists.txt +++ b/components/network_interface/CMakeLists.txt @@ -1,3 +1,18 @@ -idf_component_register(SRCS "network_interface.c" "eth_interface.c" "wifi_interface.c" +set(SRCS "network_interface.c" "wifi_interface.c") + +# Only include Ethernet interface if Ethernet is enabled +if(CONFIG_SNAPCLIENT_USE_INTERNAL_ETHERNET OR CONFIG_SNAPCLIENT_USE_SPI_ETHERNET) + list(APPEND SRCS "eth_interface.c") +endif() + +set(PRIV_DEPS driver esp_wifi esp_eth esp_netif esp_timer nvs_flash improv_wifi settings_manager lwip) + +# ping is only needed when Ethernet is enabled (used by eth_interface.c) +if(CONFIG_SNAPCLIENT_USE_INTERNAL_ETHERNET OR CONFIG_SNAPCLIENT_USE_SPI_ETHERNET) + list(APPEND PRIV_DEPS ping) +endif() + +idf_component_register(SRCS ${SRCS} INCLUDE_DIRS "include" - PRIV_REQUIRES driver esp_wifi esp_eth esp_netif esp_timer nvs_flash improv_wifi) + PRIV_INCLUDE_DIRS "priv_include" + PRIV_REQUIRES ${PRIV_DEPS}) diff --git a/components/network_interface/eth_interface.c b/components/network_interface/eth_interface.c index 34057d01..fa8019b5 100644 --- a/components/network_interface/eth_interface.c +++ b/components/network_interface/eth_interface.c @@ -17,20 +17,181 @@ #include "freertos/event_groups.h" #include "freertos/task.h" #include "sdkconfig.h" +#include "ping/ping_sock.h" +#include "lwip/inet.h" +#include +#include "esp_wifi.h" + #if CONFIG_SNAPCLIENT_USE_SPI_ETHERNET #include "driver/spi_master.h" #endif #include "network_interface.h" +#include "network_interface_priv.h" +#include "settings_manager.h" static const char *TAG = "ETH_IF"; +/* ============ Event Bit for Playback Monitor Shutdown ============ */ +/* BIT3 is reserved for eth_interface.c use - see network_interface.c comment */ +#define EVENT_MONITOR_SHUTDOWN_BIT BIT3 +#define EVENT_PLAYBACK_STOPPED_BIT BIT2 /* Needed to wait for playback stopped */ + +/* ============ Timing Constants ============ */ +#define ETH_LINK_STABILIZATION_MS 500 // Wait for link to stabilize after connect +#define ETH_STATIC_IP_SETTLE_MS 500 // Wait after applying static IP before gateway check +#define ETH_PING_CALLBACK_CLEANUP_MS 100 // Wait for ping callbacks to complete after stop +#define ETH_GATEWAY_PING_COUNT 3 // Number of ping attempts for gateway check +#define ETH_GATEWAY_PING_TIMEOUT_MS 1000 // Timeout per ping attempt +#define ETH_GATEWAY_CHECK_TIMEOUT_MS 5000 // Overall timeout for gateway reachability check +#define ETH_STATIC_IP_TASK_STACK 4096 // Stack size for static IP background task +#define ETH_STATIC_IP_TASK_PRIORITY 5 // Priority for static IP background task + +/* ============ State Variables ============ */ static uint8_t eth_port_cnt = 0; static esp_netif_ip_info_t ip_info = {{0}, {0}, {0}}; static bool connected = false; static SemaphoreHandle_t connIpSemaphoreHandle = NULL; +/* + * Takeover State Machine: + * - want_eth_takeover: Set when Ethernet connects while WiFi has IP. Cleared + * when takeover completes OR on disconnect (but preserved on brief disconnect + * if we never completed takeover). + * - we_changed_default_netif: Set after successfully changing default netif to + * Ethernet. Used to trigger WiFi fallback on disconnect. + */ +static bool we_changed_default_netif = false; +static bool want_eth_takeover = false; + +/* Ethernet mode: 0=Disabled, 1=DHCP (default), 2=Static */ +static int32_t current_eth_mode = 0; + +/* + * Static IP State Guards: + * - static_ip_in_progress: Task is running, prevents re-entry + * - static_ip_pending: Deferred due to active playback, will start when playback stops + * - static_ip_netif: Protected pointer to netif for task to use + * - static_ip_task_handle: Handle for cleanup on disconnect + * + * Valid states: (in_progress=F, pending=F) = idle + * (in_progress=F, pending=T) = waiting for playback to stop + * (in_progress=T, pending=F) = task running + * (in_progress=T, pending=T) = INVALID + */ +static bool static_ip_in_progress = false; +static bool static_ip_pending = false; +static esp_netif_t *static_ip_netif = NULL; +static TaskHandle_t static_ip_task_handle = NULL; + +/* Playback monitor task - watches for playback stopped events */ +static TaskHandle_t playback_monitor_task_handle = NULL; +#define PLAYBACK_MONITOR_TASK_STACK 2048 +#define PLAYBACK_MONITOR_TASK_PRIORITY 4 + +/* Forward declaration for playback stopped handler */ +static void eth_on_playback_stopped(void); + +/** + * @brief Task that monitors playback events and triggers pending operations + * + * This task waits for playback to start, then waits for it to stop, and + * calls eth_on_playback_stopped() to process any deferred operations like + * static IP configuration or Ethernet takeover. + * + * The task exits gracefully when EVENT_MONITOR_SHUTDOWN_BIT is set. + */ +static void playback_monitor_task(void *pvParameters) { + EventGroupHandle_t event_group = network_get_event_group(); + if (event_group == NULL) { + ESP_LOGE(TAG, "Playback monitor: event group not initialized"); + playback_monitor_task_handle = NULL; + vTaskDelete(NULL); + return; + } + + ESP_LOGI(TAG, "Playback monitor task started"); + + /* Define the bits we need to watch - PLAYBACK_STARTED (BIT1) is in network_interface.c */ + const EventBits_t PLAYBACK_STARTED_BIT = BIT1; + + while (1) { + // Wait for playback to start OR shutdown signal + EventBits_t bits = xEventGroupWaitBits(event_group, + PLAYBACK_STARTED_BIT | EVENT_MONITOR_SHUTDOWN_BIT, + pdFALSE, // Don't clear on exit + pdFALSE, // Don't wait for all bits + portMAX_DELAY); + + if (bits & EVENT_MONITOR_SHUTDOWN_BIT) { + ESP_LOGI(TAG, "Playback monitor: shutdown requested"); + break; + } + + ESP_LOGD(TAG, "Playback monitor: playback started, waiting for stop..."); + + // Wait for playback to stop OR shutdown signal + bits = xEventGroupWaitBits(event_group, + EVENT_PLAYBACK_STOPPED_BIT | EVENT_MONITOR_SHUTDOWN_BIT, + pdFALSE, // Don't clear on exit + pdFALSE, // Don't wait for all bits + portMAX_DELAY); + + if (bits & EVENT_MONITOR_SHUTDOWN_BIT) { + ESP_LOGI(TAG, "Playback monitor: shutdown requested"); + break; + } + + ESP_LOGD(TAG, "Playback monitor: playback stopped, processing pending operations..."); + + // Process any pending operations (deferred takeover or static IP) + eth_on_playback_stopped(); + } + + ESP_LOGI(TAG, "Playback monitor task exiting"); + // Set handle to NULL before vTaskDelete(NULL) — the delete never returns, + // so the assignment must come first to avoid a dangling handle. + playback_monitor_task_handle = NULL; + vTaskDelete(NULL); +} + +/** + * @brief Cleanup Ethernet drivers and free handles on initialization failure + */ +static void eth_cleanup_drivers(esp_eth_handle_t *handles, uint8_t count) { + if (!handles) return; + + for (int i = 0; i < count; i++) { + if (handles[i]) { + esp_eth_stop(handles[i]); + esp_eth_driver_uninstall(handles[i]); + } + } + free(handles); +} + +/** + * @brief Auto-disable Ethernet and persist to NVS on initialization failure + * This allows the device to boot with WiFi fallback instead of reboot-looping + */ +static void eth_auto_disable_and_persist(void) { + ESP_LOGW(TAG, "Ethernet init failed - auto-disabling to allow boot"); + current_eth_mode = 0; + + esp_err_t err = settings_set_eth_mode(0); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to persist eth_mode=0 to NVS: %s", esp_err_to_name(err)); + ESP_LOGW(TAG, "Ethernet disabled for this boot only - may retry on reboot"); + } else { + ESP_LOGI(TAG, "Ethernet disabled and saved to NVS. Re-enable via web UI when hardware is ready."); + } +} + +// Gateway ping state +static SemaphoreHandle_t ping_done_sem = NULL; +static bool ping_success = false; + #if CONFIG_SNAPCLIENT_SPI_ETHERNETS_NUM #define SPI_ETHERNETS_NUM CONFIG_SNAPCLIENT_SPI_ETHERNETS_NUM #else @@ -272,6 +433,14 @@ static esp_eth_handle_t eth_init_spi( #endif // CONFIG_SNAPCLIENT_USE_SPI_ETHERNET /** + * @brief Initialize Ethernet hardware drivers + * + * Creates and configures Ethernet driver instances for all configured + * Ethernet interfaces (internal EMAC and/or SPI-based). + * + * @param[out] eth_handles_out Pointer to receive allocated array of Ethernet handles + * @param[out] eth_cnt_out Pointer to receive count of initialized interfaces + * @return ESP_OK on success, error code on failure */ static esp_err_t eth_init(esp_eth_handle_t *eth_handles_out[], uint8_t *eth_cnt_out) { @@ -342,11 +511,360 @@ static esp_err_t eth_init(esp_eth_handle_t *eth_handles_out[], #if CONFIG_SNAPCLIENT_USE_INTERNAL_ETHERNET || \ CONFIG_SNAPCLIENT_USE_SPI_ETHERNET err: - free(eth_handles); + // Clean up any successfully created drivers before freeing handles + if (eth_handles) { + for (int i = 0; i < eth_cnt; i++) { + if (eth_handles[i]) { + esp_eth_stop(eth_handles[i]); + esp_eth_driver_uninstall(eth_handles[i]); + } + } + free(eth_handles); + } return ret; #endif } +/* ============ Gateway Ping Check ============ */ + +static void ping_on_success(esp_ping_handle_t hdl, void *args) { + ping_success = true; + xSemaphoreGive(ping_done_sem); +} + +static void ping_on_timeout(esp_ping_handle_t hdl, void *args) { + // Don't signal yet - let it try all attempts +} + +static void ping_on_end(esp_ping_handle_t hdl, void *args) { + if (!ping_success) { + xSemaphoreGive(ping_done_sem); // Signal failure after all retries + } +} + +/** + * @brief Check if gateway is reachable via ICMP ping + * @param netif The network interface to check + * @return true if gateway responds to ping, false otherwise + */ +static bool eth_check_gateway_reachable(esp_netif_t *netif) { + esp_netif_ip_info_t ip; + if (esp_netif_get_ip_info(netif, &ip) == ESP_OK && ip.gw.addr != 0) { + // Have IPv4 gateway - use ping to verify reachability + + // Semaphore should be created in eth_start(), but check defensively + if (!ping_done_sem) { + ESP_LOGE(TAG, "Ping semaphore not initialized"); + return false; + } + ping_success = false; + xSemaphoreTake(ping_done_sem, 0); // Drain any stale signal from prior timeout + + esp_ping_config_t ping_config = ESP_PING_DEFAULT_CONFIG(); + ping_config.target_addr.u_addr.ip4.addr = ip.gw.addr; + ping_config.target_addr.type = ESP_IPADDR_TYPE_V4; + ping_config.count = ETH_GATEWAY_PING_COUNT; + ping_config.timeout_ms = ETH_GATEWAY_PING_TIMEOUT_MS; + ping_config.interface = esp_netif_get_netif_impl_index(netif); + + esp_ping_callbacks_t cbs = { + .on_ping_success = ping_on_success, + .on_ping_timeout = ping_on_timeout, + .on_ping_end = ping_on_end, + }; + + esp_ping_handle_t ping; + if (esp_ping_new_session(&ping_config, &cbs, &ping) != ESP_OK) { + ESP_LOGE(TAG, "Failed to create ping session"); + return false; + } + + esp_ping_start(ping); + + // Wait for ping to complete + if (xSemaphoreTake(ping_done_sem, pdMS_TO_TICKS(ETH_GATEWAY_CHECK_TIMEOUT_MS)) != pdTRUE) { + ESP_LOGW(TAG, "Ping timed out, forcing stop"); + ping_success = false; + } + + // Stop ping and wait for callbacks to complete before deleting session + // This prevents use-after-free if callbacks fire after session deletion + esp_ping_stop(ping); + vTaskDelay(pdMS_TO_TICKS(ETH_PING_CALLBACK_CLEANUP_MS)); + esp_ping_delete_session(ping); + + if (ping_success) { + ESP_LOGI(TAG, "Gateway " IPSTR " is reachable", IP2STR(&ip.gw)); + } else { + ESP_LOGW(TAG, "Gateway " IPSTR " not reachable", IP2STR(&ip.gw)); + } + + return ping_success; + } + + // No IPv4 gateway - check IPv6 connectivity as fallback + esp_ip6_addr_t ip6; + if (esp_netif_get_ip6_global(netif, &ip6) == ESP_OK) { + ESP_LOGI(TAG, "No IPv4 gateway, but have global IPv6 " IPV6STR " - assuming network OK", + IPV62STR(ip6)); + return true; + } + + // Only link-local IPv6 available - can't verify gateway + if (esp_netif_get_ip6_linklocal(netif, &ip6) == ESP_OK) { + ESP_LOGW(TAG, "Only IPv6 link-local available, skipping gateway check"); + return true; + } + + ESP_LOGW(TAG, "No IPv4 gateway and no IPv6 address configured"); + return true; // No way to verify - assume OK to avoid blocking boot +} + +/** + * @brief Apply static IP configuration from settings + * + * LOCKING CONTRACT: This function acquires connIpSemaphoreHandle internally + * at the end to update connection state. Caller MUST NOT hold the semaphore + * when calling this function to avoid deadlock. + * + * @param netif The network interface to configure + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if config invalid + */ +static esp_err_t eth_apply_static_ip(esp_netif_t *netif) { + char ip_str[16] = {0}; + char netmask_str[16] = {0}; + char gw_str[16] = {0}; + char dns_str[16] = {0}; + + settings_get_eth_static_ip(ip_str, sizeof(ip_str)); + settings_get_eth_netmask(netmask_str, sizeof(netmask_str)); + settings_get_eth_gateway(gw_str, sizeof(gw_str)); + settings_get_eth_dns(dns_str, sizeof(dns_str)); + + // Validate required fields + if (ip_str[0] == '\0') { + ESP_LOGW(TAG, "Static IP not configured, falling back to DHCP"); + return ESP_ERR_INVALID_ARG; + } + + esp_netif_ip_info_t static_ip_info = {0}; + + // Parse IP addresses + if (inet_pton(AF_INET, ip_str, &static_ip_info.ip) != 1) { + ESP_LOGE(TAG, "Invalid static IP: %s", ip_str); + return ESP_ERR_INVALID_ARG; + } + + if (netmask_str[0] != '\0') { + if (inet_pton(AF_INET, netmask_str, &static_ip_info.netmask) != 1) { + ESP_LOGE(TAG, "Invalid netmask: %s", netmask_str); + return ESP_ERR_INVALID_ARG; + } + } else { + // Default netmask - warn user since it may not be appropriate for all networks + ESP_LOGW(TAG, "No netmask configured, using default 255.255.255.0 (/24)"); + inet_pton(AF_INET, "255.255.255.0", &static_ip_info.netmask); + } + + if (gw_str[0] != '\0') { + if (inet_pton(AF_INET, gw_str, &static_ip_info.gw) != 1) { + ESP_LOGE(TAG, "Invalid gateway: %s", gw_str); + return ESP_ERR_INVALID_ARG; + } + } + + // Stop DHCP client before setting static IP + esp_err_t dhcp_err = esp_netif_dhcpc_stop(netif); + if (dhcp_err != ESP_OK && dhcp_err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STOPPED) { + ESP_LOGD(TAG, "DHCP stop returned: %s (continuing)", esp_err_to_name(dhcp_err)); + } + + // Apply static IP configuration + esp_err_t err = esp_netif_set_ip_info(netif, &static_ip_info); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to set static IP: %s", esp_err_to_name(err)); + // Re-enable DHCP on failure + esp_netif_dhcpc_start(netif); + return err; + } + + ESP_LOGI(TAG, "Static IP configured: " IPSTR, IP2STR(&static_ip_info.ip)); + ESP_LOGI(TAG, "Netmask: " IPSTR, IP2STR(&static_ip_info.netmask)); + ESP_LOGI(TAG, "Gateway: " IPSTR, IP2STR(&static_ip_info.gw)); + + // Set DNS if configured + if (dns_str[0] != '\0') { + esp_netif_dns_info_t dns_info = {0}; + if (inet_pton(AF_INET, dns_str, &dns_info.ip.u_addr.ip4) == 1) { + dns_info.ip.type = ESP_IPADDR_TYPE_V4; + esp_netif_set_dns_info(netif, ESP_NETIF_DNS_MAIN, &dns_info); + ESP_LOGI(TAG, "DNS: %s", dns_str); + } + } + + // Update connection state explicitly since esp_netif_set_ip_info() + // does not trigger IP_EVENT_ETH_GOT_IP + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + memcpy(&ip_info, &static_ip_info, sizeof(esp_netif_ip_info_t)); + connected = true; + xSemaphoreGive(connIpSemaphoreHandle); + + return ESP_OK; +} + +/** + * @brief Unified takeover checkpoint - called from all IP acquisition paths + * + * Checks if conditions are met for Ethernet takeover and performs it atomically. + * This ensures consistent behavior whether IP was acquired via DHCP or static config. + * + * @param netif The Ethernet network interface that now has an IP + */ +static void eth_check_and_apply_takeover(esp_netif_t *netif) { + bool do_takeover = false; + + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + if (want_eth_takeover && !we_changed_default_netif && !network_is_playback_active()) { + do_takeover = true; + want_eth_takeover = false; + // Don't set we_changed_default_netif until after successful netif change + } + xSemaphoreGive(connIpSemaphoreHandle); + + if (do_takeover) { + ESP_LOGI(TAG, "Ethernet takeover: setting default netif to ETH"); + esp_err_t err = esp_netif_set_default_netif(netif); + if (err == ESP_OK) { + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + we_changed_default_netif = true; + xSemaphoreGive(connIpSemaphoreHandle); + if (network_request_reconnect() != ESP_OK) { + ESP_LOGW(TAG, "Failed to request reconnect after takeover"); + } + } else { + ESP_LOGE(TAG, "Failed to set default netif: %s", esp_err_to_name(err)); + // Restore takeover intent so it can be retried + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + want_eth_takeover = true; + xSemaphoreGive(connIpSemaphoreHandle); + } + } else if (want_eth_takeover && network_is_playback_active()) { + ESP_LOGI(TAG, "Playback active; deferring Ethernet takeover until playback stops"); + } +} + +/** + * @brief Background task for static IP configuration + * + * Moves blocking static IP operations out of the event handler to prevent + * blocking other Ethernet events. The task handles: + * - Link stabilization delay + * - Static IP application + * - Gateway reachability check + * - Takeover coordination + * + * CRITICAL: Uses static_ip_netif (protected by semaphore) instead of task + * parameter to avoid use-after-free if netif is invalidated during delays. + * + * @param pvParameters Unused (netif obtained from protected static variable) + */ +static void static_ip_task(void *pvParameters) { + (void)pvParameters; // Unused - we use protected static_ip_netif instead + esp_netif_t *netif = NULL; + + ESP_LOGI(TAG, "Static IP task started"); + + // Get netif from protected variable and check if we should abort + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + if (!static_ip_in_progress || !static_ip_netif) { + ESP_LOGW(TAG, "Static IP task: aborted (flag cleared or no netif)"); + static_ip_task_handle = NULL; + xSemaphoreGive(connIpSemaphoreHandle); + vTaskDelete(NULL); + return; + } + netif = static_ip_netif; + xSemaphoreGive(connIpSemaphoreHandle); + + // Wait for link to stabilize + vTaskDelay(pdMS_TO_TICKS(ETH_LINK_STABILIZATION_MS)); + + // Check again if we should continue (cable might have been unplugged) + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + if (!static_ip_in_progress || static_ip_netif != netif) { + ESP_LOGW(TAG, "Static IP task: aborted after link delay"); + static_ip_task_handle = NULL; + xSemaphoreGive(connIpSemaphoreHandle); + vTaskDelete(NULL); + return; + } + xSemaphoreGive(connIpSemaphoreHandle); + + // Apply static IP configuration. + // Note: semaphore is intentionally released before this call to avoid + // holding it during a potentially blocking operation. The post-apply + // validation below detects if state changed between the check and apply. + esp_err_t result = eth_apply_static_ip(netif); + + if (result == ESP_OK) { + // Give time for IP to be applied before checking gateway + vTaskDelay(pdMS_TO_TICKS(ETH_STATIC_IP_SETTLE_MS)); + + // Check if still valid + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + bool still_valid = static_ip_in_progress && connected && (static_ip_netif == netif); + xSemaphoreGive(connIpSemaphoreHandle); + + if (!still_valid) { + ESP_LOGW(TAG, "Static IP task: aborted after IP apply"); + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + static_ip_in_progress = false; + static_ip_task_handle = NULL; + xSemaphoreGive(connIpSemaphoreHandle); + vTaskDelete(NULL); + return; + } + + // Check gateway reachability + if (!eth_check_gateway_reachable(netif)) { + ESP_LOGW(TAG, "Static IP failed gateway check, falling back to DHCP"); + + // Check if still connected before starting DHCP (prevents race with disconnect) + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + bool still_connected = (static_ip_netif == netif); // netif still valid + connected = false; + static_ip_in_progress = false; + static_ip_task_handle = NULL; + xSemaphoreGive(connIpSemaphoreHandle); + + if (still_connected) { + // Start DHCP - GOT_IP event will handle takeover + esp_netif_dhcpc_start(netif); + } else { + ESP_LOGW(TAG, "Ethernet disconnected, skipping DHCP fallback"); + } + } else { + // Static IP succeeded - apply takeover using unified checkpoint + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + static_ip_in_progress = false; + static_ip_task_handle = NULL; + xSemaphoreGive(connIpSemaphoreHandle); + + eth_check_and_apply_takeover(netif); + ESP_LOGI(TAG, "Static IP configuration complete"); + } + } else { + // Static IP configuration failed, DHCP should already be running + ESP_LOGW(TAG, "Static IP configuration failed, using DHCP"); + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + static_ip_in_progress = false; + static_ip_task_handle = NULL; + xSemaphoreGive(connIpSemaphoreHandle); + } + + vTaskDelete(NULL); +} + /** Event handler for Ethernet events */ static void eth_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data) { @@ -363,13 +881,139 @@ static void eth_event_handler(void *arg, esp_event_base_t event_base, mac_addr[0], mac_addr[1], mac_addr[2], mac_addr[3], mac_addr[4], mac_addr[5]); - ESP_ERROR_CHECK(esp_netif_create_ip6_linklocal(netif)); + esp_err_t ipv6_err = esp_netif_create_ip6_linklocal(netif); + if (ipv6_err != ESP_OK) { + // ESP_ERR_ESP_NETIF_IF_NOT_READY is expected during link negotiation + if (ipv6_err == ESP_ERR_ESP_NETIF_IF_NOT_READY) { + ESP_LOGD(TAG, "IPv6 link-local: interface not ready yet (normal during link-up)"); + } else { + ESP_LOGW(TAG, "Failed to create IPv6 link-local: %s (continuing)", esp_err_to_name(ipv6_err)); + } + } + + // Check if WiFi is currently up - if so, plan to prefer Ethernet once + // Ethernet has acquired an IP (after DHCP or static IP is applied). + esp_netif_t *sta_netif = network_get_netif_from_desc(NETWORK_INTERFACE_DESC_STA); + if (sta_netif && network_has_ip(sta_netif)) { + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + want_eth_takeover = true; + xSemaphoreGive(connIpSemaphoreHandle); + ESP_LOGI(TAG, "Ethernet present and WiFi active; will prefer Ethernet after IP acquired"); + } + + // Handle static IP mode (spawn task instead of blocking) + if (current_eth_mode == 2) { // Static + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + + // Kill any existing static IP task before starting a new one + if (static_ip_task_handle != NULL) { + ESP_LOGW(TAG, "Aborting previous static IP task"); + vTaskDelete(static_ip_task_handle); + static_ip_task_handle = NULL; + } + + // Check if playback is active - defer if so + if (network_is_playback_active()) { + ESP_LOGI(TAG, "Playback active; deferring static IP until playback stops"); + static_ip_pending = true; + static_ip_netif = netif; + static_ip_in_progress = false; + xSemaphoreGive(connIpSemaphoreHandle); + break; + } + + // Store netif in protected variable BEFORE creating task + static_ip_netif = netif; + static_ip_in_progress = true; + static_ip_pending = false; + xSemaphoreGive(connIpSemaphoreHandle); + + // Spawn task to handle static IP in background (doesn't block event handler) + BaseType_t task_created = xTaskCreate( + static_ip_task, + "eth_static_ip", + ETH_STATIC_IP_TASK_STACK, + NULL, // Task uses protected static_ip_netif instead + ETH_STATIC_IP_TASK_PRIORITY, + &static_ip_task_handle + ); + + if (task_created != pdPASS) { + ESP_LOGE(TAG, "Failed to create static IP task, falling back to DHCP"); + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + static_ip_in_progress = false; + static_ip_netif = NULL; + static_ip_task_handle = NULL; + xSemaphoreGive(connIpSemaphoreHandle); + // Explicitly start DHCP as fallback + esp_err_t dhcp_err = esp_netif_dhcpc_start(netif); + if (dhcp_err != ESP_OK && dhcp_err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STARTED) { + ESP_LOGE(TAG, "Failed to start DHCP fallback: %s", esp_err_to_name(dhcp_err)); + } + } + } else if (current_eth_mode == 1) { + // DHCP mode: explicitly start DHCP client + // This is required because we stop DHCP on disconnect, and it doesn't + // automatically restart on reconnect - causing "invalid static ip" errors + esp_err_t dhcp_err = esp_netif_dhcpc_start(netif); + if (dhcp_err == ESP_OK) { + ESP_LOGI(TAG, "DHCP client started"); + } else if (dhcp_err == ESP_ERR_ESP_NETIF_DHCP_ALREADY_STARTED) { + ESP_LOGD(TAG, "DHCP client already running"); + } else { + ESP_LOGE(TAG, "Failed to start DHCP client: %s", esp_err_to_name(dhcp_err)); + } + // Takeover will be handled in got_ip_event_handler when DHCP completes + } break; case ETHERNET_EVENT_DISCONNECTED: + // Defensive check - semaphore should be created in eth_start() + if (!connIpSemaphoreHandle) { + ESP_LOGE(TAG, "Semaphore not initialized in disconnect handler"); + break; + } xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); connected = false; - xSemaphoreGive(connIpSemaphoreHandle); + + // Kill any running static IP task immediately + if (static_ip_task_handle != NULL) { + ESP_LOGI(TAG, "Killing static IP task on disconnect"); + vTaskDelete(static_ip_task_handle); + static_ip_task_handle = NULL; + } + + // Reset static IP state guards on disconnect + static_ip_in_progress = false; + static_ip_pending = false; + static_ip_netif = NULL; + + // Stop any running DHCP client to avoid confusion + esp_err_t dhcp_err = esp_netif_dhcpc_stop(netif); + if (dhcp_err != ESP_OK && dhcp_err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STOPPED) { + ESP_LOGD(TAG, "DHCP stop returned: %s", esp_err_to_name(dhcp_err)); + } + + /* If we previously changed the default netif to prefer Ethernet, reset + * the flag and trigger a reconnect so the system falls back to WiFi. + */ + if (we_changed_default_netif) { + ESP_LOGI(TAG, "Ethernet disconnected; triggering WiFi fallback"); + we_changed_default_netif = false; + want_eth_takeover = false; // Clear intent - we completed takeover and now falling back + xSemaphoreGive(connIpSemaphoreHandle); + /* Request reconnect so main re-evaluates network and uses WiFi */ + if (network_request_reconnect() != ESP_OK) { + ESP_LOGW(TAG, "Failed to request reconnect for WiFi fallback"); + } + } else { + /* Preserve want_eth_takeover on brief disconnect - if Ethernet reconnects + * quickly, we still want to complete the takeover. Only clear if we + * actually completed takeover (handled above). + */ + ESP_LOGD(TAG, "Ethernet disconnected before takeover completed, preserving intent"); + xSemaphoreGive(connIpSemaphoreHandle); + } ESP_LOGI(TAG, "Ethernet Link Down"); break; @@ -384,21 +1028,16 @@ static void eth_event_handler(void *arg, esp_event_base_t event_base, } } -/** Event handler for IP_EVENT_ETH_GOT_IP */ +/** Event handler for IP_EVENT_ETH_LOST_IP */ static void lost_ip_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data) { ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data; for (int i = 0; i < eth_port_cnt; i++) { - char if_desc_str[10]; - char num_str[3]; - - itoa(i, num_str, 10); - strcat(strcpy(if_desc_str, NETWORK_INTERFACE_DESC_ETH), num_str); + char if_desc_str[32]; // Larger buffer to prevent overflow + snprintf(if_desc_str, sizeof(if_desc_str), "%s%d", NETWORK_INTERFACE_DESC_ETH, i); if (network_is_our_netif(if_desc_str, event->esp_netif)) { - // const esp_netif_ip_info_t *ip_info = &event->ip_info; - ESP_LOGI(TAG, "Ethernet Lost IP Address"); xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); @@ -418,11 +1057,8 @@ static void got_ip_event_handler(void *arg, esp_event_base_t event_base, ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data; for (int i = 0; i < eth_port_cnt; i++) { - char if_desc_str[10]; - char num_str[3]; - - itoa(i, num_str, 10); - strcat(strcpy(if_desc_str, NETWORK_INTERFACE_DESC_ETH), num_str); + char if_desc_str[32]; // Larger buffer to prevent overflow + snprintf(if_desc_str, sizeof(if_desc_str), "%s%d", NETWORK_INTERFACE_DESC_ETH, i); if (network_is_our_netif(if_desc_str, event->esp_netif)) { xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); @@ -431,8 +1067,6 @@ static void got_ip_event_handler(void *arg, esp_event_base_t event_base, sizeof(esp_netif_ip_info_t)); connected = true; - xSemaphoreGive(connIpSemaphoreHandle); - ESP_LOGI(TAG, "Ethernet Got IP Address"); ESP_LOGI(TAG, "~~~~~~~~~~~"); ESP_LOGI(TAG, "ETHIP:" IPSTR, IP2STR(&ip_info.ip)); @@ -440,12 +1074,23 @@ static void got_ip_event_handler(void *arg, esp_event_base_t event_base, ESP_LOGI(TAG, "ETHGW:" IPSTR, IP2STR(&ip_info.gw)); ESP_LOGI(TAG, "~~~~~~~~~~~"); + xSemaphoreGive(connIpSemaphoreHandle); + + /* Check and apply Ethernet takeover (handles playback check internally) */ + eth_check_and_apply_takeover(event->esp_netif); + break; } } } /** + * @brief Get Ethernet IP information and connection status + * + * Thread-safe function to retrieve current Ethernet IP configuration. + * + * @param[out] ip Pointer to receive IP info (can be NULL to just check status) + * @return true if Ethernet is connected with valid IP, false otherwise */ bool eth_get_ip(esp_netif_ip_info_t *ip) { xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); @@ -460,6 +1105,100 @@ bool eth_get_ip(esp_netif_ip_info_t *ip) { return _connected; } +/** + * @brief Handle playback stopped event + * + * Called by playback_monitor_task when playback stops. Completes any pending + * Ethernet takeover or deferred static IP configuration that was delayed + * during active playback. + */ +static void eth_on_playback_stopped(void) { + // Defensive check - semaphore should be created in eth_start() + if (!connIpSemaphoreHandle) { + ESP_LOGD(TAG, "eth_on_playback_stopped: semaphore not initialized (Ethernet disabled?)"); + return; + } + + bool do_takeover = false; + bool do_static_ip = false; + esp_netif_t *pending_netif = NULL; + + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + + // Check for pending static IP configuration first (takes priority over takeover) + if (static_ip_pending && static_ip_netif && !static_ip_in_progress) { + do_static_ip = true; + pending_netif = static_ip_netif; + static_ip_pending = false; + static_ip_in_progress = true; + } + // Check for pending takeover (DHCP path or already-configured static IP) + else if (want_eth_takeover && connected && !we_changed_default_netif) { + do_takeover = true; + want_eth_takeover = false; + } + + xSemaphoreGive(connIpSemaphoreHandle); + + // Handle pending static IP configuration + if (do_static_ip) { + ESP_LOGI(TAG, "Playback stopped: starting deferred static IP configuration"); + BaseType_t task_created = xTaskCreate( + static_ip_task, + "eth_static_ip", + ETH_STATIC_IP_TASK_STACK, + NULL, // Task uses protected static_ip_netif instead + ETH_STATIC_IP_TASK_PRIORITY, + &static_ip_task_handle + ); + + if (task_created != pdPASS) { + ESP_LOGE(TAG, "Failed to create deferred static IP task, falling back to DHCP"); + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + static_ip_in_progress = false; + static_ip_task_handle = NULL; + xSemaphoreGive(connIpSemaphoreHandle); + // Explicitly start DHCP as fallback + if (pending_netif) { + esp_err_t dhcp_err = esp_netif_dhcpc_start(pending_netif); + if (dhcp_err != ESP_OK && dhcp_err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STARTED) { + ESP_LOGE(TAG, "Failed to start DHCP fallback: %s", esp_err_to_name(dhcp_err)); + } + } + } + return; + } + + // Handle pending takeover + if (do_takeover) { + ESP_LOGI(TAG, "Playback stopped: performing pending Ethernet takeover"); + esp_netif_t *eth_netif = network_get_netif_from_desc(NETWORK_INTERFACE_DESC_ETH); + if (eth_netif) { + esp_err_t err = esp_netif_set_default_netif(eth_netif); + if (err == ESP_OK) { + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + we_changed_default_netif = true; + xSemaphoreGive(connIpSemaphoreHandle); + if (network_request_reconnect() != ESP_OK) { + ESP_LOGW(TAG, "Failed to request reconnect after deferred takeover"); + } + } else { + ESP_LOGE(TAG, "Failed to set default netif: %s", esp_err_to_name(err)); + // Restore takeover intent so it can be retried + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + want_eth_takeover = true; + xSemaphoreGive(connIpSemaphoreHandle); + } + } else { + ESP_LOGW(TAG, "Playback-stopped takeover: ETH netif not found"); + // Restore takeover intent so it can be retried when netif becomes available + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + want_eth_takeover = true; + xSemaphoreGive(connIpSemaphoreHandle); + } + } +} + static void eth_on_got_ipv6(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data) { ip_event_got_ip6_t *event = (ip_event_got_ip6_t *)event_data; @@ -476,18 +1215,38 @@ static void eth_on_got_ipv6(void *arg, esp_event_base_t event_base, /** Init function that exposes to the main application */ void eth_start(void) { - // Initialize Ethernet driver - esp_eth_handle_t *eth_handles; - + // Initialize semaphores first (needed even if Ethernet is disabled) if (!connIpSemaphoreHandle) { connIpSemaphoreHandle = xSemaphoreCreateMutex(); } + // Create ping semaphore once here to avoid leak from repeated creation + if (!ping_done_sem) { + ping_done_sem = xSemaphoreCreateBinary(); + } - ESP_ERROR_CHECK(eth_init(ð_handles, ð_port_cnt)); + // Check Ethernet mode from settings + settings_get_eth_mode(¤t_eth_mode); + ESP_LOGI(TAG, "Ethernet mode: %ld (%s)", (long)current_eth_mode, + current_eth_mode == 0 ? "Disabled" : + current_eth_mode == 1 ? "DHCP" : "Static"); -#if CONFIG_SNAPCLIENT_USE_INTERNAL_ETHERNET || \ - CONFIG_SNAPCLIENT_USE_SPI_ETHERNET - esp_netif_t *eth_netif; + // If Ethernet is disabled, skip all initialization + if (current_eth_mode == 0) { + ESP_LOGI(TAG, "Ethernet disabled by configuration"); + return; + } + + // Initialize Ethernet driver + esp_eth_handle_t *eth_handles; + esp_err_t ret = eth_init(ð_handles, ð_port_cnt); + if (ret != ESP_OK || eth_port_cnt == 0) { + ESP_LOGE(TAG, "Ethernet driver init failed: %s", esp_err_to_name(ret)); + eth_auto_disable_and_persist(); + return; + } + +#if CONFIG_SNAPCLIENT_USE_INTERNAL_ETHERNET || CONFIG_SNAPCLIENT_USE_SPI_ETHERNET + esp_netif_t *eth_netif = NULL; // Create instance(s) of esp-netif for Ethernet(s) if (eth_port_cnt == 1) { @@ -495,9 +1254,31 @@ void eth_start(void) { // you don't need to modify default esp-netif configuration parameters. esp_netif_config_t cfg = ESP_NETIF_DEFAULT_ETH(); eth_netif = esp_netif_new(&cfg); - // Attach Ethernet driver to TCP/IP stack - ESP_ERROR_CHECK( - esp_netif_attach(eth_netif, esp_eth_new_netif_glue(eth_handles[0]))); + if (!eth_netif) { + ESP_LOGE(TAG, "Failed to create Ethernet netif"); + eth_cleanup_drivers(eth_handles, eth_port_cnt); + eth_auto_disable_and_persist(); + return; + } + + esp_eth_netif_glue_handle_t glue = esp_eth_new_netif_glue(eth_handles[0]); + if (!glue) { + ESP_LOGE(TAG, "Failed to create netif glue"); + esp_netif_destroy(eth_netif); + eth_cleanup_drivers(eth_handles, eth_port_cnt); + eth_auto_disable_and_persist(); + return; + } + + ret = esp_netif_attach(eth_netif, glue); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to attach Ethernet to TCP/IP stack: %s", esp_err_to_name(ret)); + esp_eth_del_netif_glue(glue); + esp_netif_destroy(eth_netif); + eth_cleanup_drivers(eth_handles, eth_port_cnt); + eth_auto_disable_and_persist(); + return; + } } else { // Use ESP_NETIF_INHERENT_DEFAULT_ETH when multiple Ethernet interfaces are // used and so you need to modify esp-netif configuration parameters for @@ -506,37 +1287,116 @@ void eth_start(void) { ESP_NETIF_INHERENT_DEFAULT_ETH(); esp_netif_config_t cfg_spi = {.base = &esp_netif_config, .stack = ESP_NETIF_NETSTACK_DEFAULT_ETH}; - char if_key_str[10]; - char if_desc_str[10]; - char num_str[3]; + char if_key_str[32]; // Larger buffer to prevent overflow + char if_desc_str[32]; // Larger buffer to prevent overflow + + // Track created netifs for cleanup on partial failure + esp_netif_t *created_netifs[SPI_ETHERNETS_NUM + INTERNAL_ETHERNETS_NUM]; + memset(created_netifs, 0, sizeof(created_netifs)); + for (int i = 0; i < eth_port_cnt; i++) { - itoa(i, num_str, 10); - strcat(strcpy(if_key_str, "ETH_"), num_str); - strcat(strcpy(if_desc_str, NETWORK_INTERFACE_DESC_ETH), num_str); + snprintf(if_key_str, sizeof(if_key_str), "ETH_%d", i); + snprintf(if_desc_str, sizeof(if_desc_str), "%s%d", NETWORK_INTERFACE_DESC_ETH, i); esp_netif_config.if_key = if_key_str; esp_netif_config.if_desc = if_desc_str; - esp_netif_config.route_prio -= i * 5; + // Decrease route priority for each subsequent interface, with underflow protection + uint32_t decrement = (uint32_t)i * 5; + if (esp_netif_config.route_prio > decrement) { + esp_netif_config.route_prio -= decrement; + } else { + esp_netif_config.route_prio = 1; + } eth_netif = esp_netif_new(&cfg_spi); - // Attach Ethernet driver to TCP/IP stack - ESP_ERROR_CHECK( - esp_netif_attach(eth_netif, esp_eth_new_netif_glue(eth_handles[i]))); + if (!eth_netif) { + ESP_LOGE(TAG, "Failed to create Ethernet netif %d", i); + // Cleanup previously created netifs + for (int j = 0; j < i; j++) { + if (created_netifs[j]) esp_netif_destroy(created_netifs[j]); + } + eth_cleanup_drivers(eth_handles, eth_port_cnt); + eth_auto_disable_and_persist(); + return; + } + created_netifs[i] = eth_netif; + + esp_eth_netif_glue_handle_t glue = esp_eth_new_netif_glue(eth_handles[i]); + if (!glue) { + ESP_LOGE(TAG, "Failed to create netif glue %d", i); + // Cleanup all created netifs including current + for (int j = 0; j <= i; j++) { + if (created_netifs[j]) esp_netif_destroy(created_netifs[j]); + } + eth_cleanup_drivers(eth_handles, eth_port_cnt); + eth_auto_disable_and_persist(); + return; + } + + ret = esp_netif_attach(eth_netif, glue); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to attach Ethernet %d: %s", i, esp_err_to_name(ret)); + esp_eth_del_netif_glue(glue); + // Cleanup all created netifs including current + for (int j = 0; j <= i; j++) { + if (created_netifs[j]) esp_netif_destroy(created_netifs[j]); + } + eth_cleanup_drivers(eth_handles, eth_port_cnt); + eth_auto_disable_and_persist(); + return; + } } } - // Register user defined event handers - ESP_ERROR_CHECK(esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, - ð_event_handler, eth_netif)); - ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_ETH_GOT_IP, - &got_ip_event_handler, NULL)); - ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_ETH_LOST_IP, - &lost_ip_event_handler, NULL)); - ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_GOT_IP6, - ð_on_got_ipv6, NULL)); + // Register event handlers - non-fatal if these fail + ret = esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, + ð_event_handler, eth_netif); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "Failed to register ETH event handler: %s (continuing)", esp_err_to_name(ret)); + } - // Start Ethernet driver state machine + ret = esp_event_handler_register(IP_EVENT, IP_EVENT_ETH_GOT_IP, + &got_ip_event_handler, NULL); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "Failed to register got_ip handler: %s (continuing)", esp_err_to_name(ret)); + } + + ret = esp_event_handler_register(IP_EVENT, IP_EVENT_ETH_LOST_IP, + &lost_ip_event_handler, NULL); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "Failed to register lost_ip handler: %s (continuing)", esp_err_to_name(ret)); + } + + ret = esp_event_handler_register(IP_EVENT, IP_EVENT_GOT_IP6, + ð_on_got_ipv6, NULL); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "Failed to register IPv6 handler: %s (continuing)", esp_err_to_name(ret)); + } + + // Start Ethernet driver state machine - non-fatal, may recover when cable plugged in for (int i = 0; i < eth_port_cnt; i++) { - ESP_ERROR_CHECK(esp_eth_start(eth_handles[i])); + ret = esp_eth_start(eth_handles[i]); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "Failed to start Ethernet %d: %s (may recover on cable connect)", + i, esp_err_to_name(ret)); + } } + + // Start playback monitor task to handle deferred operations when playback stops + if (playback_monitor_task_handle == NULL) { + BaseType_t task_created = xTaskCreate( + playback_monitor_task, + "eth_playback_mon", + PLAYBACK_MONITOR_TASK_STACK, + NULL, + PLAYBACK_MONITOR_TASK_PRIORITY, + &playback_monitor_task_handle + ); + if (task_created != pdPASS) { + ESP_LOGW(TAG, "Failed to create playback monitor task (deferred operations may not work)"); + } + } + + ESP_LOGI(TAG, "Ethernet initialization complete"); #endif } + diff --git a/components/network_interface/include/network_interface.h b/components/network_interface/include/network_interface.h index 660b31eb..c1905dab 100644 --- a/components/network_interface/include/network_interface.h +++ b/components/network_interface/include/network_interface.h @@ -10,6 +10,7 @@ #include +#include "esp_err.h" #include "esp_netif.h" #define NETWORK_INTERFACE_DESC_STA "sta" @@ -20,11 +21,41 @@ extern char *ipv6_addr_types_to_str[6]; +/** Get netif by description string */ esp_netif_t *network_get_netif_from_desc(const char *desc); + +/** Get interface key string */ const char *network_get_ifkey(esp_netif_t *esp_netif); -bool network_if_get_ip(esp_netif_ip_info_t *ip); + +/** Check if netif is up */ bool network_is_netif_up(esp_netif_t *esp_netif); -bool network_is_our_netif(const char *prefix, esp_netif_t *netif); + +/** Check if netif has a valid IP address */ +bool network_has_ip(esp_netif_t *esp_netif); + +/** Initialize network interfaces (WiFi and/or Ethernet) */ void network_if_init(void); +/* + * Inter-component coordination via FreeRTOS EventGroups. + * Used for reconnect requests and playback state signaling. + * + * Initialization order: Call network_events_init() before network_if_init() + * and before any playback or reconnect functions are used. + */ + +/** Initialize network event group (call early in startup) */ +void network_events_init(void); + +/** Check and clear reconnect request (thread-safe, returns true if requested) */ +bool network_check_and_clear_reconnect(void); + +/** Signal that playback has started (thread-safe) + * @return ESP_OK on success, ESP_ERR_INVALID_STATE if not initialized */ +esp_err_t network_playback_started(void); + +/** Signal that playback has stopped (thread-safe) + * @return ESP_OK on success, ESP_ERR_INVALID_STATE if not initialized */ +esp_err_t network_playback_stopped(void); + #endif /* COMPONENTS_NETWORK_INTERFACE_INCLUDE_NETWORK_INTERFACE_H_ */ diff --git a/components/network_interface/network_interface.c b/components/network_interface/network_interface.c index 2223b97f..c80bf356 100644 --- a/components/network_interface/network_interface.c +++ b/components/network_interface/network_interface.c @@ -33,6 +33,95 @@ static const char *TAG = "NET_IF"; +/* ============ Event Group for Inter-Component Coordination ============ */ +#define EVENT_RECONNECT_REQUESTED_BIT BIT0 +#define EVENT_PLAYBACK_STARTED_BIT BIT1 +#define EVENT_PLAYBACK_STOPPED_BIT BIT2 +/* BIT3 reserved for eth_interface.c monitor shutdown */ + +static EventGroupHandle_t network_event_group = NULL; +static bool network_events_initialized = false; + +void network_events_init(void) { + if (network_events_initialized) { + ESP_LOGW(TAG, "Network events already initialized"); + return; + } + + network_event_group = xEventGroupCreate(); + if (network_event_group == NULL) { + ESP_LOGE(TAG, "Failed to create network event group - events will not work!"); + } else { + network_events_initialized = true; + ESP_LOGI(TAG, "Network events initialized"); + } +} + +void network_events_deinit(void) { + if (network_event_group) { + vEventGroupDelete(network_event_group); + network_event_group = NULL; + } + network_events_initialized = false; +} + +EventGroupHandle_t network_get_event_group(void) { + return network_event_group; +} + +esp_err_t network_request_reconnect(void) { + if (!network_event_group) { + ESP_LOGW(TAG, "network_request_reconnect: events not initialized"); + return ESP_ERR_INVALID_STATE; + } + ESP_LOGD(TAG, "Reconnect requested"); + xEventGroupSetBits(network_event_group, EVENT_RECONNECT_REQUESTED_BIT); + return ESP_OK; +} + +bool network_check_and_clear_reconnect(void) { + if (!network_event_group) { + return false; + } + // Atomic test-and-clear using WaitBits with 0 timeout + EventBits_t bits = xEventGroupWaitBits( + network_event_group, + EVENT_RECONNECT_REQUESTED_BIT, + pdTRUE, // Clear on exit (atomic test-and-clear) + pdFALSE, // Don't wait for all bits + 0 // No blocking + ); + return (bits & EVENT_RECONNECT_REQUESTED_BIT) != 0; +} + +esp_err_t network_playback_started(void) { + if (!network_event_group) { + ESP_LOGW(TAG, "network_playback_started: events not initialized"); + return ESP_ERR_INVALID_STATE; + } + xEventGroupSetBits(network_event_group, EVENT_PLAYBACK_STARTED_BIT); + xEventGroupClearBits(network_event_group, EVENT_PLAYBACK_STOPPED_BIT); + return ESP_OK; +} + +esp_err_t network_playback_stopped(void) { + if (!network_event_group) { + ESP_LOGW(TAG, "network_playback_stopped: events not initialized"); + return ESP_ERR_INVALID_STATE; + } + xEventGroupSetBits(network_event_group, EVENT_PLAYBACK_STOPPED_BIT); + xEventGroupClearBits(network_event_group, EVENT_PLAYBACK_STARTED_BIT); + return ESP_OK; +} + +bool network_is_playback_active(void) { + if (!network_event_group) { + return false; + } + EventBits_t bits = xEventGroupGetBits(network_event_group); + return (bits & EVENT_PLAYBACK_STARTED_BIT) != 0; +} + /* types of ipv6 addresses to be displayed on ipv6 events */ const char *ipv6_addr_types_to_str[6] = { "ESP_IP6_ADDR_IS_UNKNOWN", "ESP_IP6_ADDR_IS_GLOBAL", @@ -66,6 +155,36 @@ bool network_is_netif_up(esp_netif_t *esp_netif) { return esp_netif_is_netif_up(esp_netif); } +/** + * @brief Check whether the given network interface has a valid IP address assigned. + */ +bool network_has_ip(esp_netif_t *esp_netif) { + if (!esp_netif) return false; + if (!esp_netif_is_netif_up(esp_netif)) return false; + + esp_netif_ip_info_t ip_info; + esp_err_t err = esp_netif_get_ip_info(esp_netif, &ip_info); + +#if CONFIG_SNAPCLIENT_CONNECT_IPV6 + // Prefer IPv4 when available + if (err == ESP_OK && ip_info.ip.addr != 0) { + return true; + } + // Fall back to IPv6 link-local check when IPv4 is not available + esp_ip6_addr_t ip6; + if (esp_netif_get_ip6_linklocal(esp_netif, &ip6) == ESP_OK) { + // Verify the IPv6 address is not all zeros + if (!ip6_addr_isany(&ip6)) { + return true; + } + } + return false; +#else + if (err != ESP_OK) return false; + return ip_info.ip.addr != 0; +#endif +} + bool network_if_get_ip(esp_netif_ip_info_t *ip) { #if CONFIG_SNAPCLIENT_USE_INTERNAL_ETHERNET || \ CONFIG_SNAPCLIENT_USE_SPI_ETHERNET diff --git a/components/network_interface/priv_include/network_interface_priv.h b/components/network_interface/priv_include/network_interface_priv.h new file mode 100644 index 00000000..3084a854 --- /dev/null +++ b/components/network_interface/priv_include/network_interface_priv.h @@ -0,0 +1,35 @@ +/* + * network_interface_priv.h + * + * Created on: Jan 26, 2025 + * Author: claude + */ + +#ifndef COMPONENTS_NETWORK_INTERFACE_PRIV_INCLUDE_NETWORK_INTERFACE_PRIV_H_ +#define COMPONENTS_NETWORK_INTERFACE_PRIV_INCLUDE_NETWORK_INTERFACE_PRIV_H_ + +#include + +#include "esp_err.h" +#include "esp_netif.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" + +/* + * Internal functions from network_interface.c - not part of public API. + * These are only for use within the network_interface component. + */ + +/** Get the network event group handle */ +EventGroupHandle_t network_get_event_group(void); + +/** Request network reconnection */ +esp_err_t network_request_reconnect(void); + +/** Check if playback is currently active */ +bool network_is_playback_active(void); + +/** Check if netif matches our interface prefix */ +bool network_is_our_netif(const char *prefix, esp_netif_t *netif); + +#endif /* COMPONENTS_NETWORK_INTERFACE_PRIV_INCLUDE_NETWORK_INTERFACE_PRIV_H_ */ diff --git a/components/network_interface/wifi_interface.c b/components/network_interface/wifi_interface.c index ae620b6c..2ac0228b 100644 --- a/components/network_interface/wifi_interface.c +++ b/components/network_interface/wifi_interface.c @@ -19,6 +19,7 @@ #include "freertos/portmacro.h" #include "freertos/semphr.h" #include "network_interface.h" +#include "network_interface_priv.h" #include "nvs_flash.h" #include "sdkconfig.h" diff --git a/components/settings_manager/include/settings_manager.h b/components/settings_manager/include/settings_manager.h index cb78d0ed..b1823e94 100644 --- a/components/settings_manager/include/settings_manager.h +++ b/components/settings_manager/include/settings_manager.h @@ -22,6 +22,26 @@ extern "C" { esp_err_t settings_manager_init(void); +/** + * @defgroup settings_return_codes Common Return Codes + * + * All getter/setter functions in this module share the following return codes: + * + * - ESP_OK: Success. For getters, if the key was not found in NVS + * a sensible default is written to the output parameter + * and ESP_OK is still returned. + * - ESP_ERR_TIMEOUT: The NVS mutex could not be acquired within 5 seconds. + * This is transient — the setting was neither read nor + * written. Callers should treat this as a recoverable + * error (e.g. retry or use a compile-time default), + * NOT as a fatal failure. + * - ESP_ERR_INVALID_ARG: NULL output pointer or invalid input value. + * - ESP_ERR_INVALID_STATE: settings_manager_init() has not been called. + * + * Setters may additionally return NVS-specific errors on write failure. + * @{ + */ + /* Hostname */ esp_err_t settings_get_hostname(char *hostname, size_t max_len); esp_err_t settings_set_hostname(const char *hostname); @@ -41,6 +61,29 @@ esp_err_t settings_get_server_port(int32_t *port); esp_err_t settings_set_server_port(int32_t port); esp_err_t settings_clear_server_port(void); +/* Ethernet mode and static IP settings + * Mode values: 0=Disabled, 1=DHCP (default), 2=Static + */ +esp_err_t settings_get_eth_mode(int32_t *mode); +esp_err_t settings_set_eth_mode(int32_t mode); +esp_err_t settings_clear_eth_mode(void); + +esp_err_t settings_get_eth_static_ip(char *ip, size_t max_len); +esp_err_t settings_set_eth_static_ip(const char *ip); +esp_err_t settings_clear_eth_static_ip(void); + +esp_err_t settings_get_eth_netmask(char *netmask, size_t max_len); +esp_err_t settings_set_eth_netmask(const char *netmask); +esp_err_t settings_clear_eth_netmask(void); + +esp_err_t settings_get_eth_gateway(char *gw, size_t max_len); +esp_err_t settings_set_eth_gateway(const char *gw); +esp_err_t settings_clear_eth_gateway(void); + +esp_err_t settings_get_eth_dns(char *dns, size_t max_len); +esp_err_t settings_set_eth_dns(const char *dns); +esp_err_t settings_clear_eth_dns(void); + /** * Get all settings as a JSON string * @param json_out Buffer to store JSON string (caller must allocate) diff --git a/components/settings_manager/settings_manager.c b/components/settings_manager/settings_manager.c index e5384d5d..41cc8081 100644 --- a/components/settings_manager/settings_manager.c +++ b/components/settings_manager/settings_manager.c @@ -22,6 +22,13 @@ static const char *NVS_KEY_MDNS = "mdns"; // int32 0/1 static const char *NVS_KEY_SERVER_HOST = "server_host"; // string static const char *NVS_KEY_SERVER_PORT = "server_port"; // int32 +// Ethernet static IP settings +static const char *NVS_KEY_ETH_MODE = "eth_mode"; // int32: 0=Disabled, 1=DHCP, 2=Static +static const char *NVS_KEY_ETH_IP = "eth_ip"; // string "192.168.1.100" +static const char *NVS_KEY_ETH_NETMASK = "eth_netmask"; // string "255.255.255.0" +static const char *NVS_KEY_ETH_GATEWAY = "eth_gw"; // string "192.168.1.1" +static const char *NVS_KEY_ETH_DNS = "eth_dns"; // string "8.8.8.8" + // Mutex for thread-safe NVS access static SemaphoreHandle_t hostname_mutex = NULL; @@ -58,6 +65,59 @@ static bool validate_hostname(const char *hostname) { return true; } +/** + * @brief Validate IPv4 address format + * @return true if valid IPv4 address (a.b.c.d where each octet is 0-255) + * @note Uses sscanf rather than inet_pton so that each octet is individually + * range-checked and trailing garbage is rejected via the %c sentinel. + */ +static bool validate_ip_address(const char *ip) { + if (!ip || strlen(ip) == 0) { + return false; + } + + unsigned int a, b, c, d; + char extra; + int ret = sscanf(ip, "%u.%u.%u.%u%c", &a, &b, &c, &d, &extra); + + // Must have exactly 4 octets, no trailing characters + if (ret != 4) { + ESP_LOGD(TAG, "%s: invalid format '%s'", __func__, ip); + return false; + } + + // Each octet must be 0-255 + if (a > 255 || b > 255 || c > 255 || d > 255) { + ESP_LOGD(TAG, "%s: octet out of range in '%s'", __func__, ip); + return false; + } + + ESP_LOGD(TAG, "%s: IP '%s' valid", __func__, ip); + return true; +} + +/** + * Validate that a netmask has contiguous high bits (e.g. 255.255.255.0). + * Assumes the string is already validated as a valid IPv4 address. + */ +static bool validate_netmask(const char *netmask) { + unsigned int a, b, c, d; + if (sscanf(netmask, "%u.%u.%u.%u", &a, &b, &c, &d) != 4) { + return false; + } + uint32_t mask = (a << 24) | (b << 16) | (c << 8) | d; + if (mask == 0) { + return false; + } + // A valid netmask, when inverted and incremented, must be a power of 2 + uint32_t inverted = ~mask; + if ((inverted & (inverted + 1)) != 0) { + ESP_LOGD(TAG, "%s: non-contiguous netmask '%s'", __func__, netmask); + return false; + } + return true; +} + esp_err_t settings_manager_init(void) { if (hostname_mutex == NULL) { hostname_mutex = xSemaphoreCreateMutex(); @@ -469,6 +529,360 @@ esp_err_t settings_clear_server_port(void) { return err; } +/* ============ Ethernet Static IP Settings ============ */ +/* Same return-code contract as all other settings functions — + * see settings_manager.h @defgroup settings_return_codes. */ + +esp_err_t settings_get_eth_mode(int32_t *mode) { + ESP_LOGD(TAG, "%s: entered", __func__); + if (!mode) return ESP_ERR_INVALID_ARG; + if (!hostname_mutex) return ESP_ERR_INVALID_STATE; + + if (xSemaphoreTake(hostname_mutex, pdMS_TO_TICKS(5000)) != pdTRUE) return ESP_ERR_TIMEOUT; + + nvs_handle_t h; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READONLY, &h); + if (err == ESP_OK) { + int32_t v = 1; // Default to DHCP + err = nvs_get_i32(h, NVS_KEY_ETH_MODE, &v); + nvs_close(h); + if (err == ESP_OK) { + *mode = v; + ESP_LOGD(TAG, "%s: eth_mode from NVS: %ld", __func__, (long)*mode); + xSemaphoreGive(hostname_mutex); + return ESP_OK; + } + if (err != ESP_ERR_NVS_NOT_FOUND) { + ESP_LOGW(TAG, "%s: NVS read error: %s", __func__, esp_err_to_name(err)); + } + } + + // Default: DHCP (1) + *mode = 1; + ESP_LOGD(TAG, "%s: eth_mode default: %ld", __func__, (long)*mode); + xSemaphoreGive(hostname_mutex); + return ESP_OK; +} + +esp_err_t settings_set_eth_mode(int32_t mode) { + ESP_LOGD(TAG, "%s: mode=%ld", __func__, (long)mode); + if (mode < 0 || mode > 2) return ESP_ERR_INVALID_ARG; // 0=Disabled, 1=DHCP, 2=Static + if (!hostname_mutex) return ESP_ERR_INVALID_STATE; + if (xSemaphoreTake(hostname_mutex, pdMS_TO_TICKS(5000)) != pdTRUE) return ESP_ERR_TIMEOUT; + + nvs_handle_t h; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &h); + if (err != ESP_OK) { + xSemaphoreGive(hostname_mutex); + return err; + } + + err = nvs_set_i32(h, NVS_KEY_ETH_MODE, mode); + if (err == ESP_OK) err = nvs_commit(h); + + nvs_close(h); + xSemaphoreGive(hostname_mutex); + if (err == ESP_OK) { + ESP_LOGI(TAG, "%s: eth_mode saved: %ld", __func__, (long)mode); + } else { + ESP_LOGE(TAG, "%s: Failed to save eth_mode: %s", __func__, esp_err_to_name(err)); + } + return err; +} + +esp_err_t settings_clear_eth_mode(void) { + ESP_LOGD(TAG, "%s: entered", __func__); + if (!hostname_mutex) return ESP_ERR_INVALID_STATE; + if (xSemaphoreTake(hostname_mutex, pdMS_TO_TICKS(5000)) != pdTRUE) return ESP_ERR_TIMEOUT; + + nvs_handle_t h; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &h); + if (err != ESP_OK) { + xSemaphoreGive(hostname_mutex); + return (err == ESP_ERR_NVS_NOT_FOUND) ? ESP_OK : err; + } + + err = nvs_erase_key(h, NVS_KEY_ETH_MODE); + if (err == ESP_OK || err == ESP_ERR_NVS_NOT_FOUND) { + nvs_commit(h); + err = ESP_OK; + ESP_LOGI(TAG, "%s: eth_mode cleared from NVS", __func__); + } + + nvs_close(h); + xSemaphoreGive(hostname_mutex); + return err; +} + +esp_err_t settings_get_eth_static_ip(char *ip, size_t max_len) { + ESP_LOGD(TAG, "%s: entered", __func__); + if (!ip || max_len == 0) return ESP_ERR_INVALID_ARG; + if (!hostname_mutex) return ESP_ERR_INVALID_STATE; + + if (xSemaphoreTake(hostname_mutex, pdMS_TO_TICKS(5000)) != pdTRUE) return ESP_ERR_TIMEOUT; + + nvs_handle_t h; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READONLY, &h); + if (err == ESP_OK) { + size_t required = max_len; + err = nvs_get_str(h, NVS_KEY_ETH_IP, ip, &required); + nvs_close(h); + if (err == ESP_OK) { + ESP_LOGD(TAG, "%s: eth_ip from NVS: %s", __func__, ip); + xSemaphoreGive(hostname_mutex); + return ESP_OK; + } + if (err != ESP_ERR_NVS_NOT_FOUND) { + ESP_LOGW(TAG, "%s: NVS read error: %s", __func__, esp_err_to_name(err)); + } + } + + ip[0] = '\0'; + xSemaphoreGive(hostname_mutex); + return ESP_OK; +} + +esp_err_t settings_set_eth_static_ip(const char *ip) { + ESP_LOGD(TAG, "%s: ip='%s'", __func__, ip ? ip : "(null)"); + if (!hostname_mutex) return ESP_ERR_INVALID_STATE; + + // Validate IP if not clearing + if (ip && ip[0] != '\0' && !validate_ip_address(ip)) { + ESP_LOGE(TAG, "%s: Invalid IP address: %s", __func__, ip); + return ESP_ERR_INVALID_ARG; + } + + if (xSemaphoreTake(hostname_mutex, pdMS_TO_TICKS(5000)) != pdTRUE) return ESP_ERR_TIMEOUT; + + nvs_handle_t h; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &h); + if (err != ESP_OK) { + xSemaphoreGive(hostname_mutex); + return err; + } + + if (ip == NULL || ip[0] == '\0') { + err = nvs_erase_key(h, NVS_KEY_ETH_IP); + if (err == ESP_OK) err = nvs_commit(h); + } else { + err = nvs_set_str(h, NVS_KEY_ETH_IP, ip); + if (err == ESP_OK) err = nvs_commit(h); + } + + nvs_close(h); + xSemaphoreGive(hostname_mutex); + if (err == ESP_OK) { + ESP_LOGI(TAG, "%s: eth_ip saved: %s", __func__, ip ? ip : "(erased)"); + } + return err; +} + +esp_err_t settings_clear_eth_static_ip(void) { + return settings_set_eth_static_ip(NULL); +} + +esp_err_t settings_get_eth_netmask(char *netmask, size_t max_len) { + ESP_LOGD(TAG, "%s: entered", __func__); + if (!netmask || max_len == 0) return ESP_ERR_INVALID_ARG; + if (!hostname_mutex) return ESP_ERR_INVALID_STATE; + + if (xSemaphoreTake(hostname_mutex, pdMS_TO_TICKS(5000)) != pdTRUE) return ESP_ERR_TIMEOUT; + + nvs_handle_t h; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READONLY, &h); + if (err == ESP_OK) { + size_t required = max_len; + err = nvs_get_str(h, NVS_KEY_ETH_NETMASK, netmask, &required); + nvs_close(h); + if (err == ESP_OK) { + ESP_LOGD(TAG, "%s: eth_netmask from NVS: %s", __func__, netmask); + xSemaphoreGive(hostname_mutex); + return ESP_OK; + } + if (err != ESP_ERR_NVS_NOT_FOUND) { + ESP_LOGW(TAG, "%s: NVS read error: %s", __func__, esp_err_to_name(err)); + } + } + + // Default netmask + strncpy(netmask, "255.255.255.0", max_len - 1); + netmask[max_len - 1] = '\0'; + xSemaphoreGive(hostname_mutex); + return ESP_OK; +} + +esp_err_t settings_set_eth_netmask(const char *netmask) { + ESP_LOGD(TAG, "%s: netmask='%s'", __func__, netmask ? netmask : "(null)"); + if (!hostname_mutex) return ESP_ERR_INVALID_STATE; + + if (netmask && netmask[0] != '\0') { + if (!validate_ip_address(netmask) || !validate_netmask(netmask)) { + ESP_LOGE(TAG, "%s: Invalid netmask: %s", __func__, netmask); + return ESP_ERR_INVALID_ARG; + } + } + + if (xSemaphoreTake(hostname_mutex, pdMS_TO_TICKS(5000)) != pdTRUE) return ESP_ERR_TIMEOUT; + + nvs_handle_t h; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &h); + if (err != ESP_OK) { + xSemaphoreGive(hostname_mutex); + return err; + } + + if (netmask == NULL || netmask[0] == '\0') { + err = nvs_erase_key(h, NVS_KEY_ETH_NETMASK); + if (err == ESP_OK) err = nvs_commit(h); + } else { + err = nvs_set_str(h, NVS_KEY_ETH_NETMASK, netmask); + if (err == ESP_OK) err = nvs_commit(h); + } + + nvs_close(h); + xSemaphoreGive(hostname_mutex); + if (err == ESP_OK) { + ESP_LOGI(TAG, "%s: eth_netmask saved: %s", __func__, netmask ? netmask : "(erased)"); + } + return err; +} + +esp_err_t settings_clear_eth_netmask(void) { + return settings_set_eth_netmask(NULL); +} + +esp_err_t settings_get_eth_gateway(char *gw, size_t max_len) { + ESP_LOGD(TAG, "%s: entered", __func__); + if (!gw || max_len == 0) return ESP_ERR_INVALID_ARG; + if (!hostname_mutex) return ESP_ERR_INVALID_STATE; + + if (xSemaphoreTake(hostname_mutex, pdMS_TO_TICKS(5000)) != pdTRUE) return ESP_ERR_TIMEOUT; + + nvs_handle_t h; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READONLY, &h); + if (err == ESP_OK) { + size_t required = max_len; + err = nvs_get_str(h, NVS_KEY_ETH_GATEWAY, gw, &required); + nvs_close(h); + if (err == ESP_OK) { + ESP_LOGD(TAG, "%s: eth_gateway from NVS: %s", __func__, gw); + xSemaphoreGive(hostname_mutex); + return ESP_OK; + } + if (err != ESP_ERR_NVS_NOT_FOUND) { + ESP_LOGW(TAG, "%s: NVS read error: %s", __func__, esp_err_to_name(err)); + } + } + + gw[0] = '\0'; + xSemaphoreGive(hostname_mutex); + return ESP_OK; +} + +esp_err_t settings_set_eth_gateway(const char *gw) { + ESP_LOGD(TAG, "%s: gw='%s'", __func__, gw ? gw : "(null)"); + if (!hostname_mutex) return ESP_ERR_INVALID_STATE; + + if (gw && gw[0] != '\0' && !validate_ip_address(gw)) { + ESP_LOGE(TAG, "%s: Invalid gateway: %s", __func__, gw); + return ESP_ERR_INVALID_ARG; + } + + if (xSemaphoreTake(hostname_mutex, pdMS_TO_TICKS(5000)) != pdTRUE) return ESP_ERR_TIMEOUT; + + nvs_handle_t h; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &h); + if (err != ESP_OK) { + xSemaphoreGive(hostname_mutex); + return err; + } + + if (gw == NULL || gw[0] == '\0') { + err = nvs_erase_key(h, NVS_KEY_ETH_GATEWAY); + if (err == ESP_OK) err = nvs_commit(h); + } else { + err = nvs_set_str(h, NVS_KEY_ETH_GATEWAY, gw); + if (err == ESP_OK) err = nvs_commit(h); + } + + nvs_close(h); + xSemaphoreGive(hostname_mutex); + if (err == ESP_OK) { + ESP_LOGI(TAG, "%s: eth_gateway saved: %s", __func__, gw ? gw : "(erased)"); + } + return err; +} + +esp_err_t settings_clear_eth_gateway(void) { + return settings_set_eth_gateway(NULL); +} + +esp_err_t settings_get_eth_dns(char *dns, size_t max_len) { + ESP_LOGD(TAG, "%s: entered", __func__); + if (!dns || max_len == 0) return ESP_ERR_INVALID_ARG; + if (!hostname_mutex) return ESP_ERR_INVALID_STATE; + + if (xSemaphoreTake(hostname_mutex, pdMS_TO_TICKS(5000)) != pdTRUE) return ESP_ERR_TIMEOUT; + + nvs_handle_t h; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READONLY, &h); + if (err == ESP_OK) { + size_t required = max_len; + err = nvs_get_str(h, NVS_KEY_ETH_DNS, dns, &required); + nvs_close(h); + if (err == ESP_OK) { + ESP_LOGD(TAG, "%s: eth_dns from NVS: %s", __func__, dns); + xSemaphoreGive(hostname_mutex); + return ESP_OK; + } + if (err != ESP_ERR_NVS_NOT_FOUND) { + ESP_LOGW(TAG, "%s: NVS read error: %s", __func__, esp_err_to_name(err)); + } + } + + dns[0] = '\0'; + xSemaphoreGive(hostname_mutex); + return ESP_OK; +} + +esp_err_t settings_set_eth_dns(const char *dns) { + ESP_LOGD(TAG, "%s: dns='%s'", __func__, dns ? dns : "(null)"); + if (!hostname_mutex) return ESP_ERR_INVALID_STATE; + + if (dns && dns[0] != '\0' && !validate_ip_address(dns)) { + ESP_LOGE(TAG, "%s: Invalid DNS: %s", __func__, dns); + return ESP_ERR_INVALID_ARG; + } + + if (xSemaphoreTake(hostname_mutex, pdMS_TO_TICKS(5000)) != pdTRUE) return ESP_ERR_TIMEOUT; + + nvs_handle_t h; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &h); + if (err != ESP_OK) { + xSemaphoreGive(hostname_mutex); + return err; + } + + if (dns == NULL || dns[0] == '\0') { + err = nvs_erase_key(h, NVS_KEY_ETH_DNS); + if (err == ESP_OK) err = nvs_commit(h); + } else { + err = nvs_set_str(h, NVS_KEY_ETH_DNS, dns); + if (err == ESP_OK) err = nvs_commit(h); + } + + nvs_close(h); + xSemaphoreGive(hostname_mutex); + if (err == ESP_OK) { + ESP_LOGI(TAG, "%s: eth_dns saved: %s", __func__, dns ? dns : "(erased)"); + } + return err; +} + +esp_err_t settings_clear_eth_dns(void) { + return settings_set_eth_dns(NULL); +} + esp_err_t settings_get_json(char *json_out, size_t max_len) { ESP_LOGD(TAG, "%s: entered", __func__); @@ -525,6 +939,40 @@ esp_err_t settings_get_json(char *json_out, size_t max_len) { cJSON_AddBoolToObject(root, "eq_available", false); #endif + // Get Ethernet mode + int32_t eth_mode = 0; + if (settings_get_eth_mode(ð_mode) == ESP_OK) { + cJSON_AddNumberToObject(root, "eth_mode", eth_mode); + } + + // Get Ethernet static IP settings + char eth_ip[16] = {0}; + if (settings_get_eth_static_ip(eth_ip, sizeof(eth_ip)) == ESP_OK && eth_ip[0] != '\0') { + cJSON_AddStringToObject(root, "eth_static_ip", eth_ip); + } + + char eth_netmask[16] = {0}; + if (settings_get_eth_netmask(eth_netmask, sizeof(eth_netmask)) == ESP_OK) { + cJSON_AddStringToObject(root, "eth_netmask", eth_netmask); + } + + char eth_gw[16] = {0}; + if (settings_get_eth_gateway(eth_gw, sizeof(eth_gw)) == ESP_OK && eth_gw[0] != '\0') { + cJSON_AddStringToObject(root, "eth_gateway", eth_gw); + } + + char eth_dns[16] = {0}; + if (settings_get_eth_dns(eth_dns, sizeof(eth_dns)) == ESP_OK && eth_dns[0] != '\0') { + cJSON_AddStringToObject(root, "eth_dns", eth_dns); + } + + // Indicate whether Ethernet support is available in this build +#if CONFIG_SNAPCLIENT_USE_INTERNAL_ETHERNET || CONFIG_SNAPCLIENT_USE_SPI_ETHERNET + cJSON_AddBoolToObject(root, "eth_available", true); +#else + cJSON_AddBoolToObject(root, "eth_available", false); +#endif + // Render to string char *json_str = cJSON_PrintUnformatted(root); cJSON_Delete(root); @@ -601,6 +1049,56 @@ esp_err_t settings_set_from_json(const char *json_in) { } } + // Update eth_mode if present + cJSON *eth_mode = cJSON_GetObjectItem(root, "eth_mode"); + if (cJSON_IsNumber(eth_mode)) { + esp_err_t save_err = settings_set_eth_mode((int32_t)eth_mode->valueint); + if (save_err != ESP_OK) { + ESP_LOGW(TAG, "%s: Failed to save eth_mode", __func__); + err = save_err; + } + } + + // Update eth_static_ip if present + cJSON *eth_ip = cJSON_GetObjectItem(root, "eth_static_ip"); + if (cJSON_IsString(eth_ip) && eth_ip->valuestring) { + esp_err_t save_err = settings_set_eth_static_ip(eth_ip->valuestring); + if (save_err != ESP_OK) { + ESP_LOGW(TAG, "%s: Failed to save eth_static_ip", __func__); + err = save_err; + } + } + + // Update eth_netmask if present + cJSON *eth_netmask = cJSON_GetObjectItem(root, "eth_netmask"); + if (cJSON_IsString(eth_netmask) && eth_netmask->valuestring) { + esp_err_t save_err = settings_set_eth_netmask(eth_netmask->valuestring); + if (save_err != ESP_OK) { + ESP_LOGW(TAG, "%s: Failed to save eth_netmask", __func__); + err = save_err; + } + } + + // Update eth_gateway if present + cJSON *eth_gw = cJSON_GetObjectItem(root, "eth_gateway"); + if (cJSON_IsString(eth_gw) && eth_gw->valuestring) { + esp_err_t save_err = settings_set_eth_gateway(eth_gw->valuestring); + if (save_err != ESP_OK) { + ESP_LOGW(TAG, "%s: Failed to save eth_gateway", __func__); + err = save_err; + } + } + + // Update eth_dns if present + cJSON *eth_dns = cJSON_GetObjectItem(root, "eth_dns"); + if (cJSON_IsString(eth_dns) && eth_dns->valuestring) { + esp_err_t save_err = settings_set_eth_dns(eth_dns->valuestring); + if (save_err != ESP_OK) { + ESP_LOGW(TAG, "%s: Failed to save eth_dns", __func__); + err = save_err; + } + } + cJSON_Delete(root); return err; } diff --git a/components/ui_http_server/html/dsp-settings.html b/components/ui_http_server/html/dsp-settings.html index 898f108b..51bd5e34 100644 --- a/components/ui_http_server/html/dsp-settings.html +++ b/components/ui_http_server/html/dsp-settings.html @@ -256,6 +256,7 @@

DSP Settings

// Debounce timers stored per parameter to avoid flooding the device const debounceTimers = {}; + // Handle parameter value changes function handleParameterChange(paramKey, value, unit) { const valueSpan = document.getElementById(`${paramKey}-value`); if (valueSpan) { @@ -322,6 +323,8 @@

DSP Settings

// Ensure pending updates are sent if user navigates away window.addEventListener('beforeunload', flushPendingUpdates); window.addEventListener('pagehide', flushPendingUpdates); + // Initialize on page load + document.addEventListener('DOMContentLoaded', loadCapabilities); \ No newline at end of file diff --git a/components/ui_http_server/html/general-settings.html b/components/ui_http_server/html/general-settings.html index ad72ae70..3714958a 100644 --- a/components/ui_http_server/html/general-settings.html +++ b/components/ui_http_server/html/general-settings.html @@ -182,7 +182,12 @@

General Settings

let currentSnapUseMDNS = true; let currentSnapHost = ''; let currentSnapPort = ''; - let needsRestart = false; // Track if device needs restart + let currentEthMode = 1; // 0=Disabled, 1=DHCP, 2=Static + let currentEthStaticIp = ''; + let currentEthNetmask = '255.255.255.0'; + let currentEthGateway = ''; + let currentEthDns = ''; + let ethAvailable = true; // whether Ethernet settings should be shown let isDirty = false; // Load current settings @@ -202,7 +207,15 @@

General Settings

currentSnapUseMDNS = capabilities.mdns_enabled !== undefined ? capabilities.mdns_enabled : true; currentSnapHost = capabilities.server_host || ''; currentSnapPort = capabilities.server_port || ''; - + + // Ethernet settings + ethAvailable = capabilities.eth_available !== undefined ? capabilities.eth_available : true; + currentEthMode = capabilities.eth_mode !== undefined ? capabilities.eth_mode : 1; + currentEthStaticIp = capabilities.eth_static_ip || ''; + currentEthNetmask = capabilities.eth_netmask || '255.255.255.0'; + currentEthGateway = capabilities.eth_gateway || ''; + currentEthDns = capabilities.eth_dns || ''; + renderUI(); } catch (error) { console.error('Error loading settings:', error); @@ -225,7 +238,6 @@

General Settings

html += '
'; html += ''; html += ''; - html += ''; html += '
'; html += ''; @@ -249,11 +261,56 @@

General Settings

html += '
'; html += ''; html += ''; - html += ''; html += '
'; - html += ''; // .setting-control.setting-section - html += ''; - + html += ''; // .setting-control.setting-section (snapserver) + + // Ethernet Configuration Section (render only if available) + if (ethAvailable) { + html += '
'; + html += '
Ethernet Configuration
'; + html += ''; + html += '
Choose how Ethernet interface is configured. Static IP requires manual configuration.
'; + html += ''; + html += '
'; + + const staticDisabled = currentEthMode !== 2; + + html += `
`; + html += ''; + html += ``; + html += '
'; + + html += ''; + html += ``; + html += '
'; + + html += ''; + html += ``; + html += '
'; + + html += ''; + html += ``; + html += '
'; + html += '
'; // #eth-static-fields + + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; // .setting-control.setting-section (ethernet) + } else { + html += '
'; + html += '
Ethernet
'; + html += '
Ethernet support is not available in this firmware build.
'; + html += '
'; + } + + html += ''; // .settings-container + // Info message html += '
'; html += 'Note: After changing the settings, you will need to restart the device for the changes to take effect.'; @@ -403,6 +460,238 @@

General Settings

}); } + // ============ Ethernet Configuration Handlers ============ + + // Validate IPv4 address format + function validateIPv4(ip) { + if (!ip || ip.length === 0) return null; // Empty is OK (for optional fields) + const parts = ip.split('.'); + if (parts.length !== 4) return 'Must be in format x.x.x.x'; + for (const part of parts) { + const num = parseInt(part, 10); + if (isNaN(num) || num < 0 || num > 255 || part !== String(num)) { + return 'Each octet must be 0-255'; + } + } + return null; + } + + // Validate netmask format (must be valid subnet mask with contiguous bits) + function validateNetmask(mask) { + if (!mask || mask.length === 0) return null; // Empty checked separately for required fields + + // First check basic IPv4 format + const ipError = validateIPv4(mask); + if (ipError) return ipError; + + // Valid subnet masks (contiguous 1-bits followed by 0-bits) + const validMasks = [ + '255.255.255.255', '255.255.255.254', '255.255.255.252', '255.255.255.248', + '255.255.255.240', '255.255.255.224', '255.255.255.192', '255.255.255.128', + '255.255.255.0', '255.255.254.0', '255.255.252.0', '255.255.248.0', + '255.255.240.0', '255.255.224.0', '255.255.192.0', '255.255.128.0', + '255.255.0.0', '255.254.0.0', '255.252.0.0', '255.248.0.0', + '255.240.0.0', '255.224.0.0', '255.192.0.0', '255.128.0.0', + '255.0.0.0', '254.0.0.0', '252.0.0.0', '248.0.0.0', + '240.0.0.0', '224.0.0.0', '192.0.0.0', '128.0.0.0', '0.0.0.0' + ]; + + if (!validMasks.includes(mask)) { + return 'Invalid subnet mask'; + } + return null; + } + + // Handle Ethernet mode dropdown change + function handleEthModeChange(value) { + const mode = parseInt(value, 10); + const staticEnabled = (mode === 2); + + // Show/hide static IP field container + const staticContainer = document.getElementById('eth-static-fields'); + if (staticContainer) { + staticContainer.style.display = staticEnabled ? 'block' : 'none'; + } + + // Also enable/disable inputs inside (for accessibility) + const ipEl = document.getElementById('eth-ip'); + const maskEl = document.getElementById('eth-netmask'); + const gwEl = document.getElementById('eth-gateway'); + const dnsEl = document.getElementById('eth-dns'); + if (ipEl) ipEl.disabled = !staticEnabled; + if (maskEl) maskEl.disabled = !staticEnabled; + if (gwEl) gwEl.disabled = !staticEnabled; + if (dnsEl) dnsEl.disabled = !staticEnabled; + + // Trigger field validation to show required field errors if switching to static + handleEthFieldChange(); + } + + // Handle changes to any Ethernet field + function handleEthFieldChange() { + const mode = parseInt(document.getElementById('eth-mode').value, 10); + const ip = document.getElementById('eth-ip').value.trim(); + const netmask = document.getElementById('eth-netmask').value.trim(); + const gateway = document.getElementById('eth-gateway').value.trim(); + const dns = document.getElementById('eth-dns').value.trim(); + + // Validate IP fields + let ipError = validateIPv4(ip); + let netmaskError = validateNetmask(netmask); + let gatewayError = validateIPv4(gateway); + const dnsError = validateIPv4(dns); + + // Check required fields for static mode + if (mode === 2) { + if (!ip) ipError = 'Required for static IP'; + if (!netmask) netmaskError = 'Required for static IP'; + if (!gateway) gatewayError = 'Required for static IP'; + } + + document.getElementById('eth-ip-validation').textContent = ipError || ''; + document.getElementById('eth-netmask-validation').textContent = netmaskError || ''; + document.getElementById('eth-gateway-validation').textContent = gatewayError || ''; + document.getElementById('eth-dns-validation').textContent = dnsError || ''; + + updateEthSaveButton(); + } + + // Update Ethernet save button enabled state + function updateEthSaveButton() { + const saveBtn = document.getElementById('save-eth-btn'); + if (!saveBtn) return; + + const mode = parseInt(document.getElementById('eth-mode').value, 10); + const ip = document.getElementById('eth-ip').value.trim(); + const netmask = document.getElementById('eth-netmask').value.trim(); + const gateway = document.getElementById('eth-gateway').value.trim(); + const dns = document.getElementById('eth-dns').value.trim(); + + // Check for validation errors (format issues) + const hasIpError = ip && validateIPv4(ip); + const hasNetmaskError = netmask && validateNetmask(netmask); + const hasGatewayError = gateway && validateIPv4(gateway); + const hasDnsError = dns && validateIPv4(dns); + + // Check required fields for static mode + const missingRequired = mode === 2 && (!ip || !netmask || !gateway); + + if (hasIpError || hasNetmaskError || hasGatewayError || hasDnsError || missingRequired) { + saveBtn.disabled = true; + return; + } + + // Check if anything has changed + const modeChanged = mode !== currentEthMode; + const ipChanged = ip !== currentEthStaticIp; + const netmaskChanged = netmask !== currentEthNetmask; + const gatewayChanged = gateway !== currentEthGateway; + const dnsChanged = dns !== currentEthDns; + + const hasChanges = modeChanged || ipChanged || netmaskChanged || gatewayChanged || dnsChanged; + saveBtn.disabled = !hasChanges; + } + + // Save Ethernet settings + async function saveEthernetSettings() { + const saveBtn = document.getElementById('save-eth-btn'); + + const mode = parseInt(document.getElementById('eth-mode').value, 10); + const ip = document.getElementById('eth-ip').value.trim(); + const netmask = document.getElementById('eth-netmask').value.trim(); + const gateway = document.getElementById('eth-gateway').value.trim(); + const dns = document.getElementById('eth-dns').value.trim(); + + // Validate required fields for static mode + if (mode === 2) { + if (!ip || !netmask || !gateway) { + alert('Static IP mode requires IP address, netmask, and gateway.'); + return; + } + // Confirm static IP change (risk of losing connectivity) + const confirmed = confirm( + 'Warning: Incorrect static IP settings may make the device unreachable via Ethernet.\n\n' + + 'Please verify:\n' + + ' - IP: ' + ip + '\n' + + ' - Netmask: ' + netmask + '\n' + + ' - Gateway: ' + gateway + '\n\n' + + 'If settings are wrong, you can still access the device via WiFi to correct them.\n\n' + + 'Continue with these settings?' + ); + if (!confirmed) return; + } + + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + + try { + // Save all Ethernet settings + const modeSuccess = await setParameter('eth_mode', String(mode)); + if (!modeSuccess) throw new Error('Failed to save Ethernet mode'); + + if (mode === 2) { + // Static mode - save IP settings (already validated as required) + const ipSuccess = await setParameter('eth_static_ip', ip); + if (!ipSuccess) throw new Error('Failed to save static IP'); + + const netmaskSuccess = await setParameter('eth_netmask', netmask); + if (!netmaskSuccess) throw new Error('Failed to save netmask'); + + const gatewaySuccess = await setParameter('eth_gateway', gateway); + if (!gatewaySuccess) throw new Error('Failed to save gateway'); + + if (dns) { + const dnsSuccess = await setParameter('eth_dns', dns); + if (!dnsSuccess) throw new Error('Failed to save DNS'); + } + } + + // Update current values + currentEthMode = mode; + currentEthStaticIp = ip; + currentEthNetmask = netmask; + currentEthGateway = gateway; + currentEthDns = dns; + + // Show success message + const app = document.getElementById('app'); + const successDiv = document.createElement('div'); + successDiv.className = 'success'; + successDiv.textContent = 'Ethernet settings saved successfully! Please restart the device for changes to take effect.'; + app.insertBefore(successDiv, app.firstChild); + setTimeout(() => { successDiv.remove(); }, 5000); + + saveBtn.textContent = 'Save Changes'; + } catch (err) { + console.error('Error saving Ethernet settings:', err); + alert(err.message || 'Failed to save Ethernet settings'); + saveBtn.disabled = false; + saveBtn.textContent = 'Save Changes'; + } + } + + // Reset Ethernet settings to defaults + function resetEthernetSettings() { + if (!confirm('Clear Ethernet settings from NVS and reset to DHCP mode?')) { + return; + } + + // Clear all Ethernet values from NVS using DELETE + Promise.all([ + deleteParameter('eth_mode'), + deleteParameter('eth_static_ip'), + deleteParameter('eth_netmask'), + deleteParameter('eth_gateway'), + deleteParameter('eth_dns') + ]).then(() => { + // Reload settings from server (which will return defaults) + location.reload(); + }).catch(error => { + console.error('Error clearing Ethernet settings:', error); + alert('Failed to clear Ethernet settings. Please try again.'); + }); + } + // Escape HTML to prevent XSS function escapeHtml(text) { const div = document.createElement('div'); diff --git a/components/ui_http_server/html/index.html b/components/ui_http_server/html/index.html index eb9e7634..945341b6 100644 --- a/components/ui_http_server/html/index.html +++ b/components/ui_http_server/html/index.html @@ -82,6 +82,28 @@ border: none; background-color: white; } + + .nav-restart { + margin-left: auto; + padding: 0 20px; + display: flex; + align-items: center; + } + + .nav-restart button { + background-color: #e74c3c; + color: white; + padding: 8px 16px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: background-color 0.3s; + } + + .nav-restart button:hover { + background-color: #c0392b; + } @@ -93,6 +115,9 @@

ESP32 Snapclient

+
@@ -141,6 +166,24 @@

ESP32 Snapclient

} } + function restartDevice() { + if (!confirm('Restart device now?')) return; + + fetch('/restart', { method: 'POST' }) + .then(resp => { + if (resp.ok) { + alert('Device will restart now. Page will reload in 10 seconds.'); + setTimeout(() => location.reload(), 10000); + } else { + alert('Restart request failed'); + } + }) + .catch(err => { + console.error('Restart failed', err); + alert('Restart request failed'); + }); + } + // Handle navigation document.addEventListener('DOMContentLoaded', function() { const navLinks = document.querySelectorAll('.nav-link'); diff --git a/components/ui_http_server/ui_http_server.c b/components/ui_http_server/ui_http_server.c index b616a700..d74cbf5b 100644 --- a/components/ui_http_server/ui_http_server.c +++ b/components/ui_http_server/ui_http_server.c @@ -242,6 +242,80 @@ static esp_err_t root_post_handler(httpd_req_t *req) { return ESP_OK; } + // Ethernet mode (integer: 0=Disabled, 1=DHCP, 2=Static) + if (strcmp(param, "eth_mode") == 0) { + long v = strtol(valstr, NULL, 10); + ESP_LOGI(TAG, "%s: Setting eth_mode to: %ld", __func__, v); + if (settings_set_eth_mode((int32_t)v) == ESP_OK) { + httpd_resp_set_status(req, "200 OK"); + httpd_resp_sendstr(req, "ok"); + } else { + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "error"); + } + return ESP_OK; + } + + // Ethernet static IP (string) + if (strcmp(param, "eth_static_ip") == 0) { + char decoded_ip[16] = {0}; + url_decode(decoded_ip, valstr, sizeof(decoded_ip)); + ESP_LOGI(TAG, "%s: Setting eth_static_ip to: %s", __func__, decoded_ip); + if (settings_set_eth_static_ip(decoded_ip) == ESP_OK) { + httpd_resp_set_status(req, "200 OK"); + httpd_resp_sendstr(req, "ok"); + } else { + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_sendstr(req, "Invalid IP address"); + } + return ESP_OK; + } + + // Ethernet netmask (string) + if (strcmp(param, "eth_netmask") == 0) { + char decoded_netmask[16] = {0}; + url_decode(decoded_netmask, valstr, sizeof(decoded_netmask)); + ESP_LOGI(TAG, "%s: Setting eth_netmask to: %s", __func__, decoded_netmask); + if (settings_set_eth_netmask(decoded_netmask) == ESP_OK) { + httpd_resp_set_status(req, "200 OK"); + httpd_resp_sendstr(req, "ok"); + } else { + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_sendstr(req, "Invalid netmask"); + } + return ESP_OK; + } + + // Ethernet gateway (string) + if (strcmp(param, "eth_gateway") == 0) { + char decoded_gw[16] = {0}; + url_decode(decoded_gw, valstr, sizeof(decoded_gw)); + ESP_LOGI(TAG, "%s: Setting eth_gateway to: %s", __func__, decoded_gw); + if (settings_set_eth_gateway(decoded_gw) == ESP_OK) { + httpd_resp_set_status(req, "200 OK"); + httpd_resp_sendstr(req, "ok"); + } else { + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_sendstr(req, "Invalid gateway"); + } + return ESP_OK; + } + + // Ethernet DNS (string) + if (strcmp(param, "eth_dns") == 0) { + char decoded_dns[16] = {0}; + url_decode(decoded_dns, valstr, sizeof(decoded_dns)); + ESP_LOGI(TAG, "%s: Setting eth_dns to: %s", __func__, decoded_dns); + if (settings_set_eth_dns(decoded_dns) == ESP_OK) { + httpd_resp_set_status(req, "200 OK"); + httpd_resp_sendstr(req, "ok"); + } else { + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_sendstr(req, "Invalid DNS"); + } + return ESP_OK; + } + // Parse integer value; strtol skips leading whitespace long v = strtol(valstr, NULL, 10); urlBuf.int_value = (int32_t)v; @@ -341,6 +415,71 @@ static esp_err_t root_delete_handler(httpd_req_t *req) { return ESP_OK; } + // Handle eth_mode clear + if (strcmp(param, "eth_mode") == 0) { + ESP_LOGI(TAG, "%s: Clearing eth_mode from NVS", __func__); + if (settings_clear_eth_mode() == ESP_OK) { + httpd_resp_set_status(req, "200 OK"); + httpd_resp_sendstr(req, "ok"); + } else { + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "error"); + } + return ESP_OK; + } + + // Handle eth_static_ip clear + if (strcmp(param, "eth_static_ip") == 0) { + ESP_LOGI(TAG, "%s: Clearing eth_static_ip from NVS", __func__); + if (settings_clear_eth_static_ip() == ESP_OK) { + httpd_resp_set_status(req, "200 OK"); + httpd_resp_sendstr(req, "ok"); + } else { + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "error"); + } + return ESP_OK; + } + + // Handle eth_netmask clear + if (strcmp(param, "eth_netmask") == 0) { + ESP_LOGI(TAG, "%s: Clearing eth_netmask from NVS", __func__); + if (settings_clear_eth_netmask() == ESP_OK) { + httpd_resp_set_status(req, "200 OK"); + httpd_resp_sendstr(req, "ok"); + } else { + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "error"); + } + return ESP_OK; + } + + // Handle eth_gateway clear + if (strcmp(param, "eth_gateway") == 0) { + ESP_LOGI(TAG, "%s: Clearing eth_gateway from NVS", __func__); + if (settings_clear_eth_gateway() == ESP_OK) { + httpd_resp_set_status(req, "200 OK"); + httpd_resp_sendstr(req, "ok"); + } else { + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "error"); + } + return ESP_OK; + } + + // Handle eth_dns clear + if (strcmp(param, "eth_dns") == 0) { + ESP_LOGI(TAG, "%s: Clearing eth_dns from NVS", __func__); + if (settings_clear_eth_dns() == ESP_OK) { + httpd_resp_set_status(req, "200 OK"); + httpd_resp_sendstr(req, "ok"); + } else { + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "error"); + } + return ESP_OK; + } + // Unknown parameter httpd_resp_set_status(req, "400 Bad Request"); httpd_resp_sendstr(req, "Unknown parameter"); @@ -432,7 +571,59 @@ static esp_err_t get_param_handler(httpd_req_t *req) { __func__); } return ESP_OK; - } + } + + if (strcmp(param, "eth_mode") == 0) { + int32_t mode = 1; + settings_get_eth_mode(&mode); + char resp[8]; + snprintf(resp, sizeof(resp), "%d", (int)mode); + httpd_resp_set_status(req, "200 OK"); + httpd_resp_set_type(req, "text/plain"); + httpd_resp_sendstr(req, resp); + ESP_LOGD(TAG, "%s: eth_mode=%d", __func__, (int)mode); + return ESP_OK; + } + + if (strcmp(param, "eth_static_ip") == 0) { + char ip[16] = {0}; + settings_get_eth_static_ip(ip, sizeof(ip)); + httpd_resp_set_status(req, "200 OK"); + httpd_resp_set_type(req, "text/plain"); + httpd_resp_sendstr(req, ip); + ESP_LOGD(TAG, "%s: eth_static_ip=%s", __func__, ip); + return ESP_OK; + } + + if (strcmp(param, "eth_netmask") == 0) { + char netmask[16] = {0}; + settings_get_eth_netmask(netmask, sizeof(netmask)); + httpd_resp_set_status(req, "200 OK"); + httpd_resp_set_type(req, "text/plain"); + httpd_resp_sendstr(req, netmask); + ESP_LOGD(TAG, "%s: eth_netmask=%s", __func__, netmask); + return ESP_OK; + } + + if (strcmp(param, "eth_gateway") == 0) { + char gw[16] = {0}; + settings_get_eth_gateway(gw, sizeof(gw)); + httpd_resp_set_status(req, "200 OK"); + httpd_resp_set_type(req, "text/plain"); + httpd_resp_sendstr(req, gw); + ESP_LOGD(TAG, "%s: eth_gateway=%s", __func__, gw); + return ESP_OK; + } + + if (strcmp(param, "eth_dns") == 0) { + char dns[16] = {0}; + settings_get_eth_dns(dns, sizeof(dns)); + httpd_resp_set_status(req, "200 OK"); + httpd_resp_set_type(req, "text/plain"); + httpd_resp_sendstr(req, dns); + ESP_LOGD(TAG, "%s: eth_dns=%s", __func__, dns); + return ESP_OK; + } #if CONFIG_USE_DSP_PROCESSOR // Get current flow from settings @@ -1345,4 +1536,4 @@ void init_http_server_task(void) { // Stack size can be reduced from 512*8 since we're not using file I/O xTaskCreatePinnedToCore(http_server_task, "HTTP", 512 * 6, NULL, 2, &taskHandle, tskNO_AFFINITY); -} \ No newline at end of file +} diff --git a/main/main.c b/main/main.c index f021afc5..5243071d 100644 --- a/main/main.c +++ b/main/main.c @@ -103,6 +103,7 @@ struct timeval tdif, tavg; /* Logging tag */ static const char *TAG = "SC"; + // static QueueHandle_t playerChunkQueueHandle = NULL; SemaphoreHandle_t timeSyncSemaphoreHandle = NULL; @@ -552,6 +553,13 @@ static void http_get_task(void *pvParameters) { } ESP_LOGI(TAG, "Wait for network connection"); + + // Ensure WiFi is started (may have been stopped if Ethernet was previously active) + esp_err_t wifi_err = esp_wifi_start(); + if (wifi_err != ESP_OK && wifi_err != ESP_ERR_WIFI_STATE) { + ESP_LOGW(TAG, "esp_wifi_start() returned %s", esp_err_to_name(wifi_err)); + } + #if CONFIG_SNAPCLIENT_USE_INTERNAL_ETHERNET || \ CONFIG_SNAPCLIENT_USE_SPI_ETHERNET esp_netif_t *eth_netif = @@ -559,27 +567,99 @@ static void http_get_task(void *pvParameters) { #endif esp_netif_t *sta_netif = network_get_netif_from_desc(NETWORK_INTERFACE_DESC_STA); - while (1) { + + // If an external module has set a preferred/default netif, prefer it + // when it already has an IP. This helps when `eth_interface.c` sets the + // default netif to Ethernet — main will then bind/connect using that + // default instead of falling back to WiFi. + esp_netif_t *default_netif = esp_netif_get_default_netif(); + if (default_netif != NULL) { + if (network_has_ip(default_netif)) { + netif = default_netif; + ESP_LOGI(TAG, "Using default netif: %s", network_get_ifkey(netif)); + // Do not stop WiFi here; network selection is purely logical. + } else { + ESP_LOGI(TAG, "Default netif present but no IP yet: %s", network_get_ifkey(default_netif)); + } + } + + // Wait for network with Ethernet priority + // If WiFi comes up first, wait a bit longer to see if Ethernet comes up + if (netif == NULL) { +#if CONFIG_SNAPCLIENT_USE_INTERNAL_ETHERNET || \ + CONFIG_SNAPCLIENT_USE_SPI_ETHERNET + int eth_wait_count = 0; + const int ETH_WAIT_MAX = 5; // Wait up to 5 seconds for Ethernet after WiFi is up +#endif + while (1) { + // If an external module requested a reconnect, close any existing + // netconn immediately and restart the loop so we re-evaluate the + // preferred network interface. This makes reconnects observable in + // the logs and reduces the time to rebind to the new default netif. + if (network_check_and_clear_reconnect()) { + if (lwipNetconn != NULL) { + ESP_LOGI(TAG, "Reconnect requested: closing existing netconn (loop start)"); + netconn_close(lwipNetconn); + netconn_delete(lwipNetconn); + lwipNetconn = NULL; + } else { + ESP_LOGI(TAG, "Reconnect requested: no active netconn (loop start)"); + } + if (firstNetBuf != NULL) { + netbuf_delete(firstNetBuf); + firstNetBuf = NULL; + } + // Small delay to let network stack settle + vTaskDelay(pdMS_TO_TICKS(50)); + } #if CONFIG_SNAPCLIENT_USE_INTERNAL_ETHERNET || \ CONFIG_SNAPCLIENT_USE_SPI_ETHERNET - bool ethUp = network_is_netif_up(eth_netif); + bool ethUp = network_has_ip(eth_netif); if (ethUp) { netif = eth_netif; - + ESP_LOGI(TAG, "Using Ethernet interface"); + // Disable WiFi to save power and avoid interference + esp_err_t disc_err = esp_wifi_disconnect(); + if (disc_err != ESP_OK) { + ESP_LOGW(TAG, "esp_wifi_disconnect() returned %s", esp_err_to_name(disc_err)); + } + esp_err_t stop_err = esp_wifi_stop(); + if (stop_err == ESP_OK) { + ESP_LOGI(TAG, "WiFi disabled (Ethernet active)"); + } else { + ESP_LOGW(TAG, "esp_wifi_stop() returned %s", esp_err_to_name(stop_err)); + } break; } #endif - bool staUp = network_is_netif_up(sta_netif); + bool staUp = network_has_ip(sta_netif); if (staUp) { +#if CONFIG_SNAPCLIENT_USE_INTERNAL_ETHERNET || \ + CONFIG_SNAPCLIENT_USE_SPI_ETHERNET + // Only wait for Ethernet if it's enabled in settings + int32_t eth_mode = 0; + settings_get_eth_mode(ð_mode); + if (eth_mode > 0) { + // WiFi is up but Ethernet isn't - wait a bit for Ethernet + if (eth_wait_count < ETH_WAIT_MAX) { + ESP_LOGI(TAG, "WiFi up, waiting for Ethernet (%d/%d)...", eth_wait_count + 1, ETH_WAIT_MAX); + eth_wait_count++; + vTaskDelay(pdMS_TO_TICKS(1000)); + continue; + } + ESP_LOGW(TAG, "Ethernet not available, falling back to WiFi"); + } +#endif netif = sta_netif; - + ESP_LOGI(TAG, "Using WiFi interface"); break; } vTaskDelay(pdMS_TO_TICKS(1000)); - } + } + } // if (netif == NULL) /* Decide at runtime whether to use mDNS or static server config. * The settings_manager holds the mdns flag and optional server host/port. @@ -623,30 +703,26 @@ static void http_get_task(void *pvParameters) { mdns_print_results(r); ESP_LOGI(TAG, "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n"); - mdns_result_t *re = r; - while (re) { - mdns_ip_addr_t *a = re->addr; - if (a == NULL) { - // No address in this result, skip to next - re = re->next; - continue; - } + // Find first valid mDNS result with correct address type + // Don't change netif - keep using the interface we already verified is UP + mdns_result_t *re = r; + while (re) { + mdns_ip_addr_t *a = re->addr; + if (a == NULL) { + re = re->next; + continue; + } #if CONFIG_SNAPCLIENT_CONNECT_IPV6 - if (a->addr.type == IPADDR_TYPE_V6) { - netif = re->esp_netif; - break; - } - - // TODO: fall back to IPv4 if no IPv6 was available + if (a->addr.type == IPADDR_TYPE_V6) { + break; // Found valid IPv6 result + } #else - if (a->addr.type == IPADDR_TYPE_V4) { - netif = re->esp_netif; - break; - } -#endif - - re = re->next; + if (a->addr.type == IPADDR_TYPE_V4) { + break; // Found valid IPv4 result } +#endif + re = re->next; + } if (!re || !re->addr) { mdns_query_results_free(r); @@ -720,9 +796,12 @@ static void http_get_task(void *pvParameters) { #ifdef USE_INTERFACE_BIND // use interface to bind connection uint8_t netifIdx = esp_netif_get_netif_impl_index(netif); + ESP_LOGI(TAG, "Binding netconn to interface %s (idx %u)", network_get_ifkey(netif), netifIdx); rc1 = netconn_bind_if(lwipNetconn, netifIdx); if (rc1 != ERR_OK) { - ESP_LOGE(TAG, "can't bind interface %s", network_get_ifkey(netif)); + ESP_LOGE(TAG, "can't bind interface %s, err %d", network_get_ifkey(netif), rc1); + } else { + ESP_LOGI(TAG, "Successfully bound netconn to %s (idx %u)", network_get_ifkey(netif), netifIdx); } #else // use IP to bind connection if (remote_ip.type == IPADDR_TYPE_V4) { @@ -738,6 +817,8 @@ static void http_get_task(void *pvParameters) { #endif //tcp_nagle_disable(pcb) + ESP_LOGI(TAG, "Connecting to remote %s:%d using local interface %s", + ipaddr_ntoa(&remote_ip), remotePort, network_get_ifkey(netif)); rc2 = netconn_connect(lwipNetconn, &remote_ip, remotePort); if (rc2 != ERR_OK) { ESP_LOGE(TAG, "can't connect to remote %s:%d, err %d", @@ -756,6 +837,21 @@ static void http_get_task(void *pvParameters) { continue; } + // allow external modules to request a reconnect via network_events + if (network_check_and_clear_reconnect()) { + if (lwipNetconn != NULL) { + netconn_close(lwipNetconn); + netconn_delete(lwipNetconn); + lwipNetconn = NULL; + } + if (firstNetBuf != NULL) { + netbuf_delete(firstNetBuf); + firstNetBuf = NULL; + } + ESP_LOGI(TAG, "Reconnect requested: restarting connection loop"); + continue; + } + ESP_LOGI(TAG, "netconn connected using %s", network_get_ifkey(netif)); //if (reset_latency_buffer() < 0) { @@ -876,12 +972,29 @@ static void http_get_task(void *pvParameters) { netconn_set_recvtimeout(lwipNetconn, timeout / 1000); // timeout in ms while (1) { + // Check if external module requested reconnect (e.g., ethernet takeover) + if (network_check_and_clear_reconnect()) { + ESP_LOGI(TAG, "Reconnect requested during receive loop, breaking out"); + netconn_close(lwipNetconn); + netconn_delete(lwipNetconn); + lwipNetconn = NULL; + if (firstNetBuf != NULL) { + netbuf_delete(firstNetBuf); + firstNetBuf = NULL; + } + // Give server time to detect disconnect and clean up old socket state + // before we reconnect (helps with servers that don't handle quick + // reconnects from the same client ID on a different interface) + ESP_LOGI(TAG, "Waiting 2s for server to clean up old connection..."); + vTaskDelay(pdMS_TO_TICKS(2000)); + break; + } + now = esp_timer_get_time(); // send time sync message if ((received_header && (now - lastTimeSyncSent) >= timeout)) { time_sync_msg_cb(NULL); lastTimeSyncSent = now; - // ESP_LOGI(TAG, "time sync sent after %lluus", timeout); } // start receive @@ -1455,6 +1568,16 @@ static void http_get_task(void *pvParameters) { scSet.chkInFrames = samples_per_frame; + // Update player settings BEFORE insert_pcm_chunk + // so start_player() has correct chkInFrames value + if (player_send_snapcast_setting(&scSet) != + pdPASS) { + ESP_LOGE(TAG, + "Failed to notify sync task about " + "codec. Did you init player?"); + return; + } + // ESP_LOGW(TAG, "%d, %llu, %llu", // samples_per_frame, 1000000ULL * // samples_per_frame / scSet.sr, @@ -1534,17 +1657,6 @@ static void http_get_task(void *pvParameters) { insert_pcm_chunk(new_pcmChunk); } - if (player_send_snapcast_setting(&scSet) != - pdPASS) { - ESP_LOGE(TAG, - "Failed to notify " - "sync task about " - "codec. Did you " - "init player?"); - - return; - } - break; } @@ -1598,6 +1710,16 @@ static void http_get_task(void *pvParameters) { // ESP_LOGI(TAG, "new_pcmChunk with size %ld", // new_pcmChunk->totalSize); + // Update player settings BEFORE insert_pcm_chunk + // so start_player() has correct chkInFrames value + if (player_send_snapcast_setting(&scSet) != + pdPASS) { + ESP_LOGE(TAG, + "Failed to notify sync task about " + "codec. Did you init player?"); + return; + } + if (ret == 0) { pcm_chunk_fragment_t *fragment = new_pcmChunk->fragment; @@ -1658,19 +1780,6 @@ static void http_get_task(void *pvParameters) { pcmChunk.outData = NULL; pcmChunk.bytes = 0; - if (player_send_snapcast_setting(&scSet) != - pdPASS) { - ESP_LOGE(TAG, - "Failed to " - "notify " - "sync task " - "about " - "codec. Did you " - "init player?"); - - return; - } - break; } @@ -2649,6 +2758,7 @@ static void http_get_task(void *pvParameters) { } while (netbuf_next(firstNetBuf) >= 0); netbuf_delete(firstNetBuf); + firstNetBuf = NULL; if (rc1 != ERR_OK) { ESP_LOGE(TAG, "Data error, closing netconn"); @@ -2835,6 +2945,12 @@ void app_main(void) { // Initialize settings manager (hostname + snapserver settings) settings_manager_init(); + + // Initialize network events (must be before network_if_init) + network_events_init(); + + // Initialize network interfaces (reads settings during startup) + network_if_init(); // Get hostname for mDNS char mdns_hostname[64] = {0};