From 1cc2a852f7810ef0714795eb3dc0b1a9bdb0c17e Mon Sep 17 00:00:00 2001 From: craigmillard86 Date: Thu, 12 Feb 2026 23:35:12 +0000 Subject: [PATCH 01/28] feat: Unified Ethernet interface with settings manager and web UI --- components/lightsnapcast/CMakeLists.txt | 2 +- components/lightsnapcast/player.c | 58 +- components/network_interface/CMakeLists.txt | 19 +- components/network_interface/eth_interface.c | 958 +++++++++++++++++- .../include/network_interface.h | 35 +- .../network_interface/network_interface.c | 119 +++ .../priv_include/network_interface_priv.h | 35 + components/network_interface/wifi_interface.c | 1 + .../include/settings_manager.h | 43 + .../settings_manager/settings_manager.c | 498 +++++++++ .../ui_http_server/html/dsp-settings.html | 3 + .../ui_http_server/html/general-settings.html | 303 +++++- components/ui_http_server/html/index.html | 43 + components/ui_http_server/ui_http_server.c | 195 +++- main/main.c | 222 +++- 15 files changed, 2408 insertions(+), 126 deletions(-) create mode 100644 components/network_interface/priv_include/network_interface_priv.h 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}; From 11d6c49f02d4c467644492f12375b4d796b15d58 Mon Sep 17 00:00:00 2001 From: raul Date: Sun, 15 Feb 2026 16:31:14 +0100 Subject: [PATCH 02/28] Add player state and refactor inter task communication --- components/lightsnapcast/include/player.h | 9 +- components/lightsnapcast/player.c | 168 ++++++++++----- main/main.c | 236 ++++++++++++++-------- 3 files changed, 279 insertions(+), 134 deletions(-) diff --git a/components/lightsnapcast/include/player.h b/components/lightsnapcast/include/player.h index dcd69aee..0bb97ea5 100644 --- a/components/lightsnapcast/include/player.h +++ b/components/lightsnapcast/include/player.h @@ -62,15 +62,20 @@ typedef struct snapcastSetting_s { i2s_data_bit_width_t bits; bool muted; - uint32_t volume; char *pcmBuf; uint32_t pcmBufSize; } snapcastSetting_t; -int init_player(i2s_std_gpio_config_t pin_config0_, i2s_port_t i2sNum_); +typedef enum { IDLE = 0, PLAYING, PAUSED } player_state_e; +int init_player(i2s_std_gpio_config_t pin_config0_, i2s_port_t i2sNum_, void (*set_mute_cb)(bool, bool)); int deinit_player(void); int start_player(snapcastSetting_t *setting); +void pause_player(bool pause); + +void call_state_cb(void); +void add_player_state_cb(void (*cb)(void)); +player_state_e get_player_state(void); int32_t allocate_pcm_chunk_memory(pcm_chunk_message_t **pcmChunk, size_t bytes); int32_t insert_pcm_chunk(pcm_chunk_message_t *pcmChunk); diff --git a/components/lightsnapcast/player.c b/components/lightsnapcast/player.c index 209de796..562a3d82 100644 --- a/components/lightsnapcast/player.c +++ b/components/lightsnapcast/player.c @@ -117,11 +117,20 @@ static bool gpTimerRunning = false; static void player_task(void *pvParameters); +//player state bool gotSettings = false; -bool playerstarted = false; +bool playerStarted = false; +bool playerPaused = false; +static SemaphoreHandle_t playerStateMux = NULL; -extern void audio_set_mute(bool mute); -extern void audio_dac_enable(bool enabled); +typedef struct state_cb_s { + void (*cb)(void); + struct state_cb_s *next; +} state_cb_t; + +static state_cb_t *state_cb_head = NULL; + +static void (*audio_set_mute)(bool mute, bool set_state); static i2s_chan_handle_t tx_chan = NULL; // I2S tx channel handler static bool i2sEnabled = false; @@ -390,17 +399,20 @@ int deinit_player(void) { // must disable i2s before stopping player task or it will hang my_i2s_channel_disable(tx_chan); - + + if (playerStateMux != NULL) { + xSemaphoreTake(playerStateMux, pdMS_TO_TICKS(10000)); + } //wait max 10s for task to stop itself for(int i = 0; i< 100; i++) { - if (playerstarted) { + if (playerStarted) { vTaskDelay(pdMS_TO_TICKS(100)); } else { break; } } - // stop the task f still running + // stop the task if still running if (playerTaskHandle != NULL) { vTaskDelete(playerTaskHandle); playerTaskHandle = NULL; @@ -411,6 +423,10 @@ int deinit_player(void) { tx_chan = NULL; } + if (playerStateMux != NULL) { + vSemaphoreDelete(playerStateMux); + playerStateMux = NULL; + } if (snapcastSettingsMux != NULL) { vSemaphoreDelete(snapcastSettingsMux); snapcastSettingsMux = NULL; @@ -445,8 +461,9 @@ int deinit_player(void) { /** * call before http task creation! */ -int init_player(i2s_std_gpio_config_t pin_config0_, i2s_port_t i2sNum_) { +int init_player(i2s_std_gpio_config_t pin_config0_, i2s_port_t i2sNum_, void (*set_mute_cb)(bool, bool)) { int ret = 0; + audio_set_mute = set_mute_cb; deinit_player(); @@ -459,14 +476,17 @@ int init_player(i2s_std_gpio_config_t pin_config0_, i2s_port_t i2sNum_) { currentSnapcastSetting.sr = 0; currentSnapcastSetting.ch = 0; currentSnapcastSetting.bits = 0; - currentSnapcastSetting.muted = true; - currentSnapcastSetting.volume = 0; if (snapcastSettingsMux == NULL) { snapcastSettingsMux = xSemaphoreCreateMutex(); xSemaphoreGive(snapcastSettingsMux); } + if (playerStateMux == NULL) { + playerStateMux = xSemaphoreCreateMutex(); + xSemaphoreGive(playerStateMux); + } + /** ret = player_setup_i2s(¤tSnapcastSetting); if (ret < 0) { @@ -516,18 +536,25 @@ int init_player(i2s_std_gpio_config_t pin_config0_, i2s_port_t i2sNum_) { * call to start the player task */ int start_player(snapcastSetting_t *setting) { - if (playerstarted){ - return -1; - } - playerstarted = true; + if (xSemaphoreTake(playerStateMux, 0) != pdTRUE) { + // shutdown in progress, don't start player + return -1; + } + if (playerStarted || playerPaused){ + xSemaphoreGive(playerStateMux); + return -1; + } + playerStarted = true; int ret = 0; ret = player_setup_i2s(setting); if (ret < 0) { ESP_LOGE(TAG, "player_setup_i2s failed: %d", ret); - playerstarted = false; + playerStarted = false; + xSemaphoreGive(playerStateMux); return -1; } + xSemaphoreGive(playerStateMux); tg0_timer_init(); @@ -574,12 +601,63 @@ int start_player(snapcastSetting_t *setting) { SYNC_TASK_PRIORITY, &playerTaskHandle, SYNC_TASK_CORE_ID); - + call_state_cb(); ESP_LOGI(TAG, "start player done"); return 0; } +void pause_player(bool pause) { + xSemaphoreTake(playerStateMux, portMAX_DELAY); + if (pause != playerPaused) { + playerPaused = pause; + if (pause && playerTaskHandle != NULL) { + xTaskNotifyGiveIndexed(playerTaskHandle, 1); + } + if (!pause) { + call_state_cb(); // notify state change, e.g. for http task to send pcm + } + } + xSemaphoreGive(playerStateMux); +} + +player_state_e get_player_state(void) { + xSemaphoreTake(playerStateMux, portMAX_DELAY); + player_state_e state = IDLE; + if (playerPaused) { + state = PAUSED; + } else if (playerStarted) { + state = PLAYING; + } + xSemaphoreGive(playerStateMux); + return state; +} + +void call_state_cb(void) { + state_cb_t *current = state_cb_head; + while (current != NULL) { + if (current->cb != NULL) { + current->cb(); + } + current = current->next; + } +} + +/** + * add callback to be called when player state changes, e.g. from not started to started. + * Callbacks needs to be implemented thread safe as they will be called from player task + */ +void add_player_state_cb(void (*cb)()) { + state_cb_t *new_cb = malloc(sizeof(state_cb_t)); + if (new_cb == NULL) { + ESP_LOGE(TAG, "Failed to allocate memory for state callback"); + return; + } + new_cb->cb = cb; + new_cb->next = state_cb_head; + state_cb_head = new_cb; +} + /** * */ @@ -681,8 +759,7 @@ int32_t player_send_snapcast_setting(snapcastSetting_t *setting) { if ((curSet.bits != setting->bits) || (curSet.buf_ms != setting->buf_ms) || (curSet.ch != setting->ch) || (curSet.chkInFrames != setting->chkInFrames) || - (curSet.codec != setting->codec) || (curSet.muted != setting->muted) || - (curSet.sr != setting->sr) || (curSet.volume != setting->volume) || + (curSet.codec != setting->codec) || (curSet.sr != setting->sr) || (curSet.cDacLat_ms != setting->cDacLat_ms)) { ret = player_set_snapcast_settings(setting); if (ret != pdPASS) { @@ -691,16 +768,7 @@ int32_t player_send_snapcast_setting(snapcastSetting_t *setting) { "snapcast setting"); } - // check if it is only volume / mute related setting, which is handled by - // http_get_task() - // if ((((curSet.muted != setting->muted) || - //(curSet.volume != setting->volume)) && - //((curSet.bits == setting->bits) && - //(curSet.buf_ms == setting->buf_ms) && (curSet.ch == setting->ch) && - //(curSet.chkInFrames == setting->chkInFrames) && - //(curSet.codec == setting->codec) && (curSet.sr == setting->sr) && - //(curSet.cDacLat_ms == setting->cDacLat_ms))) == false) { - // notify needed + // notify needed if ((playerTaskHandle != NULL) && (snapcastSettingQueueHandle != NULL)) { ret = xQueueOverwrite(snapcastSettingQueueHandle, &settingChanged); if (ret != pdPASS) { @@ -1354,14 +1422,18 @@ int32_t insert_pcm_chunk(pcm_chunk_message_t *pcmChunk) { return -1; } + if (playerPaused) { + free_pcm_chunk(pcmChunk); + return 0; + } if (pcmChkQHdl == NULL) { - ESP_LOGW(TAG, "pcm chunk queue not created. Player started: %s", playerstarted ? "True": "False"); + ESP_LOGW(TAG, "pcm chunk queue not created. Player started: %s", playerStarted ? "True": "False"); free_pcm_chunk(pcmChunk); snapcastSetting_t curSet; player_get_snapcast_settings(&curSet); - if (!curSet.muted && gotSettings) { + if (gotSettings) { start_player(&curSet); } @@ -1457,7 +1529,7 @@ static void player_task(void *pvParameters) { //audio_hal_ctrl_codec(audio_hal_handle_t audio_hal, audio_hal_codec_mode_t mode, audio_hal_ctrl_t audio_hal_ctrl) - audio_set_mute(true); + audio_set_mute(true, false); buf_us = (int64_t)(scSet.buf_ms) * 1000LL; clientDacLatency_us = (int64_t)scSet.cDacLat_ms * 1000LL; @@ -1483,7 +1555,7 @@ static void player_task(void *pvParameters) { // // ESP_LOGI(TAG, "created new queue with %d", entries); // } - audio_set_mute(scSet.muted); + audio_set_mute(false, false); // wait for early time syncs to be ready xSemaphoreTake(latencyBufFullSemaphoreHandle, portMAX_DELAY); @@ -1518,7 +1590,7 @@ static void player_task(void *pvParameters) { if ((scSet.sr != __scSet.sr) || (scSet.bits != __scSet.bits) || (scSet.ch != __scSet.ch)) { my_i2s_channel_enable(tx_chan); - audio_set_mute(true); + audio_set_mute(true, false); my_i2s_channel_disable(tx_chan); ret = player_setup_i2s(&__scSet); @@ -1564,13 +1636,11 @@ static void player_task(void *pvParameters) { (scSet.ch != __scSet.ch) || (scSet.buf_ms != __scSet.buf_ms)) { ESP_LOGI(TAG, "snapserver config changed, buffer %ldms, chunk %ld frames, " - "sample rate %ld, ch %d, bits %d mute %d latency %ld", + "sample rate %ld, ch %d, bits %d latency %ld", __scSet.buf_ms, __scSet.chkInFrames, __scSet.sr, __scSet.ch, - __scSet.bits, __scSet.muted, __scSet.cDacLat_ms); - } else { - audio_set_mute(__scSet.muted); - ESP_LOGI(TAG, "snapserver config changed, mute: %d", __scSet.muted); + __scSet.bits, __scSet.cDacLat_ms); } + audio_set_mute(false, false); scSet = __scSet; // store for next round @@ -1702,8 +1772,6 @@ static void player_task(void *pvParameters) { my_gptimer_stop(gptimer); - audio_dac_enable(true); - my_i2s_channel_enable(tx_chan); playback_start_time_us = esp_timer_get_time(); @@ -1719,7 +1787,7 @@ static void player_task(void *pvParameters) { // TODO: use a timer to un-mute non blocking vTaskDelay(pdMS_TO_TICKS(2)); - audio_set_mute(scSet.muted); + audio_set_mute(false, false); ESP_LOGI(TAG, "initial sync age: %lldus, chunk duration: %lldus", age, chunkDuration_us); @@ -1764,7 +1832,7 @@ static void player_task(void *pvParameters) { insertedSamplesCounter = 0; - audio_set_mute(true); + audio_set_mute(true, false); my_i2s_channel_disable(tx_chan); @@ -2020,7 +2088,7 @@ static void player_task(void *pvParameters) { my_gptimer_stop(gptimer); - audio_set_mute(true); + audio_set_mute(true, false); my_i2s_channel_disable(tx_chan); @@ -2110,17 +2178,23 @@ static void player_task(void *pvParameters) { dir = 0; initialSync = 0; - audio_set_mute(true); - audio_dac_enable(false); + audio_set_mute(true, false); my_i2s_channel_disable(tx_chan); i2s_del_channel(tx_chan); tx_chan = NULL; break; } + if (ulTaskNotifyTakeIndexed(1, pdTRUE, 0) == pdTRUE) { + audio_set_mute(true, false); + my_i2s_channel_disable(tx_chan); + i2s_del_channel(tx_chan); + tx_chan = NULL; + break; + } } ret = 0; - + xSemaphoreTake(playerStateMux, portMAX_DELAY); xSemaphoreTake(snapcastSettingsMux, portMAX_DELAY); // delete the queue vQueueDelete(snapcastSettingQueueHandle); @@ -2134,8 +2208,10 @@ static void player_task(void *pvParameters) { ret = destroy_pcm_queue(&pcmChkQHdl); tg0_timer_deinit(); - playerstarted = false; + playerStarted = false; + call_state_cb(); ESP_LOGI(TAG, "stop player done"); playerTaskHandle = NULL; + xSemaphoreGive(playerStateMux); vTaskDelete(NULL); } diff --git a/main/main.c b/main/main.c index 98ed9a69..9c4c61f6 100644 --- a/main/main.c +++ b/main/main.c @@ -88,6 +88,7 @@ const char *VERSION_STRING = "0.0.3"; #define OTA_TASK_CORE_ID tskNO_AFFINITY // 1 // tskNO_AFFINITY +TaskHandle_t t_main_task = NULL; TaskHandle_t t_ota_task = NULL; TaskHandle_t t_http_get_task = NULL; @@ -104,16 +105,19 @@ static const char *TAG = "SC"; SemaphoreHandle_t timeSyncSemaphoreHandle = NULL; SemaphoreHandle_t idCounterSemaphoreHandle = NULL; +SemaphoreHandle_t playerStateChangedMutex = NULL; typedef struct audioDACdata_s { - bool mute; + bool playerMute; + bool stateMute; int volume; - bool enabled; } audioDACdata_t; static audioDACdata_t audioDAC_data; static QueueHandle_t audioDACQHdl = NULL; static SemaphoreHandle_t audioDACSemaphore = NULL; +static void (*set_volume_cb)(int volume); +static void (*set_mute_cb)(bool mute, bool set_state); void time_sync_msg_cb(void *args); @@ -498,49 +502,12 @@ void error_callback(const FLAC__StreamDecoder *decoder, /** * */ -void init_snapcast(QueueHandle_t audioQHdl) { - audioDACQHdl = audioQHdl; - audioDACSemaphore = xSemaphoreCreateMutex(); - audioDAC_data.mute = true; - audioDAC_data.volume = -1; - audioDAC_data.enabled = false; +void init_snapcast(void (*set_volume)(int), void (*set_mute)(bool, bool)) { + set_volume_cb = set_volume; + set_mute_cb = set_mute; } -/** - * - */ -void audio_dac_enable(bool enabled) { - xSemaphoreTake(audioDACSemaphore, portMAX_DELAY); - if (enabled != audioDAC_data.enabled) { - audioDAC_data.enabled = enabled; - xQueueOverwrite(audioDACQHdl, &audioDAC_data); - } - xSemaphoreGive(audioDACSemaphore); -} -/** - * - */ -void audio_set_mute(bool mute) { - xSemaphoreTake(audioDACSemaphore, portMAX_DELAY); - if (mute != audioDAC_data.mute) { - audioDAC_data.mute = mute; - xQueueOverwrite(audioDACQHdl, &audioDAC_data); - } - xSemaphoreGive(audioDACSemaphore); -} - -/** - * - */ -void audio_set_volume(int volume) { - xSemaphoreTake(audioDACSemaphore, portMAX_DELAY); - if (volume != audioDAC_data.volume) { - audioDAC_data.volume = volume; - xQueueOverwrite(audioDACQHdl, &audioDAC_data); - } - xSemaphoreGive(audioDACSemaphore); -} /** * @@ -548,6 +515,7 @@ void audio_set_volume(int volume) { int server_settings_msg_received( server_settings_message_t *server_settings_message, snapcastSetting_t *scSet) { + static int volume = 0; // log mute state, buffer, latency ESP_LOGI(TAG, "Buffer length: %ld", server_settings_message->buffer_ms); ESP_LOGI(TAG, "Latency: %ld", server_settings_message->latency); @@ -564,30 +532,34 @@ int server_settings_msg_received( dsp_processor_set_volome((double)server_settings_message->volume / 100); } #endif - audio_set_mute(server_settings_message->muted); + set_mute_cb(server_settings_message->muted, true); } - if (scSet->volume != server_settings_message->volume) { + if (volume != server_settings_message->volume) { #if SNAPCAST_USE_SOFT_VOL if (!server_settings_message->muted) { dsp_processor_set_volome((double)server_settings_message->volume / 100); } #else - audio_set_volume(server_settings_message->volume); + set_volume_cb(server_settings_message->volume); #endif } - scSet->cDacLat_ms = server_settings_message->latency; - scSet->buf_ms = server_settings_message->buffer_ms; scSet->muted = server_settings_message->muted; - scSet->volume = server_settings_message->volume; + volume = server_settings_message->volume; - if (player_send_snapcast_setting(scSet) != pdPASS) { - ESP_LOGE(TAG, - "Failed to notify sync task. " - "Did you init player?"); + if (scSet->cDacLat_ms != server_settings_message->latency || + scSet->buf_ms != server_settings_message->buffer_ms) { + scSet->cDacLat_ms = server_settings_message->latency; + scSet->buf_ms = server_settings_message->buffer_ms; + + if (player_send_snapcast_setting(scSet) != pdPASS) { + ESP_LOGE(TAG, + "Failed to notify sync task. " + "Did you init player?"); return -1; // fatal, this triggers return from http_get_task + } } return 0; } @@ -991,7 +963,7 @@ int handle_chunk_message(codec_type_t codec, snapcastSetting_t *scSet, int process_data(snapcast_protocol_parser_t *parser, time_sync_data_t *time_sync_data, bool *received_codec_header, codec_type_t *codec, snapcastSetting_t *scSet, - pcm_chunk_message_t **pcmData) { + pcm_chunk_message_t **pcmData, bool paused) { base_message_t base_message_rx; if (parse_base_message(parser, &base_message_rx) == PARSER_COMPLETE) { @@ -1011,6 +983,17 @@ int process_data(snapcast_protocol_parser_t *parser, *received_codec_header, *codec, pcmData, &wire_chnk, &decoderChunk)) { case PARSER_COMPLETE: { + if (paused) { + if (*pcmData != NULL) { + free_pcm_chunk(*pcmData); + *pcmData = NULL; + } + if (decoderChunk.inData != NULL) { + free(decoderChunk.inData); + decoderChunk.inData = NULL; + } + break; + } if (handle_chunk_message(*codec, scSet, pcmData, &wire_chnk) != 0) { return -1; } @@ -1140,6 +1123,12 @@ void before_receive_callback(before_receive_callback_data_t *data) { } } + +void http_player_state_changed() { + xTaskNotifyGive(t_http_get_task); + //ESP_LOGI(TAG, "http task cb"); +} + /** * */ @@ -1161,6 +1150,7 @@ static void http_get_task(void *pvParameters) { codec_type_t codec = NONE; snapcastSetting_t scSet; pcm_chunk_message_t *pcmData = NULL; + bool paused = false; // create a timer to send time sync messages every x µs // esp_timer_create(&tSyncArgs, &time_sync_data.timeSyncMessageTimer); @@ -1172,6 +1162,8 @@ static void http_get_task(void *pvParameters) { return; } + add_player_state_cb(http_player_state_changed); + while (1) { // do some house keeping { @@ -1304,7 +1296,6 @@ static void http_get_task(void *pvParameters) { scSet.ch = 2; scSet.sr = 44100; scSet.chkInFrames = 0; - scSet.volume = 0; scSet.muted = true; snapcast_protocol_parser_t parser; @@ -1335,9 +1326,14 @@ static void http_get_task(void *pvParameters) { // Main connection loop - state machine + data processing while (1) { + if (ulTaskNotifyTake(pdTRUE, 1) == pdTRUE) { + // state change, e.g. pause/play + paused = get_player_state() == PAUSED; + //ESP_LOGI(TAG, "http got cb. %s", paused ? "paused" : "playing/idle"); + } int result = process_data(&parser, &time_sync_data, &received_codec_header, &codec, - &scSet, &pcmData); + &scSet, &pcmData, paused); if (result == -1) { return; // critical error in data processing } else if (result == -2) { @@ -1350,38 +1346,63 @@ static void http_get_task(void *pvParameters) { /** * */ -static void dac_control_task(audio_board_handle_t board_handle, - QueueHandle_t audioQHdl) { - audioDACdata_t dac_data; - audioDACdata_t dac_data_old = { - .mute = true, +static void dac_control(audio_board_handle_t board_handle, + audioDACdata_t dac_data) { + static audioDACdata_t dac_data_old = { + .playerMute = true, + .stateMute = true, .volume = -1, - .enabled = false, }; + static bool muted = true; // TODO: can and should we pass audio_hal_handle_t instead of // audio_board_handle_t? - while (1) { - if (xQueueReceive(audioQHdl, &dac_data, portMAX_DELAY) == pdTRUE) { - if (dac_data.mute != dac_data_old.mute) { - audio_hal_set_mute(board_handle->audio_hal, dac_data.mute); - } - if (dac_data.volume != dac_data_old.volume) { - audio_hal_set_volume(board_handle->audio_hal, dac_data.volume); - } - if (dac_data.enabled != dac_data_old.enabled) { - if (dac_data.enabled) { - audio_hal_ctrl_codec(board_handle->audio_hal, - AUDIO_HAL_CODEC_MODE_DECODE, - AUDIO_HAL_CTRL_START); - } else { - audio_hal_ctrl_codec(board_handle->audio_hal, - AUDIO_HAL_CODEC_MODE_DECODE, - AUDIO_HAL_CTRL_STOP); - } - } - dac_data_old = dac_data; + if (dac_data.playerMute != dac_data_old.playerMute || + dac_data.stateMute != dac_data_old.stateMute) { + bool mute = dac_data.playerMute || dac_data.stateMute; + if (mute != muted) { + muted = mute; + audio_hal_set_mute(board_handle->audio_hal, muted); } } + if (dac_data.volume != dac_data_old.volume) { + audio_hal_set_volume(board_handle->audio_hal, dac_data.volume); + } + dac_data_old = dac_data; +} + +/** + * + */ +void audio_set_mute(bool mute, bool set_state) { + xSemaphoreTake(audioDACSemaphore, portMAX_DELAY); + if (set_state && (mute != audioDAC_data.stateMute)) { + audioDAC_data.stateMute = mute; + xQueueOverwrite(audioDACQHdl, &audioDAC_data); + } + if (!set_state && mute != audioDAC_data.playerMute) { + audioDAC_data.playerMute = mute; + xQueueOverwrite(audioDACQHdl, &audioDAC_data); + } + xSemaphoreGive(audioDACSemaphore); +} + +/** + * + */ +void audio_set_volume(int volume) { + xSemaphoreTake(audioDACSemaphore, portMAX_DELAY); + if (volume != audioDAC_data.volume) { + audioDAC_data.volume = volume; + xQueueOverwrite(audioDACQHdl, &audioDAC_data); + } + xSemaphoreGive(audioDACSemaphore); +} + +void player_state_changed() { + if (playerStateChangedMutex != NULL) { + xSemaphoreGive(playerStateChangedMutex); + } + ESP_LOGI(TAG, "main task cb"); } /** @@ -1413,6 +1434,8 @@ void app_main(void) { esp_log_level_set("UI_HTTP", ESP_LOG_WARN); esp_log_level_set("dspProc", ESP_LOG_DEBUG); + t_main_task = xTaskGetCurrentTaskHandle(); + #if CONFIG_SNAPCLIENT_USE_INTERNAL_ETHERNET || \ CONFIG_SNAPCLIENT_USE_SPI_ETHERNET // clang-format off @@ -1542,10 +1565,22 @@ void app_main(void) { }, }; - QueueHandle_t audioQHdl = xQueueCreate(1, sizeof(audioDACdata_t)); + audioDACQHdl = xQueueCreate(1, sizeof(audioDACdata_t)); + audioDACSemaphore = xSemaphoreCreateMutex(); + audioDAC_data.stateMute = true; + audioDAC_data.playerMute = true; + audioDAC_data.volume = -1; + + init_snapcast(audio_set_volume, audio_set_mute); + init_player(i2s_pin_config0, I2S_NUM_0, audio_set_mute); + add_player_state_cb(player_state_changed); - init_snapcast(audioQHdl); - init_player(i2s_pin_config0, I2S_NUM_0); + // Create binary semaphore for player state change notification + playerStateChangedMutex = xSemaphoreCreateBinary(); + if (playerStateChangedMutex == NULL) { + ESP_LOGE(TAG, "Failed to create playerStateChangedMutex"); + return; + } #if CONFIG_DAC_TAS5805M // Apply persisted TAS5805M settings now that the codec has been initialized @@ -1614,6 +1649,35 @@ void app_main(void) { }; esp_pm_configure(&pmConfig); #endif - - dac_control_task(board_handle, audioQHdl); + audioDACdata_t dac_data; + player_state_e state = IDLE; + int counter = 0; + while (1) { + if (xQueueReceive(audioDACQHdl, &dac_data, pdMS_TO_TICKS(100)) == pdTRUE) { + dac_control(board_handle, dac_data); + } + if (xSemaphoreTake(playerStateChangedMutex, pdMS_TO_TICKS(10)) == pdTRUE) { + player_state_e state_new = get_player_state(); + ESP_LOGI(TAG, "main got cb: %d", state_new); + if (state_new != state) { + ESP_LOGI(TAG, "Player state changed: %d -> %d", state, state_new); + if (state_new == PLAYING) { + audio_hal_ctrl_codec(board_handle->audio_hal, + AUDIO_HAL_CODEC_MODE_DECODE, + AUDIO_HAL_CTRL_START); + } else if (state == PLAYING) { + audio_hal_ctrl_codec(board_handle->audio_hal, + AUDIO_HAL_CODEC_MODE_DECODE, + AUDIO_HAL_CTRL_STOP); + } + state = state_new; + } + } + // test pause/play toggle every 200 loops + // counter++; + // if (counter % 200 == 0) { + // ESP_LOGI(TAG, "toggle pause: %d", state == PLAYING); + // pause_player(state == PLAYING); + // } + } } From 25fc8c2849325aabb21a03fe44c02af17248dcf9 Mon Sep 17 00:00:00 2001 From: craigmillard86 Date: Fri, 20 Feb 2026 00:27:03 +0000 Subject: [PATCH 03/28] Fix: Restore required includes in snapcast_protocol_parser.c luar123/player_state code uses strcmp() and vTaskDelay() which require and FreeRTOS headers. Without these explicit includes, the code fails to compile with implicit declaration errors. These are essential dependencies, not optional. --- .claude/settings.local.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..2a9a26f5 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:github.com)", + "Bash(MSYS_NO_PATHCONV=1 docker run:*)" + ] + } +} From 3706883ccf1ade79bcdf4441b0ff04d20a5ff2e1 Mon Sep 17 00:00:00 2001 From: craigmillard86 Date: Fri, 20 Feb 2026 00:29:03 +0000 Subject: [PATCH 04/28] Revert README.md to match luar123/player_state version --- README.md | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 582aebe3..00462827 100644 --- a/README.md +++ b/README.md @@ -107,23 +107,11 @@ Update third party code (opus, flac, esp-dsp, improv_wifi): ``` git submodule update --init ``` -Copy one of the template sdkconfig files and rename it to sdkconfig... -...on Linux: -``` -cp sdkconfig_lyrat_v4.3 sdkconfig -``` - -...on Windows: -``` -copy sdkconfig_lyrat_v4.3 sdkconfig -``` - -### ESP-IDF environment setup (required for configuration, compiling and flashing) +### ESP-IDF environnement configuration - If you're on Windows : Install [ESP-IDF v5.5.1](https://github.com/espressif/esp-idf/releases/tag/v5.5.1) locally ([More info](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/get-started/windows-setup-update.html)). -- If you're on Linux (docker) : Use the image for ESP-IDF by following [docker build](doc/docker_build.md) doc (you won't need any of the remaining commands/steps below up until the Test section then) +- If you're on Linux (docker) : Use the image for ESP-IDF by following [docker build](doc/docker_build.md) doc - If you're on Linux : follow [official Espressif](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/get-started/linux-macos-setup.html) instructions - For debian based systems you'll need to do the following: ``` sudo apt-get install git wget flex bison gperf python3 python3-pip python3-venv cmake ninja-build ccache libffi-dev libssl-dev dfu-util libusb-1.0-0 @@ -135,16 +123,24 @@ copy sdkconfig_lyrat_v4.3 sdkconfig . ./export.sh ``` -### Snapcast ESP Configuration (Non-Docker-Linux and Windows) + +### Snapcast ESP Configuration +Start with the default config (remove any existing sdkconfig file) or copy one of the template sdkconfig files and rename it to sdkconfig -Configure your platform: +``` +rm sdkconfig +``` +or +``` +cp sdkconfig_lyrat_v4.3 sdkconfig +``` + +then configure your platform: ``` idf.py menuconfig ``` - - -Choose configuration options to match your setup +Configure to match your setup - Audio HAL : Choose your audio board - Lyrat (4.3, 4.2) - Lyrat TD (2.2, 2.1) @@ -228,7 +224,7 @@ Replace `snapclient.local` with your clients IP address. If you have multiple cl You are very welcome to help and provide [Pull Requests](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests) -to the project. Use [develop](https://github.com/CarlosDerSeher/snapclient/tree/develop) branch for your PRs as this is the place where new features will go. +to the project. We strongly suggest you activate [pre-commit](https://pre-commit.com) hooks in this git repository before starting to hack and make commits. From 62842df32e2f6233557bb10caaf21985d30bb5da Mon Sep 17 00:00:00 2001 From: craigmillard86 Date: Fri, 20 Feb 2026 00:29:57 +0000 Subject: [PATCH 05/28] Remove local Claude Code config file from commits --- .claude/settings.local.json | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 2a9a26f5..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "permissions": { - "allow": [ - "WebFetch(domain:github.com)", - "Bash(MSYS_NO_PATHCONV=1 docker run:*)" - ] - } -} From ebf7a14729f1bcdf9bfd84e2667b2096fbdf0ead Mon Sep 17 00:00:00 2001 From: craigmillard86 Date: Fri, 20 Feb 2026 00:30:11 +0000 Subject: [PATCH 06/28] Add .claude to .gitignore to exclude local IDE config --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 42f6cadf..f63602aa 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ managed_components sdkconfig sdkconfig.old dependencies.lock +.claude # Created by https://www.toptal.com/developers/gitignore/api/c,c++,eclipse # Edit at https://www.toptal.com/developers/gitignore?templates=c,c++,eclipse From bb97646adef3432b29f888824f3f62661055e69c Mon Sep 17 00:00:00 2001 From: craigmillard86 Date: Fri, 20 Feb 2026 00:31:33 +0000 Subject: [PATCH 07/28] Revert .gitignore to match luar123/player_state --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index f63602aa..42f6cadf 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ managed_components sdkconfig sdkconfig.old dependencies.lock -.claude # Created by https://www.toptal.com/developers/gitignore/api/c,c++,eclipse # Edit at https://www.toptal.com/developers/gitignore?templates=c,c++,eclipse From dfe124ae839b5447fed03d866183e985659f5749 Mon Sep 17 00:00:00 2001 From: raul Date: Fri, 20 Feb 2026 18:25:29 +0100 Subject: [PATCH 08/28] maximize partitions --- partitions.csv | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/partitions.csv b/partitions.csv index 04073e0a..44cbbfb0 100644 --- a/partitions.csv +++ b/partitions.csv @@ -1,6 +1,6 @@ # Name, Type, SubType, Offset, Size, Flags -nvs, data, nvs, 0x9000, 32K, -otadata, data, ota, 0x11000, 8K, -phy_init, data, phy, 0x13000, 4K, -ota_0, app, ota_0, 0x20000, 1664K, -ota_1, app, ota_1, 0x1C0000, 1664K, +nvs, data, nvs, , 80K, +otadata, data, ota, , 8K, +phy_init, data, phy, , 4K, +ota_0, app, ota_0, , 1984K, +ota_1, app, ota_1, , 1984K, \ No newline at end of file From 2074ba985bb2bff70493e2db8395b476d1a11d7a Mon Sep 17 00:00:00 2001 From: raul Date: Fri, 20 Feb 2026 23:44:11 +0100 Subject: [PATCH 09/28] Fix dead lock in deinit_player. Refactor callbacks and check for NULL --- components/lightsnapcast/include/player.h | 2 +- components/lightsnapcast/player.c | 34 ++++++++++++--------- main/main.c | 36 ++++++++++++++++++----- 3 files changed, 50 insertions(+), 22 deletions(-) diff --git a/components/lightsnapcast/include/player.h b/components/lightsnapcast/include/player.h index 0bb97ea5..3e598319 100644 --- a/components/lightsnapcast/include/player.h +++ b/components/lightsnapcast/include/player.h @@ -68,7 +68,7 @@ typedef struct snapcastSetting_s { } snapcastSetting_t; typedef enum { IDLE = 0, PLAYING, PAUSED } player_state_e; -int init_player(i2s_std_gpio_config_t pin_config0_, i2s_port_t i2sNum_, void (*set_mute_cb)(bool, bool)); +int init_player(i2s_std_gpio_config_t pin_config0_, i2s_port_t i2sNum_, void (*set_mute_cb)(bool)); int deinit_player(void); int start_player(snapcastSetting_t *setting); void pause_player(bool pause); diff --git a/components/lightsnapcast/player.c b/components/lightsnapcast/player.c index 562a3d82..255a7c02 100644 --- a/components/lightsnapcast/player.c +++ b/components/lightsnapcast/player.c @@ -130,7 +130,7 @@ typedef struct state_cb_s { static state_cb_t *state_cb_head = NULL; -static void (*audio_set_mute)(bool mute, bool set_state); +static void (*audio_set_mute)(bool mute); static i2s_chan_handle_t tx_chan = NULL; // I2S tx channel handler static bool i2sEnabled = false; @@ -400,9 +400,11 @@ int deinit_player(void) { // must disable i2s before stopping player task or it will hang my_i2s_channel_disable(tx_chan); - if (playerStateMux != NULL) { - xSemaphoreTake(playerStateMux, pdMS_TO_TICKS(10000)); - } + //don't take playerStateMux here, otherwise we might block player task from stopping + //if (playerStateMux != NULL) { + // xSemaphoreTake(playerStateMux, pdMS_TO_TICKS(10000)); + //} + //wait max 10s for task to stop itself for(int i = 0; i< 100; i++) { if (playerStarted) { @@ -461,8 +463,12 @@ int deinit_player(void) { /** * call before http task creation! */ -int init_player(i2s_std_gpio_config_t pin_config0_, i2s_port_t i2sNum_, void (*set_mute_cb)(bool, bool)) { +int init_player(i2s_std_gpio_config_t pin_config0_, i2s_port_t i2sNum_, void (*set_mute_cb)(bool)) { int ret = 0; + if (set_mute_cb == NULL) { + ESP_LOGE(TAG, "set_mute_cb is NULL"); + return -1; + } audio_set_mute = set_mute_cb; deinit_player(); @@ -1529,7 +1535,7 @@ static void player_task(void *pvParameters) { //audio_hal_ctrl_codec(audio_hal_handle_t audio_hal, audio_hal_codec_mode_t mode, audio_hal_ctrl_t audio_hal_ctrl) - audio_set_mute(true, false); + audio_set_mute(true); buf_us = (int64_t)(scSet.buf_ms) * 1000LL; clientDacLatency_us = (int64_t)scSet.cDacLat_ms * 1000LL; @@ -1555,7 +1561,7 @@ static void player_task(void *pvParameters) { // // ESP_LOGI(TAG, "created new queue with %d", entries); // } - audio_set_mute(false, false); + audio_set_mute(false); // wait for early time syncs to be ready xSemaphoreTake(latencyBufFullSemaphoreHandle, portMAX_DELAY); @@ -1590,7 +1596,7 @@ static void player_task(void *pvParameters) { if ((scSet.sr != __scSet.sr) || (scSet.bits != __scSet.bits) || (scSet.ch != __scSet.ch)) { my_i2s_channel_enable(tx_chan); - audio_set_mute(true, false); + audio_set_mute(true); my_i2s_channel_disable(tx_chan); ret = player_setup_i2s(&__scSet); @@ -1640,7 +1646,7 @@ static void player_task(void *pvParameters) { __scSet.buf_ms, __scSet.chkInFrames, __scSet.sr, __scSet.ch, __scSet.bits, __scSet.cDacLat_ms); } - audio_set_mute(false, false); + audio_set_mute(false); scSet = __scSet; // store for next round @@ -1787,7 +1793,7 @@ static void player_task(void *pvParameters) { // TODO: use a timer to un-mute non blocking vTaskDelay(pdMS_TO_TICKS(2)); - audio_set_mute(false, false); + audio_set_mute(false); ESP_LOGI(TAG, "initial sync age: %lldus, chunk duration: %lldus", age, chunkDuration_us); @@ -1832,7 +1838,7 @@ static void player_task(void *pvParameters) { insertedSamplesCounter = 0; - audio_set_mute(true, false); + audio_set_mute(true); my_i2s_channel_disable(tx_chan); @@ -2088,7 +2094,7 @@ static void player_task(void *pvParameters) { my_gptimer_stop(gptimer); - audio_set_mute(true, false); + audio_set_mute(true); my_i2s_channel_disable(tx_chan); @@ -2178,7 +2184,7 @@ static void player_task(void *pvParameters) { dir = 0; initialSync = 0; - audio_set_mute(true, false); + audio_set_mute(true); my_i2s_channel_disable(tx_chan); i2s_del_channel(tx_chan); tx_chan = NULL; @@ -2186,7 +2192,7 @@ static void player_task(void *pvParameters) { break; } if (ulTaskNotifyTakeIndexed(1, pdTRUE, 0) == pdTRUE) { - audio_set_mute(true, false); + audio_set_mute(true); my_i2s_channel_disable(tx_chan); i2s_del_channel(tx_chan); tx_chan = NULL; diff --git a/main/main.c b/main/main.c index 9c4c61f6..3cc42527 100644 --- a/main/main.c +++ b/main/main.c @@ -117,7 +117,7 @@ static audioDACdata_t audioDAC_data; static QueueHandle_t audioDACQHdl = NULL; static SemaphoreHandle_t audioDACSemaphore = NULL; static void (*set_volume_cb)(int volume); -static void (*set_mute_cb)(bool mute, bool set_state); +static void (*set_mute_cb)(bool mute); void time_sync_msg_cb(void *args); @@ -502,9 +502,21 @@ void error_callback(const FLAC__StreamDecoder *decoder, /** * */ -void init_snapcast(void (*set_volume)(int), void (*set_mute)(bool, bool)) { +int init_snapcast(void (*set_volume)(int), void (*set_mute)(bool)) { + if (set_volume == NULL) { + ESP_LOGE(TAG, "Volume callback is NULL"); + + return -1; + } + if (set_mute == NULL) { + ESP_LOGE(TAG, "Mute callback is NULL"); + + return -1; + } set_volume_cb = set_volume; set_mute_cb = set_mute; + + return 0; } @@ -532,7 +544,7 @@ int server_settings_msg_received( dsp_processor_set_volome((double)server_settings_message->volume / 100); } #endif - set_mute_cb(server_settings_message->muted, true); + set_mute_cb(server_settings_message->muted); } if (volume != server_settings_message->volume) { @@ -1358,6 +1370,7 @@ static void dac_control(audio_board_handle_t board_handle, // audio_board_handle_t? if (dac_data.playerMute != dac_data_old.playerMute || dac_data.stateMute != dac_data_old.stateMute) { + // if either player or state mute is active, we need to mute the output bool mute = dac_data.playerMute || dac_data.stateMute; if (mute != muted) { muted = mute; @@ -1371,7 +1384,8 @@ static void dac_control(audio_board_handle_t board_handle, } /** - * + * Set mute state. If set_state is true, it reflects the snapclient state. Otherwise it + * is coming from player for temporary muting e.g. during startup. */ void audio_set_mute(bool mute, bool set_state) { xSemaphoreTake(audioDACSemaphore, portMAX_DELAY); @@ -1379,13 +1393,21 @@ void audio_set_mute(bool mute, bool set_state) { audioDAC_data.stateMute = mute; xQueueOverwrite(audioDACQHdl, &audioDAC_data); } - if (!set_state && mute != audioDAC_data.playerMute) { + else if (!set_state && mute != audioDAC_data.playerMute) { audioDAC_data.playerMute = mute; xQueueOverwrite(audioDACQHdl, &audioDAC_data); } xSemaphoreGive(audioDACSemaphore); } +void player_set_mute(bool mute) { + audio_set_mute(mute, false); +} + +void set_mute_state(bool mute) { + audio_set_mute(mute, true); +} + /** * */ @@ -1571,8 +1593,8 @@ void app_main(void) { audioDAC_data.playerMute = true; audioDAC_data.volume = -1; - init_snapcast(audio_set_volume, audio_set_mute); - init_player(i2s_pin_config0, I2S_NUM_0, audio_set_mute); + init_snapcast(audio_set_volume, set_mute_state); + init_player(i2s_pin_config0, I2S_NUM_0, player_set_mute); add_player_state_cb(player_state_changed); // Create binary semaphore for player state change notification From 5836146fbbfb30b96637a70c5ad106f04c328413 Mon Sep 17 00:00:00 2001 From: craigmillard86 Date: Tue, 24 Feb 2026 22:18:52 +0000 Subject: [PATCH 10/28] Unified Ethernet/WiFi MAC, player state fixes, and boot-time improvements - Implement unified MAC address so Ethernet uses WiFi eFuse MAC for consistent Snapcast client identity across interfaces - Defer MAC unification during active playback to avoid audio interruption - Add WiFi suppression during MAC transition to prevent switch MAC flapping - Fix boot-time MAC unification when Ethernet links up before WiFi - Fix Ethernet disconnect failover with proper server cleanup delays - Harden thread safety for takeover state with semaphore protection - Fix player mutex corruption, state callback, and pause/resume handling - Fix hostname save showing false error in web UI (undeclared variable) - Respect default netif set by takeover logic in connection handler - Add Ethernet grace period before committing to WiFi at boot --- components/lightsnapcast/player.c | 57 ++- components/network_interface/eth_interface.c | 403 +++++++++++++++--- .../network_interface/include/eth_interface.h | 2 + .../network_interface/network_interface.c | 83 +++- .../priv_include/network_interface_priv.h | 12 + components/network_interface/wifi_interface.c | 38 +- .../ui_http_server/html/general-settings.html | 1 + main/connection_handler.c | 133 ++++-- main/main.c | 41 +- 9 files changed, 646 insertions(+), 124 deletions(-) diff --git a/components/lightsnapcast/player.c b/components/lightsnapcast/player.c index 36e59b98..ad43555c 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/include/network_interface.h" #define USE_SAMPLE_INSERTION CONFIG_USE_SAMPLE_INSERTION @@ -562,6 +563,11 @@ int start_player(snapcastSetting_t *setting) { } xSemaphoreGive(playerStateMux); + // Set network playback state SYNCHRONOUSLY before Ethernet events can fire + // This prevents race condition where eth_check_and_apply_takeover() sees + // playback as inactive during the callback propagation window + network_playback_started(); + tg0_timer_init(); #if CONFIG_PM_ENABLE @@ -607,6 +613,19 @@ int start_player(snapcastSetting_t *setting) { pcmChkQHdl = xQueueCreate(entries, sizeof(pcm_chunk_message_t *)); + if (pcmChkQHdl == NULL) { + ESP_LOGE(TAG, "FAILED to create PCM queue with %d entries (memory exhausted?)", entries); + vQueueDelete(snapcastSettingQueueHandle); + snapcastSettingQueueHandle = NULL; +#if CONFIG_PM_ENABLE + esp_pm_lock_release(player_pm_lock_handle); +#endif + tg0_timer_deinit(); + playerStarted = false; + call_state_cb(); + return -1; + } + ESP_LOGI(TAG, "created new queue with %d", entries); } @@ -628,15 +647,15 @@ void pause_player(bool pause) { if (pause != playerPaused) { playerPaused = pause; state_changed = true; - if (pause && playerTaskHandle != NULL) { + if (playerTaskHandle != NULL) { xTaskNotifyGiveIndexed(playerTaskHandle, 1); } } xSemaphoreGive(playerStateMux); // Call callbacks OUTSIDE the mutex to avoid reentrancy deadlock - if (state_changed && !pause) { - call_state_cb(); // notify state change, e.g. for http task to send pcm + if (state_changed) { + call_state_cb(); // notify state change on both pause and resume } } @@ -1648,7 +1667,11 @@ static void player_task(void *pvParameters) { pcmChkQHdl = xQueueCreate(entries, sizeof(pcm_chunk_message_t *)); - ESP_LOGI(TAG, "created new queue with %d", entries); + if (pcmChkQHdl != NULL) { + ESP_LOGI(TAG, "created new queue with %d", entries); + } else { + ESP_LOGE(TAG, "FAILED to create PCM queue with %d entries (memory exhausted?)", entries); + } } if ((scSet.sr != __scSet.sr) || (scSet.bits != __scSet.bits) || @@ -2204,12 +2227,20 @@ static void player_task(void *pvParameters) { break; } - if (ulTaskNotifyTakeIndexed(1, pdTRUE, 0) == pdTRUE) { - audio_set_mute(true); - my_i2s_channel_disable(tx_chan); - i2s_del_channel(tx_chan); - tx_chan = NULL; - break; + if (ulTaskNotifyTakeIndexed(1, pdTRUE, 0) != 0) { + // Check if we're actually pausing (playerPaused==true) + // Resume signals (playerPaused==false) should NOT break/stop playback + xSemaphoreTake(playerStateMux, portMAX_DELAY); + bool should_stop = playerPaused; + xSemaphoreGive(playerStateMux); + + if (should_stop) { + audio_set_mute(true); + my_i2s_channel_disable(tx_chan); + i2s_del_channel(tx_chan); + tx_chan = NULL; + break; + } } } ret = 0; @@ -2228,10 +2259,14 @@ static void player_task(void *pvParameters) { tg0_timer_deinit(); playerStarted = false; - call_state_cb(); ESP_LOGI(TAG, "stop player done"); playerTaskHandle = NULL; xSemaphoreGive(playerStateMux); + + // Call state callback AFTER releasing mutex to avoid deadlock + // (callbacks may try to acquire playerStateMux) + call_state_cb(); + vTaskDelete(NULL); } diff --git a/components/network_interface/eth_interface.c b/components/network_interface/eth_interface.c index fa8019b5..ea31a2c7 100644 --- a/components/network_interface/eth_interface.c +++ b/components/network_interface/eth_interface.c @@ -13,6 +13,7 @@ #include "esp_log.h" #include "esp_mac.h" #include "esp_netif.h" +#include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/event_groups.h" #include "freertos/task.h" @@ -46,6 +47,7 @@ static const char *TAG = "ETH_IF"; #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 +#define ETH_TAKEOVER_MIN_GRACE_MS 500 // Minimum delay before takeover (prevents immediate switch during race condition window) /* ============ State Variables ============ */ static uint8_t eth_port_cnt = 0; @@ -61,9 +63,12 @@ static SemaphoreHandle_t connIpSemaphoreHandle = NULL; * if we never completed takeover). * - we_changed_default_netif: Set after successfully changing default netif to * Ethernet. Used to trigger WiFi fallback on disconnect. + * - eth_got_ip_time: Timestamp when Ethernet acquired IP, used to enforce + * grace period before performing takeover (allows active playback to adapt) */ static bool we_changed_default_netif = false; static bool want_eth_takeover = false; +static int64_t eth_got_ip_time = 0; /* Ethernet mode: 0=Disabled, 1=DHCP (default), 2=Static */ static int32_t current_eth_mode = 0; @@ -85,9 +90,23 @@ static bool static_ip_pending = false; static esp_netif_t *static_ip_netif = NULL; static TaskHandle_t static_ip_task_handle = NULL; +/* + * MAC Unification Deferral: + * When Ethernet connects during active playback, we let Ethernet keep its + * default (different) MAC so the switch doesn't learn our WiFi MAC on the + * Ethernet port. Only after playback stops do we set the unified MAC and + * restart DHCP to get the same IP as WiFi for seamless takeover. + */ +static bool mac_unification_pending = false; +static esp_netif_t *mac_unification_netif = NULL; + +/* Saved eth_handles pointer for deferred MAC unification */ +static esp_eth_handle_t *s_eth_handles = 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_STACK 4096 #define PLAYBACK_MONITOR_TASK_PRIORITY 4 /* Forward declaration for playback stopped handler */ @@ -129,7 +148,7 @@ static void playback_monitor_task(void *pvParameters) { break; } - ESP_LOGD(TAG, "Playback monitor: playback started, waiting for stop..."); + ESP_LOGI(TAG, "Playback monitor: playback started, waiting for stop..."); // Wait for playback to stop OR shutdown signal bits = xEventGroupWaitBits(event_group, @@ -143,7 +162,26 @@ static void playback_monitor_task(void *pvParameters) { break; } - ESP_LOGD(TAG, "Playback monitor: playback stopped, processing pending operations..."); + ESP_LOGI(TAG, "Playback monitor: playback stopped, waiting grace period..."); + + // Grace period: wait 2s to see if playback restarts (e.g. during RESYNCING HARD) + bits = xEventGroupWaitBits(event_group, + PLAYBACK_STARTED_BIT | EVENT_MONITOR_SHUTDOWN_BIT, + pdFALSE, // Don't clear on exit + pdFALSE, // Don't wait for all bits + pdMS_TO_TICKS(2000)); + + if (bits & EVENT_MONITOR_SHUTDOWN_BIT) { + ESP_LOGI(TAG, "Playback monitor: shutdown requested during grace period"); + break; + } + + if (bits & PLAYBACK_STARTED_BIT) { + ESP_LOGI(TAG, "Playback monitor: playback restarted during grace period, skipping"); + continue; + } + + ESP_LOGI(TAG, "Playback monitor: grace period expired, processing pending ops"); // Process any pending operations (deferred takeover or static IP) eth_on_playback_stopped(); @@ -285,6 +323,9 @@ static esp_eth_handle_t eth_init_internal(esp_eth_mac_t **mac_out, ESP_GOTO_ON_FALSE(esp_eth_driver_install(&config, ð_handle) == ESP_OK, NULL, err, TAG, "Ethernet driver install failed"); + // MAC is NOT set here - Ethernet uses its default (eFuse) MAC at init time. + // Unified MAC is applied later via eth_apply_unified_mac() when safe to do so. + if (mac_out != NULL) { *mac_out = mac; } @@ -472,23 +513,27 @@ static esp_err_t eth_init(esp_eth_handle_t *eth_handles_out[], spi_eth_module_config_t spi_eth_module_config[CONFIG_SNAPCLIENT_SPI_ETHERNETS_NUM] = {0}; INIT_SPI_ETH_MODULE_CONFIG(spi_eth_module_config, 0); - // The SPI Ethernet module(s) might not have a burned factory MAC address, - // hence use manually configured address(es). In this example, Locally - // Administered MAC address derived from ESP32x base MAC address is used. Note - // that Locally Administered OUI range should be used only when testing on a - // LAN under your control! - uint8_t base_mac_addr[ETH_ADDR_LEN]; - ESP_GOTO_ON_ERROR(esp_efuse_mac_get_default(base_mac_addr), err, TAG, - "get EFUSE MAC failed"); - uint8_t local_mac_1[ETH_ADDR_LEN]; - esp_derive_local_mac(local_mac_1, base_mac_addr); - spi_eth_module_config[0].mac_addr = local_mac_1; + + // SPI Ethernet chips (W5500, etc.) have no factory MAC - they need one assigned. + // Use the ESP32's Ethernet eFuse MAC as a temporary MAC (different from WiFi MAC). + // The unified WiFi MAC is applied later via eth_apply_unified_mac() when safe. + static uint8_t spi_temp_mac[CONFIG_SNAPCLIENT_SPI_ETHERNETS_NUM][ETH_ADDR_LEN]; + esp_err_t mac_err = esp_read_mac(spi_temp_mac[0], ESP_MAC_ETH); + if (mac_err != ESP_OK) { + ESP_LOGE(TAG, "Failed to read Ethernet base MAC: %s", esp_err_to_name(mac_err)); + ESP_GOTO_ON_ERROR(mac_err, err, TAG, "Cannot proceed without MAC address"); + } + spi_eth_module_config[0].mac_addr = spi_temp_mac[0]; + ESP_LOGI(TAG, "SPI Ethernet #0 temporary MAC: %02X:%02X:%02X:%02X:%02X:%02X", + spi_temp_mac[0][0], spi_temp_mac[0][1], spi_temp_mac[0][2], + spi_temp_mac[0][3], spi_temp_mac[0][4], spi_temp_mac[0][5]); + #if CONFIG_SNAPCLIENT_SPI_ETHERNETS_NUM > 1 INIT_SPI_ETH_MODULE_CONFIG(spi_eth_module_config, 1); - uint8_t local_mac_2[ETH_ADDR_LEN]; - base_mac_addr[ETH_ADDR_LEN - 1] += 1; - esp_derive_local_mac(local_mac_2, base_mac_addr); - spi_eth_module_config[1].mac_addr = local_mac_2; + // Derive second SPI MAC by incrementing the base + memcpy(spi_temp_mac[1], spi_temp_mac[0], ETH_ADDR_LEN); + spi_temp_mac[1][ETH_ADDR_LEN - 1]++; + spi_eth_module_config[1].mac_addr = spi_temp_mac[1]; #endif #if CONFIG_SNAPCLIENT_SPI_ETHERNETS_NUM > 2 #error Maximum number of supported SPI Ethernet devices is currently limited to 2 by this example. @@ -712,41 +757,123 @@ static esp_err_t eth_apply_static_ip(esp_netif_t *netif) { return ESP_OK; } +/** + * @brief Apply unified (WiFi) MAC address to all Ethernet handles and netif + * + * Sets the MAC address at both the driver level (hardware) and the netif level + * (lwIP stack) to match WiFi, enabling seamless IP takeover via DHCP. + * Both layers must be updated so DHCP packets have consistent MAC in the + * Ethernet frame and the chaddr/client-id fields. + * + * @param netif The Ethernet netif to update (NULL to skip netif update) + * @return ESP_OK on success, error code on failure + */ +static esp_err_t eth_apply_unified_mac(esp_netif_t *netif) { + if (!s_eth_handles) return ESP_ERR_INVALID_STATE; + + uint8_t wifi_mac[ETH_ADDR_LEN]; + esp_err_t err = network_get_unified_mac_internal(wifi_mac); + if (err != ESP_OK) return err; + + for (int i = 0; i < eth_port_cnt; i++) { + if (s_eth_handles[i]) { + err = esp_eth_ioctl(s_eth_handles[i], ETH_CMD_S_MAC_ADDR, wifi_mac); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to set unified MAC on eth%d: %s", + i, esp_err_to_name(err)); + return err; + } + } + } + + // Update the netif MAC so lwIP/DHCP uses the new address in packets + if (netif) { + err = esp_netif_set_mac(netif, wifi_mac); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to set netif MAC: %s", esp_err_to_name(err)); + } + } + + ESP_LOGI(TAG, "Unified MAC applied: %02X:%02X:%02X:%02X:%02X:%02X", + wifi_mac[0], wifi_mac[1], wifi_mac[2], + wifi_mac[3], wifi_mac[4], wifi_mac[5]); + 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. * + * Enforces a grace period after Ethernet IP acquisition to allow active playback + * to adapt to the network change, preventing audio glitches from premature switching. + * * @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; + bool do_mac_unify = false; xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); - if (want_eth_takeover && !we_changed_default_netif && !network_is_playback_active()) { + + if (want_eth_takeover && !we_changed_default_netif && + !network_is_playback_active()) { do_takeover = true; + do_mac_unify = mac_unification_pending; 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) { + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to set default netif: %s", esp_err_to_name(err)); + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + want_eth_takeover = true; + xSemaphoreGive(connIpSemaphoreHandle); + return; + } + + if (do_mac_unify) { + // Suppress WiFi before applying unified MAC to prevent MAC flapping + wifi_suppress_for_takeover(); + esp_wifi_disconnect(); + vTaskDelay(pdMS_TO_TICKS(100)); + + err = eth_apply_unified_mac(netif); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to apply unified MAC: %s, restoring WiFi", + esp_err_to_name(err)); + wifi_clear_suppression(true); + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + want_eth_takeover = true; + xSemaphoreGive(connIpSemaphoreHandle); + return; + } + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); we_changed_default_netif = true; + mac_unification_pending = false; + mac_unification_netif = NULL; xSemaphoreGive(connIpSemaphoreHandle); + + // Restart DHCP to obtain IP with unified MAC + esp_netif_dhcpc_stop(netif); + esp_netif_dhcpc_start(netif); + 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 + // No MAC unification needed - just complete takeover xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); - want_eth_takeover = true; + we_changed_default_netif = true; xSemaphoreGive(connIpSemaphoreHandle); + if (network_request_reconnect() != ESP_OK) { + ESP_LOGW(TAG, "Failed to request reconnect after takeover"); + } } } else if (want_eth_takeover && network_is_playback_active()) { ESP_LOGI(TAG, "Playback active; deferring Ethernet takeover until playback stops"); @@ -868,7 +995,7 @@ static void static_ip_task(void *pvParameters) { /** 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) { - uint8_t mac_addr[6] = {0}; + uint8_t mac_addr[ETH_ADDR_LEN] = {0}; /* we can get the ethernet driver handle from event data */ esp_eth_handle_t eth_handle = *(esp_eth_handle_t *)event_data; esp_netif_t *netif = (esp_netif_t *)arg; @@ -881,26 +1008,38 @@ 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_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)"); + // Check if MAC is already unified or will be deferred + uint8_t expected_mac[ETH_ADDR_LEN]; + if (network_get_unified_mac_internal(expected_mac) == ESP_OK) { + if (memcmp(mac_addr, expected_mac, ETH_ADDR_LEN) == 0) { + ESP_LOGI(TAG, "Ethernet MAC matches WiFi MAC (unified)"); } else { - ESP_LOGW(TAG, "Failed to create IPv6 link-local: %s (continuing)", esp_err_to_name(ipv6_err)); + ESP_LOGI(TAG, "Ethernet using default MAC (will unify when ready)"); } } - // 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"); + // Create IPv6 link-local address unconditionally. + // With deferred MAC unification, Ethernet uses its own default MAC during + // playback, so NDP traffic won't affect the switch's WiFi forwarding. + { + esp_err_t ipv6_err = esp_netif_create_ip6_linklocal(netif); + if (ipv6_err != ESP_OK) { + 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)); + } + } } + // Plan to prefer Ethernet and unify MAC once Ethernet has acquired + // an IP (after DHCP or static IP is applied). Set unconditionally + // so MAC unification happens even when ETH links up before WiFi. + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + want_eth_takeover = true; + xSemaphoreGive(connIpSemaphoreHandle); + ESP_LOGI(TAG, "Ethernet present; will unify MAC after IP acquired"); + // Handle static IP mode (spawn task instead of blocking) if (current_eth_mode == 2) { // Static xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); @@ -912,9 +1051,14 @@ static void eth_event_handler(void *arg, esp_event_base_t event_base, static_ip_task_handle = NULL; } - // Check if playback is active - defer if so + // Always defer MAC unification to avoid switch MAC flapping + // when both WiFi and Ethernet are up simultaneously + mac_unification_pending = true; + mac_unification_netif = netif; + + // Check if playback is active - defer static IP too if (network_is_playback_active()) { - ESP_LOGI(TAG, "Playback active; deferring static IP until playback stops"); + ESP_LOGI(TAG, "Playback active; deferring static IP and MAC unification until playback stops"); static_ip_pending = true; static_ip_netif = netif; static_ip_in_progress = false; @@ -952,18 +1096,15 @@ static void eth_event_handler(void *arg, esp_event_base_t event_base, } } } 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 + // DHCP mode: always defer MAC unification. Applying the unified MAC + // while WiFi is also active causes MAC flapping on the switch (same + // MAC seen on two ports), leading to packet loss and TCP resets. + // MAC will be unified during takeover when traffic shifts to Ethernet. + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + mac_unification_pending = true; + mac_unification_netif = netif; + xSemaphoreGive(connIpSemaphoreHandle); + ESP_LOGI(TAG, "DHCP mode: MAC unification deferred until takeover..."); } break; @@ -988,6 +1129,23 @@ static void eth_event_handler(void *arg, esp_event_base_t event_base, static_ip_pending = false; static_ip_netif = NULL; + // Reset MAC unification state on disconnect + mac_unification_pending = false; + mac_unification_netif = NULL; + + // Revert Ethernet to temp MAC so next Link Up doesn't have the same + // MAC as WiFi (which causes switch MAC flapping on both ports) + { + uint8_t temp_mac[ETH_ADDR_LEN]; + if (esp_read_mac(temp_mac, ESP_MAC_ETH) == ESP_OK) { + esp_eth_ioctl(eth_handle, ETH_CMD_S_MAC_ADDR, temp_mac); + esp_netif_set_mac(netif, temp_mac); + ESP_LOGD(TAG, "Reverted to temp MAC: %02X:%02X:%02X:%02X:%02X:%02X", + temp_mac[0], temp_mac[1], temp_mac[2], + temp_mac[3], temp_mac[4], temp_mac[5]); + } + } + // 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) { @@ -1001,7 +1159,19 @@ static void eth_event_handler(void *arg, esp_event_base_t event_base, 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 + eth_got_ip_time = 0; // Reset grace period timer xSemaphoreGive(connIpSemaphoreHandle); + + // Re-enable WiFi if it was suppressed during takeover + if (wifi_is_suppressed()) { + wifi_clear_suppression(true); + } + + // Reset default netif to WiFi so setup_network() uses it + esp_netif_t *sta_netif = network_get_netif_from_desc(NETWORK_INTERFACE_DESC_STA); + if (sta_netif) { + esp_netif_set_default_netif(sta_netif); + } /* 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"); @@ -1012,6 +1182,7 @@ static void eth_event_handler(void *arg, esp_event_base_t event_base, * actually completed takeover (handled above). */ ESP_LOGD(TAG, "Ethernet disconnected before takeover completed, preserving intent"); + eth_got_ip_time = 0; // Reset grace period timer xSemaphoreGive(connIpSemaphoreHandle); } @@ -1063,6 +1234,9 @@ static void got_ip_event_handler(void *arg, esp_event_base_t event_base, if (network_is_our_netif(if_desc_str, event->esp_netif)) { xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + // Record timestamp when Ethernet got IP for grace period enforcement + eth_got_ip_time = esp_timer_get_time(); + memcpy((void *)&ip_info, (const void *)&event->ip_info, sizeof(esp_netif_ip_info_t)); connected = true; @@ -1105,6 +1279,29 @@ bool eth_get_ip(esp_netif_ip_info_t *ip) { return _connected; } +/** + * @brief Check if Ethernet takeover is pending (linked up, waiting for IP) + * + * Used by connection_handler to avoid committing to WiFi when Ethernet + * is about to acquire an IP and take over as the preferred interface. + */ +bool eth_is_takeover_pending(void) { + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + bool pending = want_eth_takeover && !we_changed_default_netif; + xSemaphoreGive(connIpSemaphoreHandle); + return pending; +} + +/** + * @brief Check if Ethernet is enabled in configuration + * + * Used by connection_handler to decide whether to wait briefly for + * Ethernet link-up before committing to WiFi at boot. + */ +bool eth_is_enabled(void) { + return current_eth_mode != 0; +} + /** * @brief Handle playback stopped event * @@ -1121,11 +1318,20 @@ static void eth_on_playback_stopped(void) { bool do_takeover = false; bool do_static_ip = false; + bool do_mac_unify = false; esp_netif_t *pending_netif = NULL; xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); - // Check for pending static IP configuration first (takes priority over takeover) + // Check for pending MAC unification (always process first) + if (mac_unification_pending && mac_unification_netif) { + do_mac_unify = true; + pending_netif = mac_unification_netif; + mac_unification_pending = false; + mac_unification_netif = NULL; + } + + // Check for pending static IP configuration (takes priority over takeover) if (static_ip_pending && static_ip_netif && !static_ip_in_progress) { do_static_ip = true; pending_netif = static_ip_netif; @@ -1133,13 +1339,86 @@ static void eth_on_playback_stopped(void) { 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) { + // ✓ Re-verify playback is not active - protects against playback resuming between + // the time the STOPPED event was set and this function executes + else if (want_eth_takeover && connected && !we_changed_default_netif && + !network_is_playback_active()) { do_takeover = true; want_eth_takeover = false; } xSemaphoreGive(connIpSemaphoreHandle); + ESP_LOGI(TAG, "eth_on_playback_stopped: mac_unify=%d static_ip=%d takeover=%d", + do_mac_unify, do_static_ip, do_takeover); + + // Handle pending MAC unification (deferred during playback) + if (do_mac_unify) { + // Clear takeover flag — we're handling the transition via mac_unify path. + // Without this, eth_is_takeover_pending() returns true during the handover, + // causing setup_network() to waste time in the "waiting for ETH" loop. + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + want_eth_takeover = false; + xSemaphoreGive(connIpSemaphoreHandle); + + // Step 1: Request reconnect FIRST so the main loop cleanly closes the + // old TCP connection (netconn_close + netconn_delete) before we kill WiFi. + // Without this, esp_wifi_disconnect() kills the TCP abruptly and the + // server may not detect the disconnect before our new HELLO arrives. + if (do_takeover) { + esp_err_t err = esp_netif_set_default_netif(pending_netif); + if (err == ESP_OK) { + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + we_changed_default_netif = true; + xSemaphoreGive(connIpSemaphoreHandle); + } else { + ESP_LOGE(TAG, "Failed to set default netif: %s", esp_err_to_name(err)); + } + } + + if (network_request_reconnect() != ESP_OK) { + ESP_LOGW(TAG, "Failed to request reconnect"); + } + + // Wait for main loop to close old connection (2s) + server cleanup + vTaskDelay(pdMS_TO_TICKS(2500)); + + // Now safe to suppress WiFi and apply unified MAC + ESP_LOGI(TAG, "Playback stopped: unifying MAC on Ethernet"); + wifi_suppress_for_takeover(); + esp_wifi_disconnect(); + vTaskDelay(pdMS_TO_TICKS(100)); + + esp_err_t mac_err = eth_apply_unified_mac(pending_netif); + if (mac_err != ESP_OK) { + ESP_LOGE(TAG, "Failed to apply unified MAC: %s, restoring WiFi", + esp_err_to_name(mac_err)); + wifi_clear_suppression(true); + return; + } + + if (do_takeover) { + esp_netif_dhcpc_stop(pending_netif); + esp_netif_dhcpc_start(pending_netif); + return; + } + + if (!do_static_ip) { + esp_err_t err = esp_netif_set_default_netif(pending_netif); + if (err == ESP_OK) { + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + we_changed_default_netif = true; + xSemaphoreGive(connIpSemaphoreHandle); + } else { + ESP_LOGE(TAG, "Failed to set default netif: %s", esp_err_to_name(err)); + } + esp_netif_dhcpc_stop(pending_netif); + esp_netif_dhcpc_start(pending_netif); + return; + } + // Static IP mode: fall through to static IP handling below + } + // Handle pending static IP configuration if (do_static_ip) { ESP_LOGI(TAG, "Playback stopped: starting deferred static IP configuration"); @@ -1174,6 +1453,15 @@ static void eth_on_playback_stopped(void) { 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) { + // Verify Ethernet has IP immediately to avoid race with disconnect event + if (!network_has_ip(eth_netif)) { + ESP_LOGD(TAG, "eth_on_playback_stopped: Ethernet has no IP, aborting takeover"); + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + want_eth_takeover = false; + xSemaphoreGive(connIpSemaphoreHandle); + return; + } + esp_err_t err = esp_netif_set_default_netif(eth_netif); if (err == ESP_OK) { xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); @@ -1245,6 +1533,9 @@ void eth_start(void) { return; } + // Save handles for deferred MAC unification + s_eth_handles = eth_handles; + #if CONFIG_SNAPCLIENT_USE_INTERNAL_ETHERNET || CONFIG_SNAPCLIENT_USE_SPI_ETHERNET esp_netif_t *eth_netif = NULL; @@ -1279,6 +1570,7 @@ void eth_start(void) { 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 @@ -1344,6 +1636,7 @@ void eth_start(void) { eth_auto_disable_and_persist(); return; } + } } diff --git a/components/network_interface/include/eth_interface.h b/components/network_interface/include/eth_interface.h index 37f5ef5a..35bc7750 100644 --- a/components/network_interface/include/eth_interface.h +++ b/components/network_interface/include/eth_interface.h @@ -7,6 +7,8 @@ extern "C" { #endif bool eth_get_ip(esp_netif_ip_info_t *ip); +bool eth_is_takeover_pending(void); +bool eth_is_enabled(void); void eth_start(void); #ifdef __cplusplus diff --git a/components/network_interface/network_interface.c b/components/network_interface/network_interface.c index c80bf356..94e10b59 100644 --- a/components/network_interface/network_interface.c +++ b/components/network_interface/network_interface.c @@ -15,12 +15,14 @@ #include #include "esp_event.h" +#include "esp_eth.h" #include "esp_log.h" #include "esp_mac.h" #include "esp_netif.h" #include "esp_wifi_types.h" #include "freertos/FreeRTOS.h" #include "freertos/event_groups.h" +#include "freertos/semphr.h" #include "freertos/task.h" #include "sdkconfig.h" @@ -30,9 +32,15 @@ #endif #include "wifi_interface.h" +#include "../lightsnapcast/include/player.h" static const char *TAG = "NET_IF"; +/* ============ Shared MAC Address for WiFi and Ethernet ============ */ +static uint8_t unified_mac_address[ETH_ADDR_LEN] = {0}; +static bool unified_mac_initialized = false; +static SemaphoreHandle_t mac_mutex = NULL; + /* ============ Event Group for Inter-Component Coordination ============ */ #define EVENT_RECONNECT_REQUESTED_BIT BIT0 #define EVENT_PLAYBACK_STARTED_BIT BIT1 @@ -115,11 +123,18 @@ esp_err_t network_playback_stopped(void) { } bool network_is_playback_active(void) { - if (!network_event_group) { - return false; + // Primary check: Event bits (fast path, set by Layer 1 synchronously) + if (network_event_group) { + EventBits_t bits = xEventGroupGetBits(network_event_group); + if (bits & EVENT_PLAYBACK_STARTED_BIT) { + return true; + } } - EventBits_t bits = xEventGroupGetBits(network_event_group); - return (bits & EVENT_PLAYBACK_STARTED_BIT) != 0; + + // Fallback: Direct player state check (catches edge cases) + // This protects against race conditions if event bit isn't set yet + player_state_e state = get_player_state(); + return (state == PLAYING); } /* types of ipv6 addresses to be displayed on ipv6 events */ @@ -185,6 +200,58 @@ bool network_has_ip(esp_netif_t *esp_netif) { #endif } +/** + * @brief Get the unified MAC address for all network interfaces (thread-safe) + * + * Reads WiFi's MAC address from eFuse on first call and caches it. + * All network interfaces (WiFi and Ethernet) should use this MAC. + * + * Thread Safety: Uses mutex to protect initialization and read operations. + * Safe to call from multiple threads concurrently. + * + * @param[out] mac_out Buffer to receive ETH_ADDR_LEN-byte MAC address. Must not be NULL. + * @return ESP_OK on success, ESP_ERR_INVALID_ARG if mac_out is NULL, + * ESP_ERR_NO_MEM if mutex creation fails + */ +esp_err_t network_get_unified_mac_internal(uint8_t *mac_out) { + if (!mac_out) { + return ESP_ERR_INVALID_ARG; + } + + // Create mutex on first use (happens once during early boot before multithreading) + if (!mac_mutex) { + mac_mutex = xSemaphoreCreateMutex(); + if (!mac_mutex) { + ESP_LOGE(TAG, "Failed to create MAC mutex"); + return ESP_ERR_NO_MEM; + } + } + + // Acquire mutex for thread-safe access + xSemaphoreTake(mac_mutex, portMAX_DELAY); + + if (!unified_mac_initialized) { + esp_err_t err = esp_read_mac(unified_mac_address, ESP_MAC_WIFI_STA); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to read WiFi MAC address: %s", esp_err_to_name(err)); + xSemaphoreGive(mac_mutex); + return err; + } + + ESP_LOGI(TAG, "Unified MAC address: %02X:%02X:%02X:%02X:%02X:%02X", + unified_mac_address[0], unified_mac_address[1], + unified_mac_address[2], unified_mac_address[3], + unified_mac_address[4], unified_mac_address[5]); + + unified_mac_initialized = true; + } + + memcpy(mac_out, unified_mac_address, ETH_ADDR_LEN); + + xSemaphoreGive(mac_mutex); + return ESP_OK; +} + bool network_if_get_ip(esp_netif_ip_info_t *ip) { #if CONFIG_SNAPCLIENT_USE_INTERNAL_ETHERNET || \ CONFIG_SNAPCLIENT_USE_SPI_ETHERNET @@ -204,6 +271,14 @@ void network_if_init(void) { esp_netif_init(); ESP_ERROR_CHECK(esp_event_loop_create_default()); + // Initialize unified MAC address before starting any network interfaces + uint8_t mac[ETH_ADDR_LEN]; + esp_err_t err = network_get_unified_mac_internal(mac); + if (err != ESP_OK) { + ESP_LOGE(TAG, "CRITICAL: Failed to initialize unified MAC address"); + ESP_ERROR_CHECK(err); // Fatal error - cannot proceed without MAC + } + #if CONFIG_SNAPCLIENT_USE_INTERNAL_ETHERNET || \ CONFIG_SNAPCLIENT_USE_SPI_ETHERNET eth_start(); diff --git a/components/network_interface/priv_include/network_interface_priv.h b/components/network_interface/priv_include/network_interface_priv.h index 3084a854..8ab018ff 100644 --- a/components/network_interface/priv_include/network_interface_priv.h +++ b/components/network_interface/priv_include/network_interface_priv.h @@ -32,4 +32,16 @@ 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); +/** Get unified MAC address for all network interfaces */ +esp_err_t network_get_unified_mac_internal(uint8_t *mac_out); + +/** Suppress WiFi auto-reconnect during Ethernet MAC takeover */ +void wifi_suppress_for_takeover(void); + +/** Clear WiFi suppression, optionally triggering reconnect */ +void wifi_clear_suppression(bool reconnect); + +/** Check if WiFi is currently suppressed for takeover */ +bool wifi_is_suppressed(void); + #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 2ac0228b..76f1a390 100644 --- a/components/network_interface/wifi_interface.c +++ b/components/network_interface/wifi_interface.c @@ -9,6 +9,7 @@ #include // for memcpy #include "esp_event.h" +#include "esp_eth.h" #include "esp_log.h" #include "esp_mac.h" #include "esp_netif_types.h" @@ -76,6 +77,7 @@ static esp_netif_t *esp_wifi_netif = NULL; static esp_netif_ip_info_t ip_info = {{0}, {0}, {0}}; static bool connected = false; static SemaphoreHandle_t connIpSemaphoreHandle = NULL; +static bool wifi_suppressed_for_takeover = false; /* The event group allows multiple bits for each event, but we only care about one event - are we connected @@ -88,11 +90,13 @@ static void event_handler(void *arg, esp_event_base_t event_base, int event_id, esp_wifi_connect(); } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { - if ((s_retry_num < WIFI_MAXIMUM_RETRY) || (WIFI_MAXIMUM_RETRY == 0)) { - xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); - connected = false; - xSemaphoreGive(connIpSemaphoreHandle); + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + connected = false; + xSemaphoreGive(connIpSemaphoreHandle); + if (wifi_suppressed_for_takeover) { + ESP_LOGI(TAG, "WiFi disconnected (suppressed for ETH takeover, not reconnecting)"); + } else if ((s_retry_num < WIFI_MAXIMUM_RETRY) || (WIFI_MAXIMUM_RETRY == 0)) { esp_wifi_connect(); s_retry_num++; ESP_LOGV(TAG, "retry to connect to the AP"); @@ -133,6 +137,14 @@ static void got_ip_event_handler(void *arg, esp_event_base_t event_base, xSemaphoreGive(connIpSemaphoreHandle); + // Log WiFi MAC for verification + uint8_t wifi_mac[ETH_ADDR_LEN]; + if (network_get_unified_mac_internal(wifi_mac) == ESP_OK) { + ESP_LOGI(TAG, "WiFi MAC: %02X:%02X:%02X:%02X:%02X:%02X", + wifi_mac[0], wifi_mac[1], wifi_mac[2], + wifi_mac[3], wifi_mac[4], wifi_mac[5]); + } + ESP_LOGI(TAG, "Wifi Got IP Address"); ESP_LOGI(TAG, "~~~~~~~~~~~"); ESP_LOGI(TAG, "WIFIIP:" IPSTR, IP2STR(&ip_info.ip)); @@ -178,6 +190,24 @@ bool wifi_get_ip(esp_netif_ip_info_t *ip) { return _connected; } +void wifi_suppress_for_takeover(void) { + wifi_suppressed_for_takeover = true; + ESP_LOGI(TAG, "WiFi suppressed for Ethernet takeover"); +} + +void wifi_clear_suppression(bool reconnect) { + wifi_suppressed_for_takeover = false; + ESP_LOGI(TAG, "WiFi suppression cleared (reconnect=%d)", reconnect); + if (reconnect) { + s_retry_num = 0; + esp_wifi_connect(); + } +} + +bool wifi_is_suppressed(void) { + return wifi_suppressed_for_takeover; +} + /** */ void wifi_start(void) { diff --git a/components/ui_http_server/html/general-settings.html b/components/ui_http_server/html/general-settings.html index 3714958a..dc71abe0 100644 --- a/components/ui_http_server/html/general-settings.html +++ b/components/ui_http_server/html/general-settings.html @@ -189,6 +189,7 @@

General Settings

let currentEthDns = ''; let ethAvailable = true; // whether Ethernet settings should be shown let isDirty = false; + let needsRestart = false; // Load current settings async function loadSettings() { diff --git a/main/connection_handler.c b/main/connection_handler.c index c07a1915..5efd3762 100644 --- a/main/connection_handler.c +++ b/main/connection_handler.c @@ -1,6 +1,9 @@ #include "connection_handler.h" #include "esp_log.h" +#if CONFIG_SNAPCLIENT_USE_INTERNAL_ETHERNET || CONFIG_SNAPCLIENT_USE_SPI_ETHERNET +#include "eth_interface.h" +#endif #include "lwip/err.h" #include "lwip/netdb.h" #include "mdns.h" @@ -18,6 +21,10 @@ void setup_network(esp_netif_t** netif) { uint16_t remotePort = 0; while (1) { + // Reset netif to ensure clean state for each connection attempt + // This prevents carrying over interface selection from previous failed attempts + *netif = NULL; + if (lwipNetconn != NULL) { netconn_delete(lwipNetconn); lwipNetconn = NULL; @@ -31,23 +38,83 @@ void setup_network(esp_netif_t** netif) { #endif esp_netif_t* sta_netif = network_get_netif_from_desc(NETWORK_INTERFACE_DESC_STA); +#if CONFIG_SNAPCLIENT_USE_INTERNAL_ETHERNET || \ + CONFIG_SNAPCLIENT_USE_SPI_ETHERNET + int eth_wait_count = 0; +#endif 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)) { #if CONFIG_SNAPCLIENT_USE_INTERNAL_ETHERNET || \ CONFIG_SNAPCLIENT_USE_SPI_ETHERNET - bool ethUp = network_is_netif_up(eth_netif); + // If WiFi has IP but Ethernet takeover is pending (ETH linked up, + // waiting for DHCP), wait for ETH instead of connecting via WiFi. + // This avoids a connect-disconnect-reconnect cycle at boot. + if (default_netif != eth_netif && eth_is_takeover_pending()) { + ESP_LOGI(TAG, "WiFi ready but Ethernet takeover pending, waiting for ETH..."); + vTaskDelay(pdMS_TO_TICKS(1000)); + continue; + } + // Ethernet is enabled but hasn't linked up yet - give it a brief + // grace period before committing to WiFi. Without this, WiFi gets + // used first and the subsequent ETH takeover causes a 60s TCP timeout. + if (default_netif != eth_netif && eth_is_enabled() && + !network_has_ip(eth_netif)) { + if (eth_wait_count < 3) { + eth_wait_count++; + ESP_LOGI(TAG, "WiFi ready, waiting for Ethernet link-up (%d/3)...", eth_wait_count); + vTaskDelay(pdMS_TO_TICKS(1000)); + continue; + } + ESP_LOGI(TAG, "Ethernet not up after grace period, proceeding with WiFi"); + eth_wait_count = 0; + } +#endif + *netif = default_netif; + ESP_LOGI(TAG, "Using default netif: %s", network_get_ifkey(*netif)); + break; + } +#if CONFIG_SNAPCLIENT_USE_INTERNAL_ETHERNET || \ + CONFIG_SNAPCLIENT_USE_SPI_ETHERNET + else if (default_netif == eth_netif) { + // Ethernet was explicitly set as default (takeover in progress) + // but DHCP hasn't completed yet. Wait for it — WiFi won't work + // because the switch learned our MAC on the Ethernet port. + ESP_LOGI(TAG, "Default netif %s waiting for IP...", + network_get_ifkey(default_netif)); + vTaskDelay(pdMS_TO_TICKS(1000)); + continue; + } +#endif + // For WiFi or other default netif without IP, fall through to + // normal Ethernet-priority check below + } - if (ethUp) { - *netif = eth_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 + bool ethUp = network_has_ip(eth_netif); - break; - } + if (ethUp) { + *netif = eth_netif; + ESP_LOGI(TAG, "Using Ethernet interface"); + break; + } #endif - bool staUp = network_is_netif_up(sta_netif); - if (staUp) { - *netif = sta_netif; - - break; + bool staUp = network_has_ip(sta_netif); + if (staUp) { + *netif = sta_netif; + ESP_LOGI(TAG, "Using WiFi interface"); + break; + } } vTaskDelay(pdMS_TO_TICKS(1000)); @@ -72,10 +139,10 @@ void setup_network(esp_netif_t** netif) { #endif if (use_mdns) { -#if CONFIG_SNAPSERVER_USE_MDNS - ESP_LOGI(TAG, "Enable mdns"); - mdns_init(); -#endif + // mDNS is already initialized by net_mdns_register() in app_main(). + // Do NOT call mdns_init() here - repeated calls on reconnect corrupt + // the multicast socket state and cause query failures. + // Find snapcast server via mDNS mdns_result_t* r = NULL; esp_err_t err = 0; @@ -107,14 +174,20 @@ void setup_network(esp_netif_t** netif) { } #if CONFIG_SNAPCLIENT_CONNECT_IPV6 if (a->addr.type == IPADDR_TYPE_V6) { - *netif = re->esp_netif; + // Found valid IPv6 address - use it with already-selected interface + // Do NOT overwrite *netif with re->esp_netif! + // Interface was selected in setup_network() based on Ethernet priority. + // The mDNS result only tells us the server IP, not which interface to use. break; } // TODO: fall back to IPv4 if no IPv6 was available #else if (a->addr.type == IPADDR_TYPE_V4) { - *netif = re->esp_netif; + // Found valid IPv4 address - use it with already-selected interface + // Do NOT overwrite *netif with re->esp_netif! + // Interface was selected in setup_network() based on Ethernet priority. + // The mDNS result only tells us the server IP, not which interface to use. break; } #endif @@ -285,30 +358,10 @@ static int receive_data(struct netbuf** firstNetBuf, bool isMuted, break; } -#if CONFIG_SNAPCLIENT_USE_INTERNAL_ETHERNET || \ - CONFIG_SNAPCLIENT_USE_SPI_ETHERNET - if (isMuted) { - esp_netif_t* eth_netif = - network_get_netif_from_desc(NETWORK_INTERFACE_DESC_ETH); - - if (netif != eth_netif) { - bool ethUp = network_is_netif_up(eth_netif); - - if (ethUp) { - netconn_close(lwipNetconn); - - if (*firstNetBuf != NULL) { - netbuf_delete(*firstNetBuf); - - *firstNetBuf = NULL; - } - - // restart and try to reconnect using preferred interface ETH - return -1; - } - } - } -#endif + // Ethernet preference is handled by eth_interface.c takeover system. + // It sets the default netif and requests reconnect when ready. + // The old muted-check-and-force-restart logic was removed because it + // conflicts with the takeover system and causes infinite reconnect loops. return 0; } diff --git a/main/main.c b/main/main.c index 4f0690b7..85e3e0f5 100644 --- a/main/main.c +++ b/main/main.c @@ -1139,9 +1139,11 @@ void before_receive_callback(before_receive_callback_data_t *data) { void network_playback_state_changed() { player_state_e state = get_player_state(); - if (state == PLAYING) { + if (state == PLAYING || state == PAUSED) { + // Both PLAYING and PAUSED are active sessions - keep network up network_playback_started(); } else { + // Only IDLE state means playback truly stopped network_playback_stopped(); } } @@ -1220,6 +1222,13 @@ static void http_get_task(void *pvParameters) { } } + // If a reconnect was requested but the inner loop exited via TCP error + // (-2) instead, the server still needs time to tear down the old session. + if (network_check_and_clear_reconnect()) { + ESP_LOGD(TAG, "Pending reconnect; waiting 2s for server cleanup"); + vTaskDelay(pdMS_TO_TICKS(2000)); + } + // NETWORK setup ends here ( or before getting mac address ) setup_network(&connection.netif); @@ -1232,10 +1241,16 @@ static void http_get_task(void *pvParameters) { uint8_t base_mac[6]; #if CONFIG_SNAPCLIENT_USE_INTERNAL_ETHERNET || \ CONFIG_SNAPCLIENT_USE_SPI_ETHERNET - // Get MAC address for Eth Interface + // Get runtime MAC address for Eth Interface (reflects unified MAC if applied) char eth_mac_address[18]; - - esp_read_mac(base_mac, ESP_MAC_ETH); + { + esp_netif_t *eth_nif = network_get_netif_from_desc(NETWORK_INTERFACE_DESC_ETH); + if (eth_nif && esp_netif_get_mac(eth_nif, base_mac) == ESP_OK) { + // Use runtime MAC from netif (matches what's on the wire) + } else { + esp_read_mac(base_mac, ESP_MAC_ETH); // fallback to eFuse + } + } sprintf(eth_mac_address, "%02X:%02X:%02X:%02X:%02X:%02X", base_mac[0], base_mac[1], base_mac[2], base_mac[3], base_mac[4], base_mac[5]); ESP_LOGI(TAG, "eth mac: %s", eth_mac_address); @@ -1347,17 +1362,23 @@ static void http_get_task(void *pvParameters) { netconn_set_recvtimeout(lwipNetconn, time_sync_data.timeout / 1000); // timeout in ms + // Drain any reconnect request that arrived while we were connecting + // (e.g., boot-time MAC unification fires reconnect during mDNS/TCP setup). + // No delay needed: no prior server session exists to clean up. + network_check_and_clear_reconnect(); + // Main connection loop - state machine + data processing bool paused = false; 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"); - // 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)); + ESP_LOGI(TAG, "Reconnect requested, closing connection"); + if (lwipNetconn != NULL) { + netconn_close(lwipNetconn); + netconn_delete(lwipNetconn); + lwipNetconn = NULL; + } + vTaskDelay(pdMS_TO_TICKS(2000)); // let server clean up break; } From 3d5841f39d2e8e06cac562d23cf53fb67daaca91 Mon Sep 17 00:00:00 2001 From: craigmillard86 Date: Wed, 25 Feb 2026 00:12:43 +0000 Subject: [PATCH 11/28] Fix correctness bugs from code review and Copilot findings - Restore WiFi as default netif when MAC unification fails (both immediate and deferred takeover paths) to prevent broken routing - Remove 100ms vTaskDelay that blocked the ESP system event loop task - Add missing network_playback_stopped() on start_player failure paths so the network layer doesn't think playback is still active - Mark wifi_suppressed_for_takeover as volatile for cross-task visibility - Remove shadowed 'paused' variable in http_get_task inner scope - Remove 0.0.0.0 from JS validMasks to match C-side netmask rejection --- components/lightsnapcast/player.c | 2 ++ components/network_interface/eth_interface.c | 13 ++++++++++++- components/network_interface/wifi_interface.c | 2 +- .../ui_http_server/html/general-settings.html | 2 +- main/main.c | 2 +- 5 files changed, 17 insertions(+), 4 deletions(-) diff --git a/components/lightsnapcast/player.c b/components/lightsnapcast/player.c index ad43555c..5208daa7 100644 --- a/components/lightsnapcast/player.c +++ b/components/lightsnapcast/player.c @@ -600,6 +600,7 @@ int start_player(snapcastSetting_t *setting) { #endif tg0_timer_deinit(); playerStarted = false; + network_playback_stopped(); call_state_cb(); return -1; } @@ -622,6 +623,7 @@ int start_player(snapcastSetting_t *setting) { #endif tg0_timer_deinit(); playerStarted = false; + network_playback_stopped(); call_state_cb(); return -1; } diff --git a/components/network_interface/eth_interface.c b/components/network_interface/eth_interface.c index ea31a2c7..4d6f9acb 100644 --- a/components/network_interface/eth_interface.c +++ b/components/network_interface/eth_interface.c @@ -840,12 +840,15 @@ static void eth_check_and_apply_takeover(esp_netif_t *netif) { // Suppress WiFi before applying unified MAC to prevent MAC flapping wifi_suppress_for_takeover(); esp_wifi_disconnect(); - vTaskDelay(pdMS_TO_TICKS(100)); err = eth_apply_unified_mac(netif); if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to apply unified MAC: %s, restoring WiFi", esp_err_to_name(err)); + esp_netif_t *sta_netif = network_get_netif_from_desc(NETWORK_INTERFACE_DESC_STA); + if (sta_netif) { + esp_netif_set_default_netif(sta_netif); + } wifi_clear_suppression(true); xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); want_eth_takeover = true; @@ -1394,6 +1397,14 @@ static void eth_on_playback_stopped(void) { ESP_LOGE(TAG, "Failed to apply unified MAC: %s, restoring WiFi", esp_err_to_name(mac_err)); wifi_clear_suppression(true); + esp_netif_t *sta_netif = network_get_netif_from_desc(NETWORK_INTERFACE_DESC_STA); + if (sta_netif) { + esp_netif_set_default_netif(sta_netif); + } + xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY); + we_changed_default_netif = false; + want_eth_takeover = true; // allow retry + xSemaphoreGive(connIpSemaphoreHandle); return; } diff --git a/components/network_interface/wifi_interface.c b/components/network_interface/wifi_interface.c index 76f1a390..9914edfc 100644 --- a/components/network_interface/wifi_interface.c +++ b/components/network_interface/wifi_interface.c @@ -77,7 +77,7 @@ static esp_netif_t *esp_wifi_netif = NULL; static esp_netif_ip_info_t ip_info = {{0}, {0}, {0}}; static bool connected = false; static SemaphoreHandle_t connIpSemaphoreHandle = NULL; -static bool wifi_suppressed_for_takeover = false; +static volatile bool wifi_suppressed_for_takeover = false; /* The event group allows multiple bits for each event, but we only care about one event - are we connected diff --git a/components/ui_http_server/html/general-settings.html b/components/ui_http_server/html/general-settings.html index dc71abe0..b02910ec 100644 --- a/components/ui_http_server/html/general-settings.html +++ b/components/ui_http_server/html/general-settings.html @@ -494,7 +494,7 @@

General Settings

'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' + '240.0.0.0', '224.0.0.0', '192.0.0.0', '128.0.0.0' ]; if (!validMasks.includes(mask)) { diff --git a/main/main.c b/main/main.c index 85e3e0f5..5f5dbd16 100644 --- a/main/main.c +++ b/main/main.c @@ -1368,7 +1368,7 @@ static void http_get_task(void *pvParameters) { network_check_and_clear_reconnect(); // Main connection loop - state machine + data processing - bool paused = false; + paused = false; while (1) { // Check if external module requested reconnect (e.g., ethernet takeover) if (network_check_and_clear_reconnect()) { From d4923a9fa7af8ad2d41144f5896907fb747f4182 Mon Sep 17 00:00:00 2001 From: raul Date: Sat, 28 Feb 2026 16:40:01 +0100 Subject: [PATCH 12/28] Keep volume in settings struct --- components/lightsnapcast/include/player.h | 1 + main/main.c | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/components/lightsnapcast/include/player.h b/components/lightsnapcast/include/player.h index 3e598319..73e71bfc 100644 --- a/components/lightsnapcast/include/player.h +++ b/components/lightsnapcast/include/player.h @@ -62,6 +62,7 @@ typedef struct snapcastSetting_s { i2s_data_bit_width_t bits; bool muted; + uint32_t volume; char *pcmBuf; uint32_t pcmBufSize; diff --git a/main/main.c b/main/main.c index 3cc42527..e7cfe508 100644 --- a/main/main.c +++ b/main/main.c @@ -527,7 +527,6 @@ int init_snapcast(void (*set_volume)(int), void (*set_mute)(bool)) { int server_settings_msg_received( server_settings_message_t *server_settings_message, snapcastSetting_t *scSet) { - static int volume = 0; // log mute state, buffer, latency ESP_LOGI(TAG, "Buffer length: %ld", server_settings_message->buffer_ms); ESP_LOGI(TAG, "Latency: %ld", server_settings_message->latency); @@ -547,7 +546,7 @@ int server_settings_msg_received( set_mute_cb(server_settings_message->muted); } - if (volume != server_settings_message->volume) { + if (scSet->volume != server_settings_message->volume) { #if SNAPCAST_USE_SOFT_VOL if (!server_settings_message->muted) { dsp_processor_set_volome((double)server_settings_message->volume / 100); @@ -558,7 +557,7 @@ int server_settings_msg_received( } scSet->muted = server_settings_message->muted; - volume = server_settings_message->volume; + scSet->volume = server_settings_message->volume; if (scSet->cDacLat_ms != server_settings_message->latency || scSet->buf_ms != server_settings_message->buffer_ms) { From 61a54a7d731ffd88f31fdea5a38b7e9e36ffab02 Mon Sep 17 00:00:00 2001 From: raul Date: Sat, 28 Feb 2026 16:48:50 +0100 Subject: [PATCH 13/28] Move log message so it does not spam the logs when paused --- components/lightsnapcast/snapcast_protocol_parser.c | 2 -- main/main.c | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/lightsnapcast/snapcast_protocol_parser.c b/components/lightsnapcast/snapcast_protocol_parser.c index 19a7d3a1..53944ffe 100644 --- a/components/lightsnapcast/snapcast_protocol_parser.c +++ b/components/lightsnapcast/snapcast_protocol_parser.c @@ -327,7 +327,5 @@ parser_return_state_t parser_skip_typed_message(snapcast_protocol_parser_t* pars if (!read_byte(parser, &dummy_byte)) return PARSER_RESTART_CONNECTION; } - ESP_LOGI(TAG, "done skipping typed message %d", base_message_rx->type); - return PARSER_OK; } diff --git a/main/main.c b/main/main.c index 21756435..da25aa01 100644 --- a/main/main.c +++ b/main/main.c @@ -1048,6 +1048,8 @@ int process_data(snapcast_protocol_parser_t *parser, if (parser_skip_typed_message(parser, &base_message_rx) != PARSER_OK) { return -1; } + + ESP_LOGI(TAG, "done skipping typed message %d", base_message_rx.type); return 0; } } From 029bf60113c350c7c51fb83e2ef0d4f3196fcc20 Mon Sep 17 00:00:00 2001 From: raul Date: Sat, 28 Feb 2026 20:13:15 +0100 Subject: [PATCH 14/28] fix --- main/main.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/main/main.c b/main/main.c index da25aa01..19941abf 100644 --- a/main/main.c +++ b/main/main.c @@ -569,8 +569,9 @@ void server_settings_msg_received( "Failed to notify sync task. " "Did you init player?"); - // critical error - esp_restart(); + // critical error + esp_restart(); + } } } From b3c95321c6b7d6788072ca07884851c47e5aade6 Mon Sep 17 00:00:00 2001 From: raul Date: Sat, 28 Feb 2026 23:52:26 +0100 Subject: [PATCH 15/28] Set CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=2 --- components/lightsnapcast/player.c | 21 +++++++-------------- main/main.c | 7 ------- sdkconfig.defaults | 1 + sdkconfig_MAX98357A_ESP32-S2 | 2 +- sdkconfig_PCM5102A | 2 +- sdkconfig_PCM5102A_Olimex-ESP32-PoE | 2 +- sdkconfig_PCM5102A_WT32-ETH01 | 2 +- sdkconfig_TAS5805M | 2 +- sdkconfig_adau1961 | 2 +- sdkconfig_for_esp_snapserver | 2 +- sdkconfig_lyrat_v4.3 | 2 +- 11 files changed, 16 insertions(+), 29 deletions(-) diff --git a/components/lightsnapcast/player.c b/components/lightsnapcast/player.c index 255a7c02..98c2a94c 100644 --- a/components/lightsnapcast/player.c +++ b/components/lightsnapcast/player.c @@ -617,14 +617,16 @@ void pause_player(bool pause) { xSemaphoreTake(playerStateMux, portMAX_DELAY); if (pause != playerPaused) { playerPaused = pause; + xSemaphoreGive(playerStateMux); if (pause && playerTaskHandle != NULL) { xTaskNotifyGiveIndexed(playerTaskHandle, 1); } if (!pause) { call_state_cb(); // notify state change, e.g. for http task to send pcm } + } else { + xSemaphoreGive(playerStateMux); } - xSemaphoreGive(playerStateMux); } player_state_e get_player_state(void) { @@ -2180,25 +2182,16 @@ static void player_task(void *pvParameters) { "diff2Server: %llds, %lld.%lldms", uxQueueMessagesWaiting(pcmChkQHdl), sec, msec, usec); } - - dir = 0; - initialSync = 0; - - audio_set_mute(true); - my_i2s_channel_disable(tx_chan); - i2s_del_channel(tx_chan); - tx_chan = NULL; - break; } if (ulTaskNotifyTakeIndexed(1, pdTRUE, 0) == pdTRUE) { - audio_set_mute(true); - my_i2s_channel_disable(tx_chan); - i2s_del_channel(tx_chan); - tx_chan = NULL; break; } } + audio_set_mute(true); + my_i2s_channel_disable(tx_chan); + i2s_del_channel(tx_chan); + tx_chan = NULL; ret = 0; xSemaphoreTake(playerStateMux, portMAX_DELAY); xSemaphoreTake(snapcastSettingsMux, portMAX_DELAY); diff --git a/main/main.c b/main/main.c index 19941abf..a497cfea 100644 --- a/main/main.c +++ b/main/main.c @@ -1618,7 +1618,6 @@ void app_main(void) { #endif audioDACdata_t dac_data; player_state_e state = IDLE; - int counter = 0; while (1) { if (xQueueReceive(audioDACQHdl, &dac_data, pdMS_TO_TICKS(100)) == pdTRUE) { dac_control(board_handle, dac_data); @@ -1640,11 +1639,5 @@ void app_main(void) { state = state_new; } } - // test pause/play toggle every 200 loops - // counter++; - // if (counter % 200 == 0) { - // ESP_LOGI(TAG, "toggle pause: %d", state == PLAYING); - // pause_player(state == PLAYING); - // } } } diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 0bd8708b..36cb5f63 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -37,6 +37,7 @@ CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=1536 CONFIG_FREERTOS_TIMER_QUEUE_LENGTH=5 CONFIG_FREERTOS_USE_TICKLESS_IDLE=y CONFIG_FREERTOS_PLACE_FUNCTIONS_INTO_FLASH=y +CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=2 CONFIG_LOG_DEFAULT_LEVEL_NONE=y CONFIG_LOG_MAXIMUM_LEVEL_INFO=y CONFIG_LOG_COLORS=y diff --git a/sdkconfig_MAX98357A_ESP32-S2 b/sdkconfig_MAX98357A_ESP32-S2 index dd98d5dd..9235e98e 100644 --- a/sdkconfig_MAX98357A_ESP32-S2 +++ b/sdkconfig_MAX98357A_ESP32-S2 @@ -1190,7 +1190,7 @@ CONFIG_FREERTOS_TIMER_TASK_PRIORITY=1 CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=1536 CONFIG_FREERTOS_TIMER_QUEUE_LENGTH=5 CONFIG_FREERTOS_QUEUE_REGISTRY_SIZE=0 -CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=1 +CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=2 # CONFIG_FREERTOS_USE_TRACE_FACILITY is not set # CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS is not set # end of Kernel diff --git a/sdkconfig_PCM5102A b/sdkconfig_PCM5102A index f287231c..854f4065 100644 --- a/sdkconfig_PCM5102A +++ b/sdkconfig_PCM5102A @@ -1136,7 +1136,7 @@ CONFIG_FREERTOS_TIMER_TASK_PRIORITY=1 CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=1536 CONFIG_FREERTOS_TIMER_QUEUE_LENGTH=5 CONFIG_FREERTOS_QUEUE_REGISTRY_SIZE=0 -CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=1 +CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=2 # CONFIG_FREERTOS_USE_TRACE_FACILITY is not set # CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS is not set CONFIG_FREERTOS_USE_TICKLESS_IDLE=y diff --git a/sdkconfig_PCM5102A_Olimex-ESP32-PoE b/sdkconfig_PCM5102A_Olimex-ESP32-PoE index 7d7e5aba..314cd2d7 100644 --- a/sdkconfig_PCM5102A_Olimex-ESP32-PoE +++ b/sdkconfig_PCM5102A_Olimex-ESP32-PoE @@ -1252,7 +1252,7 @@ CONFIG_FREERTOS_TIMER_TASK_PRIORITY=1 CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=1536 CONFIG_FREERTOS_TIMER_QUEUE_LENGTH=5 CONFIG_FREERTOS_QUEUE_REGISTRY_SIZE=0 -CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=1 +CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=2 # CONFIG_FREERTOS_USE_TRACE_FACILITY is not set # CONFIG_FREERTOS_USE_LIST_DATA_INTEGRITY_CHECK_BYTES is not set # CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS is not set diff --git a/sdkconfig_PCM5102A_WT32-ETH01 b/sdkconfig_PCM5102A_WT32-ETH01 index 36f82ca9..92112d05 100644 --- a/sdkconfig_PCM5102A_WT32-ETH01 +++ b/sdkconfig_PCM5102A_WT32-ETH01 @@ -1138,7 +1138,7 @@ CONFIG_FREERTOS_TIMER_TASK_PRIORITY=1 CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=1536 CONFIG_FREERTOS_TIMER_QUEUE_LENGTH=5 CONFIG_FREERTOS_QUEUE_REGISTRY_SIZE=0 -CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=1 +CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=2 # CONFIG_FREERTOS_USE_TRACE_FACILITY is not set # CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS is not set # end of Kernel diff --git a/sdkconfig_TAS5805M b/sdkconfig_TAS5805M index 347372a1..311993de 100644 --- a/sdkconfig_TAS5805M +++ b/sdkconfig_TAS5805M @@ -1214,7 +1214,7 @@ CONFIG_FREERTOS_TIMER_TASK_PRIORITY=1 CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=1536 CONFIG_FREERTOS_TIMER_QUEUE_LENGTH=5 CONFIG_FREERTOS_QUEUE_REGISTRY_SIZE=0 -CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=1 +CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=2 # CONFIG_FREERTOS_USE_TRACE_FACILITY is not set # CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS is not set CONFIG_FREERTOS_USE_TICKLESS_IDLE=y diff --git a/sdkconfig_adau1961 b/sdkconfig_adau1961 index 7786e673..3f980c39 100644 --- a/sdkconfig_adau1961 +++ b/sdkconfig_adau1961 @@ -1147,7 +1147,7 @@ CONFIG_FREERTOS_TIMER_TASK_PRIORITY=1 CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=1536 CONFIG_FREERTOS_TIMER_QUEUE_LENGTH=5 CONFIG_FREERTOS_QUEUE_REGISTRY_SIZE=0 -CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=1 +CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=2 # CONFIG_FREERTOS_USE_TRACE_FACILITY is not set # CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS is not set CONFIG_FREERTOS_USE_TICKLESS_IDLE=y diff --git a/sdkconfig_for_esp_snapserver b/sdkconfig_for_esp_snapserver index fdcc5d9b..0637c068 100644 --- a/sdkconfig_for_esp_snapserver +++ b/sdkconfig_for_esp_snapserver @@ -1097,7 +1097,7 @@ CONFIG_FREERTOS_TIMER_TASK_PRIORITY=1 CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=1536 CONFIG_FREERTOS_TIMER_QUEUE_LENGTH=5 CONFIG_FREERTOS_QUEUE_REGISTRY_SIZE=0 -CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=1 +CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=2 # CONFIG_FREERTOS_USE_TRACE_FACILITY is not set # CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS is not set # end of Kernel diff --git a/sdkconfig_lyrat_v4.3 b/sdkconfig_lyrat_v4.3 index bc7af6c5..a4003b15 100644 --- a/sdkconfig_lyrat_v4.3 +++ b/sdkconfig_lyrat_v4.3 @@ -1184,7 +1184,7 @@ CONFIG_FREERTOS_TIMER_TASK_PRIORITY=1 CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=1536 CONFIG_FREERTOS_TIMER_QUEUE_LENGTH=5 CONFIG_FREERTOS_QUEUE_REGISTRY_SIZE=0 -CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=1 +CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES=2 # CONFIG_FREERTOS_USE_TRACE_FACILITY is not set # CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS is not set CONFIG_FREERTOS_USE_TICKLESS_IDLE=y From b6daa5967daf4db8a0731dd79bba02d26e84d670 Mon Sep 17 00:00:00 2001 From: raul Date: Sun, 1 Mar 2026 09:36:50 +0100 Subject: [PATCH 16/28] fix state cb --- components/lightsnapcast/player.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/lightsnapcast/player.c b/components/lightsnapcast/player.c index 98c2a94c..708460ec 100644 --- a/components/lightsnapcast/player.c +++ b/components/lightsnapcast/player.c @@ -621,7 +621,7 @@ void pause_player(bool pause) { if (pause && playerTaskHandle != NULL) { xTaskNotifyGiveIndexed(playerTaskHandle, 1); } - if (!pause) { + else { call_state_cb(); // notify state change, e.g. for http task to send pcm } } else { From db77bee476373a6265ece8faee0720f67e3b926a Mon Sep 17 00:00:00 2001 From: raul Date: Tue, 3 Mar 2026 23:20:35 +0100 Subject: [PATCH 17/28] fix --- components/lightsnapcast/player.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/lightsnapcast/player.c b/components/lightsnapcast/player.c index cc3e2a7f..40717f9f 100644 --- a/components/lightsnapcast/player.c +++ b/components/lightsnapcast/player.c @@ -1003,7 +1003,7 @@ static bool IRAM_ATTR timer_group0_alarm_cb( uint64_t timer_counter_value = edata->count_value; // Notify the task in the task's notification value. - xTaskNotifyFromISR(playerTaskHandle, (uint32_t)timer_counter_value, + xTaskNotifyIndexedFromISR(playerTaskHandle, 0, (uint32_t)timer_counter_value, eSetValueWithOverwrite, &xHigherPriorityTaskWoken); return xHigherPriorityTaskWoken == pdTRUE; @@ -1771,7 +1771,7 @@ static void player_task(void *pvParameters) { } // Wait to be notified of a timer interrupt. - xTaskNotifyWait(pdFALSE, // Don't clear bits on entry. + xTaskNotifyWaitIndexed(0, pdFALSE, // Don't clear bits on entry. pdFALSE, // Don't clear bits on exit. ¬ifiedValue, // Stores the notified value. portMAX_DELAY); From cf4f9845151dd3e34b02e18da93e005530ec6bb2 Mon Sep 17 00:00:00 2001 From: raul Date: Sun, 29 Mar 2026 23:19:16 +0200 Subject: [PATCH 18/28] Apply volume/mute only while playing --- main/main.c | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/main/main.c b/main/main.c index bcfa940e..4451d989 100644 --- a/main/main.c +++ b/main/main.c @@ -528,7 +528,7 @@ int init_snapcast(void (*set_volume)(int), void (*set_mute)(bool)) { */ void server_settings_msg_received( server_settings_message_t *server_settings_message, - snapcastSetting_t *scSet) { + snapcastSetting_t *scSet, bool playing) { // log mute state, buffer, latency ESP_LOGI(TAG, "Buffer length: %ld", server_settings_message->buffer_ms); ESP_LOGI(TAG, "Latency: %ld", server_settings_message->latency); @@ -537,7 +537,7 @@ void server_settings_msg_received( // Volume setting using ADF HAL // abstraction - if (scSet->muted != server_settings_message->muted) { + if (playing && scSet->muted != server_settings_message->muted) { #if SNAPCAST_USE_SOFT_VOL if (server_settings_message->muted) { dsp_processor_set_volome(0.0); @@ -548,7 +548,7 @@ void server_settings_msg_received( set_mute_cb(server_settings_message->muted); } - if (scSet->volume != server_settings_message->volume) { + if (playing && scSet->volume != server_settings_message->volume) { #if SNAPCAST_USE_SOFT_VOL if (!server_settings_message->muted) { dsp_processor_set_volome((double)server_settings_message->volume / 100); @@ -981,7 +981,7 @@ void handle_chunk_message(codec_type_t codec, snapcastSetting_t *scSet, int process_data(snapcast_protocol_parser_t *parser, time_sync_data_t *time_sync_data, bool *received_codec_header, codec_type_t *codec, snapcastSetting_t *scSet, - pcm_chunk_message_t **pcmData, bool paused) { + pcm_chunk_message_t **pcmData, player_state_e state) { base_message_t base_message_rx; if (parse_base_message(parser, &base_message_rx) != PARSER_OK) { @@ -997,7 +997,7 @@ int process_data(snapcast_protocol_parser_t *parser, wire_chunk_message_t wire_chnk = {{0, 0}, 0, NULL}; // is wire_chnk.payload ever used? // skip this wires chunk message if codec header message was not received yet! - if (*received_codec_header == false || paused) { + if (*received_codec_header == false || state == PAUSED) { if (parser_skip_typed_message(parser, &base_message_rx) != PARSER_OK) { return -1; } @@ -1034,7 +1034,7 @@ int process_data(snapcast_protocol_parser_t *parser, if (parse_sever_settings_message(parser, &base_message_rx, &server_settings_message) != PARSER_OK) { return -1; } - server_settings_msg_received(&server_settings_message, scSet); + server_settings_msg_received(&server_settings_message, scSet, state == PLAYING); return 0; } @@ -1109,7 +1109,7 @@ static void http_get_task(void *pvParameters) { codec_type_t codec = NONE; snapcastSetting_t scSet; pcm_chunk_message_t *pcmData = NULL; - bool paused = false; + player_state_e player_state = IDLE; // create a timer to send time sync messages every x µs // esp_timer_create(&tSyncArgs, &time_sync_data.timeSyncMessageTimer); @@ -1289,12 +1289,25 @@ static void http_get_task(void *pvParameters) { while (1) { if (ulTaskNotifyTake(pdTRUE, 1) == pdTRUE) { // state change, e.g. pause/play - paused = get_player_state() == PAUSED; + player_state_e state = get_player_state(); + if (state != player_state && state == PLAYING) { +#if SNAPCAST_USE_SOFT_VOL + if (!scSet.muted) { + dsp_processor_set_volome((double)scSet.volume / 100); + } else { + dsp_processor_set_volome(0.0); + } +#else + set_volume_cb(scSet.volume); +#endif + set_mute_cb(scSet.muted); + } + player_state = state; //ESP_LOGI(TAG, "http got cb. %s", paused ? "paused" : "playing/idle"); } int result = process_data(&parser, &time_sync_data, &received_codec_header, &codec, - &scSet, &pcmData, paused); + &scSet, &pcmData, player_state); if (result != 0) { break; // restart connection } @@ -1626,7 +1639,7 @@ void app_main(void) { audioDACdata_t dac_data; player_state_e state = IDLE; while (1) { - if (xQueueReceive(audioDACQHdl, &dac_data, pdMS_TO_TICKS(100)) == pdTRUE) { + if (xQueueReceive(audioDACQHdl, &dac_data, pdMS_TO_TICKS(90)) == pdTRUE) { dac_control(board_handle, dac_data); } if (xSemaphoreTake(playerStateChangedMutex, pdMS_TO_TICKS(10)) == pdTRUE) { From 5ec9a485482542d081952096a693f4d010a239e5 Mon Sep 17 00:00:00 2001 From: raul Date: Sat, 28 Mar 2026 19:27:48 +0100 Subject: [PATCH 19/28] Use snapcast state instead of player state --- components/lightsnapcast/include/player.h | 5 +- components/lightsnapcast/player.c | 53 +--- main/main.c | 286 +++++++++++++++++----- 3 files changed, 238 insertions(+), 106 deletions(-) diff --git a/components/lightsnapcast/include/player.h b/components/lightsnapcast/include/player.h index 73e71bfc..1be896b2 100644 --- a/components/lightsnapcast/include/player.h +++ b/components/lightsnapcast/include/player.h @@ -68,15 +68,12 @@ typedef struct snapcastSetting_s { uint32_t pcmBufSize; } snapcastSetting_t; -typedef enum { IDLE = 0, PLAYING, PAUSED } player_state_e; -int init_player(i2s_std_gpio_config_t pin_config0_, i2s_port_t i2sNum_, void (*set_mute_cb)(bool)); +int init_player(i2s_std_gpio_config_t pin_config0_, i2s_port_t i2sNum_, void (*set_mute_cb)(bool), void (*cb)(bool)); int deinit_player(void); int start_player(snapcastSetting_t *setting); void pause_player(bool pause); void call_state_cb(void); -void add_player_state_cb(void (*cb)(void)); -player_state_e get_player_state(void); int32_t allocate_pcm_chunk_memory(pcm_chunk_message_t **pcmChunk, size_t bytes); int32_t insert_pcm_chunk(pcm_chunk_message_t *pcmChunk); diff --git a/components/lightsnapcast/player.c b/components/lightsnapcast/player.c index b095f6ee..d20ebe1a 100644 --- a/components/lightsnapcast/player.c +++ b/components/lightsnapcast/player.c @@ -123,12 +123,7 @@ bool playerStarted = false; bool playerPaused = false; static SemaphoreHandle_t playerStateMux = NULL; -typedef struct state_cb_s { - void (*cb)(void); - struct state_cb_s *next; -} state_cb_t; - -static state_cb_t *state_cb_head = NULL; +static void (*state_cb)(bool) = NULL; static void (*audio_set_mute)(bool mute); @@ -463,13 +458,17 @@ int deinit_player(void) { /** * call before http task creation! */ -int init_player(i2s_std_gpio_config_t pin_config0_, i2s_port_t i2sNum_, void (*set_mute_cb)(bool)) { +int init_player(i2s_std_gpio_config_t pin_config0_, i2s_port_t i2sNum_, void (*set_mute_cb)(bool), void (*cb)(bool)) { int ret = 0; if (set_mute_cb == NULL) { ESP_LOGE(TAG, "set_mute_cb is NULL"); return -1; } audio_set_mute = set_mute_cb; + if (cb != NULL) { + state_cb = cb; + } + deinit_player(); @@ -629,41 +628,13 @@ void pause_player(bool pause) { } } -player_state_e get_player_state(void) { - xSemaphoreTake(playerStateMux, portMAX_DELAY); - player_state_e state = IDLE; - if (playerPaused) { - state = PAUSED; - } else if (playerStarted) { - state = PLAYING; - } - xSemaphoreGive(playerStateMux); - return state; -} - void call_state_cb(void) { - state_cb_t *current = state_cb_head; - while (current != NULL) { - if (current->cb != NULL) { - current->cb(); - } - current = current->next; - } -} - -/** - * add callback to be called when player state changes, e.g. from not started to started. - * Callbacks needs to be implemented thread safe as they will be called from player task - */ -void add_player_state_cb(void (*cb)()) { - state_cb_t *new_cb = malloc(sizeof(state_cb_t)); - if (new_cb == NULL) { - ESP_LOGE(TAG, "Failed to allocate memory for state callback"); - return; + if (state_cb != NULL) { + xSemaphoreTake(playerStateMux, portMAX_DELAY); + bool paused = playerPaused; + xSemaphoreGive(playerStateMux); + state_cb(paused); } - new_cb->cb = cb; - new_cb->next = state_cb_head; - state_cb_head = new_cb; } /** @@ -2211,10 +2182,10 @@ static void player_task(void *pvParameters) { tg0_timer_deinit(); playerStarted = false; - call_state_cb(); ESP_LOGI(TAG, "stop player done"); playerTaskHandle = NULL; xSemaphoreGive(playerStateMux); + call_state_cb(); vTaskDelete(NULL); } diff --git a/main/main.c b/main/main.c index 4451d989..0ba55244 100644 --- a/main/main.c +++ b/main/main.c @@ -107,7 +107,7 @@ static const char *TAG = "SC"; SemaphoreHandle_t timeSyncSemaphoreHandle = NULL; SemaphoreHandle_t idCounterSemaphoreHandle = NULL; -SemaphoreHandle_t playerStateChangedMutex = NULL; +SemaphoreHandle_t snapcastStateChangedMutex = NULL; typedef struct audioDACdata_s { bool playerMute; @@ -119,7 +119,8 @@ static audioDACdata_t audioDAC_data; static QueueHandle_t audioDACQHdl = NULL; static SemaphoreHandle_t audioDACSemaphore = NULL; static void (*set_volume_cb)(int volume); -static void (*set_mute_cb)(bool mute); +static void (*set_mute_cb)(bool mute, bool state); +static SemaphoreHandle_t snapcastStateMux = NULL; void time_sync_msg_cb(void *args); @@ -501,26 +502,89 @@ void error_callback(const FLAC__StreamDecoder *decoder, FLAC__StreamDecoderErrorStatusString[status]); } -/** - * - */ -int init_snapcast(void (*set_volume)(int), void (*set_mute)(bool)) { - if (set_volume == NULL) { - ESP_LOGE(TAG, "Volume callback is NULL"); +typedef enum { IDLE = 0, STOPPED, PLAYING, PAUSED } snapcast_state_t; //defined in player.h +typedef enum { STOP = 0, START, RESTART, PAUSE, UNPAUSE } snapcast_commands_t; - return -1; +typedef struct state_cb_s { + void (*cb)(void); + struct state_cb_s *next; +} state_cb_t; + +static snapcast_state_t sc_state = STOPPED; +static state_cb_t *state_cb_head = NULL; + +void player_set_mute(bool mute) { + set_mute_cb(mute, false); +} + +void set_mute_state(bool mute) { + set_mute_cb(mute, true); +} + +void sc_send_command(snapcast_commands_t command) { + if (t_http_get_task != NULL) { + xTaskNotify(t_http_get_task, (uint32_t) command, eSetValueWithOverwrite); } - if (set_mute == NULL) { - ESP_LOGE(TAG, "Mute callback is NULL"); +} - return -1; +void player_state_paused(bool paused) { + if (paused) { + sc_send_command(PAUSE); + } else { + sc_send_command(UNPAUSE); } - set_volume_cb = set_volume; - set_mute_cb = set_mute; +} - return 0; +void sc_start_snapcast() { + sc_send_command(START); } +void sc_restart_snapcast() { + sc_send_command(RESTART); +} + +void sc_stop_snapcast() { + sc_send_command(STOP); +} + +void sc_pause_snapcast(bool pause) { + pause_player(pause); + if (!pause) { + //sc_send_command(UNPAUSE); + } +} + +snapcast_state_t sc_get_snapcast_state(void) { + xSemaphoreTake(snapcastStateMux, portMAX_DELAY); + snapcast_state_t state = sc_state; + xSemaphoreGive(snapcastStateMux); + return state; +} + +void sc_call_state_cb(void) { + state_cb_t *current = state_cb_head; + while (current != NULL) { + if (current->cb != NULL) { + current->cb(); + } + current = current->next; + } +} + +/** + * add callback to be called when snapcast state changes, e.g. from not started to started. + * Callbacks needs to be implemented thread safe as they will be called from http task + */ +void sc_add_state_cb(void (*cb)()) { + state_cb_t *new_cb = malloc(sizeof(state_cb_t)); + if (new_cb == NULL) { + ESP_LOGE(TAG, "Failed to allocate memory for state callback"); + return; + } + new_cb->cb = cb; + new_cb->next = state_cb_head; + state_cb_head = new_cb; +} /** @@ -545,7 +609,7 @@ void server_settings_msg_received( dsp_processor_set_volome((double)server_settings_message->volume / 100); } #endif - set_mute_cb(server_settings_message->muted); + set_mute_state(server_settings_message->muted); } if (playing && scSet->volume != server_settings_message->volume) { @@ -973,6 +1037,46 @@ void handle_chunk_message(codec_type_t codec, snapcastSetting_t *scSet, } } +void update_state(bool *received_wire_chnk, bool *playback, bool paused) { + static int64_t last = 0; + static snapcast_state_t state = IDLE; //Todo + if ((paused || state != PLAYING) && (!paused || state != PAUSED) && *received_wire_chnk) { + xSemaphoreTake(snapcastStateMux, portMAX_DELAY); + if (paused) { + sc_state = PAUSED; + *playback = false; + ESP_LOGI(TAG, "Set paused"); + }else{ + sc_state = PLAYING; + ESP_LOGI(TAG, "Set playing"); + *playback = true; + } + state = sc_state; + xSemaphoreGive(snapcastStateMux); + sc_call_state_cb(); + last = esp_timer_get_time(); + *received_wire_chnk = false; + } + else if (state == PLAYING || state == PAUSED) { + int64_t now = esp_timer_get_time(); + if (now-last > 1000000) { //update once per sec + if (!(*received_wire_chnk)) { + xSemaphoreTake(snapcastStateMux, portMAX_DELAY); + sc_state = IDLE; + *playback = false; + state = sc_state; + xSemaphoreGive(snapcastStateMux); + sc_call_state_cb(); + ESP_LOGI(TAG, "Set idle"); + } + last = now; + *received_wire_chnk = false; + } + } + +} + + /* * returns: * 0 if a message was (partially) processed sucessfully @@ -981,9 +1085,12 @@ void handle_chunk_message(codec_type_t codec, snapcastSetting_t *scSet, int process_data(snapcast_protocol_parser_t *parser, time_sync_data_t *time_sync_data, bool *received_codec_header, codec_type_t *codec, snapcastSetting_t *scSet, - pcm_chunk_message_t **pcmData, player_state_e state) { + pcm_chunk_message_t **pcmData, bool *playback, bool paused) { base_message_t base_message_rx; + static bool received_wire_chnk = false; + update_state(&received_wire_chnk, playback, paused); + if (parse_base_message(parser, &base_message_rx) != PARSER_OK) { return -1; // restart connection } @@ -995,9 +1102,9 @@ int process_data(snapcast_protocol_parser_t *parser, switch (base_message_rx.type) { case SNAPCAST_MESSAGE_WIRE_CHUNK: { wire_chunk_message_t wire_chnk = {{0, 0}, 0, NULL}; // is wire_chnk.payload ever used? - + received_wire_chnk = true; // skip this wires chunk message if codec header message was not received yet! - if (*received_codec_header == false || state == PAUSED) { + if (*received_codec_header == false || paused) { if (parser_skip_typed_message(parser, &base_message_rx) != PARSER_OK) { return -1; } @@ -1034,7 +1141,7 @@ int process_data(snapcast_protocol_parser_t *parser, if (parse_sever_settings_message(parser, &base_message_rx, &server_settings_message) != PARSER_OK) { return -1; } - server_settings_msg_received(&server_settings_message, scSet, state == PLAYING); + server_settings_msg_received(&server_settings_message, scSet, *playback); return 0; } @@ -1082,12 +1189,6 @@ void before_receive_callback(before_receive_callback_data_t *data) { } } - -void http_player_state_changed() { - xTaskNotifyGive(t_http_get_task); - //ESP_LOGI(TAG, "http task cb"); -} - /** * */ @@ -1109,7 +1210,9 @@ static void http_get_task(void *pvParameters) { codec_type_t codec = NONE; snapcastSetting_t scSet; pcm_chunk_message_t *pcmData = NULL; - player_state_e player_state = IDLE; + uint32_t command = STOP; + bool paused = false; + bool playback = false; // create a timer to send time sync messages every x µs // esp_timer_create(&tSyncArgs, &time_sync_data.timeSyncMessageTimer); @@ -1121,8 +1224,6 @@ static void http_get_task(void *pvParameters) { esp_restart(); } - add_player_state_cb(http_player_state_changed); - while (1) { // do some house keeping { @@ -1156,6 +1257,21 @@ static void http_get_task(void *pvParameters) { } } + // block if state = STOPPED + xSemaphoreTake(snapcastStateMux, portMAX_DELAY); + if (sc_state == STOPPED) { + xSemaphoreGive(snapcastStateMux); + command = STOP; + while(command != START) { + xTaskNotifyWait( 0, 0, &command, portMAX_DELAY); + } + xSemaphoreTake(snapcastStateMux, portMAX_DELAY); + } + sc_state = IDLE; + xSemaphoreGive(snapcastStateMux); + sc_call_state_cb(); + playback = false; + // NETWORK setup ends here ( or before getting mac address ) setup_network(&connection.netif); @@ -1287,10 +1403,40 @@ static void http_get_task(void *pvParameters) { // Main connection loop - state machine + data processing while (1) { - if (ulTaskNotifyTake(pdTRUE, 1) == pdTRUE) { - // state change, e.g. pause/play - player_state_e state = get_player_state(); - if (state != player_state && state == PLAYING) { + bool restart = false; + static bool playback_old = false; + if (xTaskNotifyWait(0, 0, &command, 1) == pdTRUE) { + switch(command) { + case STOP: + xSemaphoreTake(snapcastStateMux, portMAX_DELAY); + sc_state = STOPPED; + xSemaphoreGive(snapcastStateMux); + sc_call_state_cb(); + case RESTART: + restart = true; + break; + case UNPAUSE: + paused = false; + break; + case PAUSE: + paused = true; + break; + default: + break; + } + //ESP_LOGI(TAG, "http got cb. %s", paused ? "paused" : "playing/idle"); + } + if (restart) { + //restart required + netconn_close(lwipNetconn); + netconn_delete(lwipNetconn); + lwipNetconn = NULL; + break; // restart connection + } + + if (playback_old != playback) { + if (playback) { + // need to apply settings when starting to play #if SNAPCAST_USE_SOFT_VOL if (!scSet.muted) { dsp_processor_set_volome((double)scSet.volume / 100); @@ -1300,14 +1446,14 @@ static void http_get_task(void *pvParameters) { #else set_volume_cb(scSet.volume); #endif - set_mute_cb(scSet.muted); + set_mute_state(scSet.muted); } - player_state = state; - //ESP_LOGI(TAG, "http got cb. %s", paused ? "paused" : "playing/idle"); + playback_old = playback; } + int result = process_data(&parser, &time_sync_data, &received_codec_header, &codec, - &scSet, &pcmData, player_state); + &scSet, &pcmData, &playback, paused); if (result != 0) { break; // restart connection } @@ -1315,6 +1461,35 @@ static void http_get_task(void *pvParameters) { } } +/** + * + */ +int init_snapcast(void (*set_volume)(int), void (*set_mute)(bool, bool), i2s_std_gpio_config_t i2s_pin_config0, i2s_port_t I2S_NUM_0) { + if (set_volume == NULL) { + ESP_LOGE(TAG, "Volume callback is NULL"); + + return -1; + } + if (set_mute == NULL) { + ESP_LOGE(TAG, "Mute callback is NULL"); + + return -1; + } + set_volume_cb = set_volume; + set_mute_cb = set_mute; + if (snapcastStateMux == NULL) { + snapcastStateMux = xSemaphoreCreateMutex(); + } + init_player(i2s_pin_config0, I2S_NUM_0, player_set_mute, player_state_paused); + + xTaskCreatePinnedToCore(&http_get_task, "http", 15 * 1024, NULL, + HTTP_TASK_PRIORITY, &t_http_get_task, + HTTP_TASK_CORE_ID); + + return 0; +} + + /** * */ @@ -1360,14 +1535,6 @@ void audio_set_mute(bool mute, bool set_state) { xSemaphoreGive(audioDACSemaphore); } -void player_set_mute(bool mute) { - audio_set_mute(mute, false); -} - -void set_mute_state(bool mute) { - audio_set_mute(mute, true); -} - /** * */ @@ -1380,9 +1547,9 @@ void audio_set_volume(int volume) { xSemaphoreGive(audioDACSemaphore); } -void player_state_changed() { - if (playerStateChangedMutex != NULL) { - xSemaphoreGive(playerStateChangedMutex); +void sc_state_changed() { + if (snapcastStateChangedMutex != NULL) { + xSemaphoreGive(snapcastStateChangedMutex); } ESP_LOGI(TAG, "main task cb"); } @@ -1555,14 +1722,14 @@ void app_main(void) { audioDAC_data.playerMute = true; audioDAC_data.volume = -1; - init_snapcast(audio_set_volume, set_mute_state); - init_player(i2s_pin_config0, I2S_NUM_0, player_set_mute); - add_player_state_cb(player_state_changed); + init_snapcast(audio_set_volume, audio_set_mute, i2s_pin_config0, I2S_NUM_0); + //init_player(i2s_pin_config0, I2S_NUM_0, player_set_mute); + sc_add_state_cb(sc_state_changed); // Create binary semaphore for player state change notification - playerStateChangedMutex = xSemaphoreCreateBinary(); - if (playerStateChangedMutex == NULL) { - ESP_LOGE(TAG, "Failed to create playerStateChangedMutex"); + snapcastStateChangedMutex = xSemaphoreCreateBinary(); + if (snapcastStateChangedMutex == NULL) { + ESP_LOGE(TAG, "Failed to create snapcastStateChangedMutex"); return; } @@ -1606,10 +1773,7 @@ void app_main(void) { xTaskCreatePinnedToCore(&ota_server_task, "ota", 14 * 256, NULL, OTA_TASK_PRIORITY, &t_ota_task, OTA_TASK_CORE_ID); - - xTaskCreatePinnedToCore(&http_get_task, "http", 15 * 1024, NULL, - HTTP_TASK_PRIORITY, &t_http_get_task, - HTTP_TASK_CORE_ID); + sc_start_snapcast(); // while (1) { // // audio_event_iface_msg_t msg; @@ -1637,13 +1801,13 @@ void app_main(void) { esp_pm_configure(&pmConfig); #endif audioDACdata_t dac_data; - player_state_e state = IDLE; + snapcast_state_t state = IDLE; while (1) { if (xQueueReceive(audioDACQHdl, &dac_data, pdMS_TO_TICKS(90)) == pdTRUE) { dac_control(board_handle, dac_data); } - if (xSemaphoreTake(playerStateChangedMutex, pdMS_TO_TICKS(10)) == pdTRUE) { - player_state_e state_new = get_player_state(); + if (xSemaphoreTake(snapcastStateChangedMutex, pdMS_TO_TICKS(10)) == pdTRUE) { + snapcast_state_t state_new = sc_get_snapcast_state(); ESP_LOGI(TAG, "main got cb: %d", state_new); if (state_new != state) { ESP_LOGI(TAG, "Player state changed: %d -> %d", state, state_new); From 49154583ec1f5e1bed3dab3ed56d5570642af9de Mon Sep 17 00:00:00 2001 From: raul Date: Sun, 29 Mar 2026 15:44:05 +0200 Subject: [PATCH 20/28] Add i2s lock --- components/lightsnapcast/include/player.h | 2 +- components/lightsnapcast/player.c | 31 +++++++++++++++-------- main/main.c | 27 +++++++++++++++----- 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/components/lightsnapcast/include/player.h b/components/lightsnapcast/include/player.h index 1be896b2..49ece93e 100644 --- a/components/lightsnapcast/include/player.h +++ b/components/lightsnapcast/include/player.h @@ -68,7 +68,7 @@ typedef struct snapcastSetting_s { uint32_t pcmBufSize; } snapcastSetting_t; -int init_player(i2s_std_gpio_config_t pin_config0_, i2s_port_t i2sNum_, void (*set_mute_cb)(bool), void (*cb)(bool)); +int init_player(i2s_std_gpio_config_t pin_config0_, i2s_port_t i2sNum_, void (*set_mute_cb)(bool), void (*cb)(bool), bool (*lock)(bool, TickType_t)); int deinit_player(void); int start_player(snapcastSetting_t *setting); void pause_player(bool pause); diff --git a/components/lightsnapcast/player.c b/components/lightsnapcast/player.c index d20ebe1a..cd9ac623 100644 --- a/components/lightsnapcast/player.c +++ b/components/lightsnapcast/player.c @@ -124,8 +124,8 @@ bool playerPaused = false; static SemaphoreHandle_t playerStateMux = NULL; static void (*state_cb)(bool) = NULL; - static void (*audio_set_mute)(bool mute); +static bool (*lock_i2s)(bool, TickType_t) = NULL; static i2s_chan_handle_t tx_chan = NULL; // I2S tx channel handler static bool i2sEnabled = false; @@ -215,7 +215,14 @@ static void ensure_noiseless(i2s_chan_handle_t tx) { /** * */ -static esp_err_t player_setup_i2s(snapcastSetting_t *setting) { +static esp_err_t player_setup_i2s(snapcastSetting_t *setting, bool lock) { + + if (lock_i2s != NULL && lock) { + if (lock_i2s(true, pdMS_TO_TICKS(10)) != pdTRUE) { + return -1; + } + } + // ensure save setting int32_t sr = setting->sr; if (sr == 0) { @@ -419,6 +426,9 @@ int deinit_player(void) { i2s_del_channel(tx_chan); tx_chan = NULL; } + if (lock_i2s != NULL) { + lock_i2s(false, 0); + } if (playerStateMux != NULL) { vSemaphoreDelete(playerStateMux); @@ -458,16 +468,15 @@ int deinit_player(void) { /** * call before http task creation! */ -int init_player(i2s_std_gpio_config_t pin_config0_, i2s_port_t i2sNum_, void (*set_mute_cb)(bool), void (*cb)(bool)) { +int init_player(i2s_std_gpio_config_t pin_config0_, i2s_port_t i2sNum_, void (*set_mute_cb)(bool), void (*cb)(bool), bool (*lock)(bool, TickType_t)) { int ret = 0; if (set_mute_cb == NULL) { ESP_LOGE(TAG, "set_mute_cb is NULL"); return -1; } audio_set_mute = set_mute_cb; - if (cb != NULL) { - state_cb = cb; - } + state_cb = cb; // can be NULL + lock_i2s = lock; // can be NULL deinit_player(); @@ -552,7 +561,7 @@ int start_player(snapcastSetting_t *setting) { playerStarted = true; int ret = 0; - ret = player_setup_i2s(setting); + ret = player_setup_i2s(setting, true); if (ret < 0) { ESP_LOGE(TAG, "player_setup_i2s failed: %d", ret); playerStarted = false; @@ -1575,7 +1584,7 @@ static void player_task(void *pvParameters) { audio_set_mute(true); my_i2s_channel_disable(tx_chan); - ret = player_setup_i2s(&__scSet); + ret = player_setup_i2s(&__scSet, false); if (ret < 0) { ESP_LOGE(TAG, "player_setup_i2s failed: %d", ret); @@ -2166,7 +2175,9 @@ static void player_task(void *pvParameters) { my_i2s_channel_disable(tx_chan); i2s_del_channel(tx_chan); tx_chan = NULL; - ret = 0; + if (lock_i2s != NULL) { + lock_i2s(false, 0); + } xSemaphoreTake(playerStateMux, portMAX_DELAY); xSemaphoreTake(snapcastSettingsMux, portMAX_DELAY); // delete the queue @@ -2178,7 +2189,7 @@ static void player_task(void *pvParameters) { esp_pm_lock_release(player_pm_lock_handle); #endif - ret = destroy_pcm_queue(&pcmChkQHdl); + destroy_pcm_queue(&pcmChkQHdl); tg0_timer_deinit(); playerStarted = false; diff --git a/main/main.c b/main/main.c index 0ba55244..c895c49e 100644 --- a/main/main.c +++ b/main/main.c @@ -106,8 +106,8 @@ static const char *TAG = "SC"; // static QueueHandle_t playerChunkQueueHandle = NULL; SemaphoreHandle_t timeSyncSemaphoreHandle = NULL; -SemaphoreHandle_t idCounterSemaphoreHandle = NULL; -SemaphoreHandle_t snapcastStateChangedMutex = NULL; +static SemaphoreHandle_t idCounterSemaphoreHandle = NULL; +static SemaphoreHandle_t snapcastStateChangedMutex = NULL; typedef struct audioDACdata_s { bool playerMute; @@ -121,6 +121,7 @@ static SemaphoreHandle_t audioDACSemaphore = NULL; static void (*set_volume_cb)(int volume); static void (*set_mute_cb)(bool mute, bool state); static SemaphoreHandle_t snapcastStateMux = NULL; +static SemaphoreHandle_t i2sLockMutex = NULL; void time_sync_msg_cb(void *args); @@ -502,7 +503,7 @@ void error_callback(const FLAC__StreamDecoder *decoder, FLAC__StreamDecoderErrorStatusString[status]); } -typedef enum { IDLE = 0, STOPPED, PLAYING, PAUSED } snapcast_state_t; //defined in player.h +typedef enum { STOPPED = 0, IDLE, PLAYING, PAUSED } snapcast_state_t; //defined in player.h typedef enum { STOP = 0, START, RESTART, PAUSE, UNPAUSE } snapcast_commands_t; typedef struct state_cb_s { @@ -1464,7 +1465,7 @@ static void http_get_task(void *pvParameters) { /** * */ -int init_snapcast(void (*set_volume)(int), void (*set_mute)(bool, bool), i2s_std_gpio_config_t i2s_pin_config0, i2s_port_t I2S_NUM_0) { +int init_snapcast(void (*set_volume)(int), void (*set_mute)(bool, bool), i2s_std_gpio_config_t i2s_pin_config0, i2s_port_t I2S_NUM_0, bool (*lock)(bool, TickType_t)) { if (set_volume == NULL) { ESP_LOGE(TAG, "Volume callback is NULL"); @@ -1480,7 +1481,7 @@ int init_snapcast(void (*set_volume)(int), void (*set_mute)(bool, bool), i2s_std if (snapcastStateMux == NULL) { snapcastStateMux = xSemaphoreCreateMutex(); } - init_player(i2s_pin_config0, I2S_NUM_0, player_set_mute, player_state_paused); + init_player(i2s_pin_config0, I2S_NUM_0, player_set_mute, player_state_paused, lock); xTaskCreatePinnedToCore(&http_get_task, "http", 15 * 1024, NULL, HTTP_TASK_PRIORITY, &t_http_get_task, @@ -1554,6 +1555,18 @@ void sc_state_changed() { ESP_LOGI(TAG, "main task cb"); } +bool i2s_lock(bool lock, TickType_t wait) { + if (i2sLockMutex == NULL) { + return false; + } + if (lock) { + return xSemaphoreTake(i2sLockMutex, wait); + } + else { + return xSemaphoreGive(i2sLockMutex); + } +} + /** * */ @@ -1722,7 +1735,9 @@ void app_main(void) { audioDAC_data.playerMute = true; audioDAC_data.volume = -1; - init_snapcast(audio_set_volume, audio_set_mute, i2s_pin_config0, I2S_NUM_0); + i2sLockMutex = xSemaphoreCreateBinary(); + + init_snapcast(audio_set_volume, audio_set_mute, i2s_pin_config0, I2S_NUM_0, i2s_lock); //init_player(i2s_pin_config0, I2S_NUM_0, player_set_mute); sc_add_state_cb(sc_state_changed); From a274fa1bf42901000ab826c5790ede6cb04118c4 Mon Sep 17 00:00:00 2001 From: raul Date: Mon, 30 Mar 2026 15:57:03 +0200 Subject: [PATCH 21/28] Refactor handling of scSettings object --- components/dsp_processor/dsp_processor.c | 17 +- .../dsp_processor/include/dsp_processor.h | 2 +- components/lightsnapcast/include/player.h | 25 +-- components/lightsnapcast/player.c | 183 +++++------------- main/main.c | 132 +++++++------ 5 files changed, 134 insertions(+), 225 deletions(-) diff --git a/components/dsp_processor/dsp_processor.c b/components/dsp_processor/dsp_processor.c index 9874ca8f..11d44591 100644 --- a/components/dsp_processor/dsp_processor.c +++ b/components/dsp_processor/dsp_processor.c @@ -259,23 +259,13 @@ static inline int16_t float_to_int16_clamped(float value) { /** * */ -int dsp_processor_worker(void *p_pcmChnk, const void *p_scSet) { +int dsp_processor_worker(char *pcmChnk, uint16_t len, uint32_t samplerate, int ch) { ESP_LOGV(TAG, "%s: processing audio chunk", __func__); - const snapcastSetting_t *scSet = (const snapcastSetting_t *)p_scSet; - pcm_chunk_message_t *pcmChnk = (pcm_chunk_message_t *)p_pcmChnk; - uint32_t samplerate = scSet->sr; - if (!pcmChnk || !pcmChnk->fragment->payload) { + if (!pcmChnk) { return -1; } - int bits = scSet->bits; - int ch = scSet->ch; - - if (bits == 0) { - bits = 16; - } - if (ch == 0) { ch = 2; } @@ -285,12 +275,11 @@ int dsp_processor_worker(void *p_pcmChnk, const void *p_scSet) { ESP_LOGW(TAG, "%s: Sample rate is not set, using default: %lu", __func__, (unsigned long)samplerate); } - int16_t len = pcmChnk->fragment->size / ((bits / 8) * ch); int16_t valint; uint16_t i; // volatile needed to ensure 32 bit access volatile uint32_t *audio_tmp = - (volatile uint32_t *)(pcmChnk->fragment->payload); + (volatile uint32_t *)(pcmChnk); // Local working copy of filter parameters static filterParams_t currentFilterParams = {0}; diff --git a/components/dsp_processor/include/dsp_processor.h b/components/dsp_processor/include/dsp_processor.h index 5099dcf4..5f9c3b3c 100644 --- a/components/dsp_processor/include/dsp_processor.h +++ b/components/dsp_processor/include/dsp_processor.h @@ -25,7 +25,7 @@ enum filtertypes { void dsp_processor_init(void); void dsp_processor_uninit(void); -int dsp_processor_worker(void *pcmChnk, const void *scSet); +int dsp_processor_worker(char *pcmChnk, uint16_t len, uint32_t samplerate, int ch); esp_err_t dsp_processor_update_filter_params(filterParams_t *params); void dsp_processor_set_volome(double volume); diff --git a/components/lightsnapcast/include/player.h b/components/lightsnapcast/include/player.h index 49ece93e..e50abfe0 100644 --- a/components/lightsnapcast/include/player.h +++ b/components/lightsnapcast/include/player.h @@ -51,30 +51,20 @@ typedef struct pcmData { typedef enum codec_type_e { NONE = 0, PCM, FLAC, OGG, OPUS } codec_type_t; -typedef struct snapcastSetting_s { - uint32_t buf_ms; - uint32_t chkInFrames; - int32_t cDacLat_ms; - - codec_type_t codec; +typedef struct playerSetting_s { + uint16_t buf_ms; + uint16_t chkInFrames; + int16_t cDacLat_ms; int32_t sr; uint8_t ch; i2s_data_bit_width_t bits; - - bool muted; - uint32_t volume; - - char *pcmBuf; - uint32_t pcmBufSize; -} snapcastSetting_t; +} playerSetting_t; int init_player(i2s_std_gpio_config_t pin_config0_, i2s_port_t i2sNum_, void (*set_mute_cb)(bool), void (*cb)(bool), bool (*lock)(bool, TickType_t)); int deinit_player(void); -int start_player(snapcastSetting_t *setting); +int start_player(void); void pause_player(bool pause); -void call_state_cb(void); - int32_t allocate_pcm_chunk_memory(pcm_chunk_message_t **pcmChunk, size_t bytes); int32_t insert_pcm_chunk(pcm_chunk_message_t *pcmChunk); @@ -90,8 +80,7 @@ int32_t player_latency_insert(int64_t newValue); int32_t get_diff_to_server(int64_t *tDiff, int64_t now); int32_t latency_buffer_full(bool *is_full); -int32_t player_send_snapcast_setting(snapcastSetting_t *setting); -int8_t player_get_snapcast_settings(snapcastSetting_t *setting); +int32_t player_send_snapcast_setting(playerSetting_t *setting); int32_t reset_latency_buffer(void); diff --git a/components/lightsnapcast/player.c b/components/lightsnapcast/player.c index cd9ac623..3ad0a9dd 100644 --- a/components/lightsnapcast/player.c +++ b/components/lightsnapcast/player.c @@ -106,9 +106,7 @@ static QueueHandle_t snapcastSettingQueueHandle = NULL; static uint32_t i2sDmaBufCnt; static uint32_t i2sDmaBufMaxLen; - -static SemaphoreHandle_t snapcastSettingsMux = NULL; -static snapcastSetting_t currentSnapcastSetting; +static playerSetting_t *scSet; // should be used only from http_task static void tg0_timer_init(void); static void tg0_timer_deinit(void); @@ -118,7 +116,6 @@ static bool gpTimerRunning = false; static void player_task(void *pvParameters); //player state -bool gotSettings = false; bool playerStarted = false; bool playerPaused = false; static SemaphoreHandle_t playerStateMux = NULL; @@ -215,7 +212,7 @@ static void ensure_noiseless(i2s_chan_handle_t tx) { /** * */ -static esp_err_t player_setup_i2s(snapcastSetting_t *setting, bool lock) { +static esp_err_t player_setup_i2s(playerSetting_t *setting, bool lock) { if (lock_i2s != NULL && lock) { if (lock_i2s(true, pdMS_TO_TICKS(10)) != pdTRUE) { @@ -434,9 +431,10 @@ int deinit_player(void) { vSemaphoreDelete(playerStateMux); playerStateMux = NULL; } - if (snapcastSettingsMux != NULL) { - vSemaphoreDelete(snapcastSettingsMux); - snapcastSettingsMux = NULL; + if (snapcastSettingQueueHandle != NULL) { + // delete the queue + vQueueDelete(snapcastSettingQueueHandle); + snapcastSettingQueueHandle = NULL; } ret = destroy_pcm_queue(&pcmChkQHdl); @@ -465,15 +463,24 @@ int deinit_player(void) { return ret; } +void call_state_cb(void) { + if (state_cb != NULL) { + xSemaphoreTake(playerStateMux, portMAX_DELAY); + bool paused = playerPaused; + xSemaphoreGive(playerStateMux); + state_cb(paused); + } +} + /** * call before http task creation! */ int init_player(i2s_std_gpio_config_t pin_config0_, i2s_port_t i2sNum_, void (*set_mute_cb)(bool), void (*cb)(bool), bool (*lock)(bool, TickType_t)) { - int ret = 0; if (set_mute_cb == NULL) { ESP_LOGE(TAG, "set_mute_cb is NULL"); return -1; } + audio_set_mute = set_mute_cb; state_cb = cb; // can be NULL lock_i2s = lock; // can be NULL @@ -484,31 +491,14 @@ int init_player(i2s_std_gpio_config_t pin_config0_, i2s_port_t i2sNum_, void (*s pin_config0 = pin_config0_; i2sNum = i2sNum_; - currentSnapcastSetting.buf_ms = 0; - currentSnapcastSetting.chkInFrames = 0; - currentSnapcastSetting.codec = NONE; - currentSnapcastSetting.sr = 0; - currentSnapcastSetting.ch = 0; - currentSnapcastSetting.bits = 0; - - if (snapcastSettingsMux == NULL) { - snapcastSettingsMux = xSemaphoreCreateMutex(); - xSemaphoreGive(snapcastSettingsMux); - } + // create message queue to inform task of changed settings + snapcastSettingQueueHandle = xQueueCreate(1, sizeof(playerSetting_t)); if (playerStateMux == NULL) { playerStateMux = xSemaphoreCreateMutex(); xSemaphoreGive(playerStateMux); } - /** - ret = player_setup_i2s(¤tSnapcastSetting); - if (ret < 0) { - ESP_LOGE(TAG, "player_setup_i2s failed: %d", ret); - - return -1; - }*/ - // create semaphore for time diff buffer to server if (latencyBufSemaphoreHandle == NULL) { latencyBufSemaphoreHandle = xSemaphoreCreateMutex(); @@ -549,7 +539,7 @@ int init_player(i2s_std_gpio_config_t pin_config0_, i2s_port_t i2sNum_, void (*s /** * call to start the player task */ -int start_player(snapcastSetting_t *setting) { +int start_player() { if (xSemaphoreTake(playerStateMux, 0) != pdTRUE) { // shutdown in progress, don't start player return -1; @@ -558,10 +548,14 @@ int start_player(snapcastSetting_t *setting) { xSemaphoreGive(playerStateMux); return -1; } + if (scSet == NULL || !(( scSet->buf_ms > 0) && (scSet->chkInFrames > 0))) { + xSemaphoreGive(playerStateMux); + return -1; + } playerStarted = true; int ret = 0; - ret = player_setup_i2s(setting, true); + ret = player_setup_i2s(scSet, true); if (ret < 0) { ESP_LOGE(TAG, "player_setup_i2s failed: %d", ret); playerStarted = false; @@ -582,27 +576,15 @@ int start_player(snapcastSetting_t *setting) { 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 - } - - int entries = ceil(((float)scSet.sr / (float)chkInFrames) * - ((float)scSet.buf_ms / 1000)); + 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 *)); @@ -611,7 +593,7 @@ int start_player(snapcastSetting_t *setting) { ESP_LOGI(TAG, "Start player_task"); - xTaskCreatePinnedToCore(player_task, "player", 1024 * 3, NULL, + xTaskCreatePinnedToCore(player_task, "player", 1024 * 3, (void*) scSet, SYNC_TASK_PRIORITY, &playerTaskHandle, SYNC_TASK_CORE_ID); @@ -637,45 +619,6 @@ void pause_player(bool pause) { } } -void call_state_cb(void) { - if (state_cb != NULL) { - xSemaphoreTake(playerStateMux, portMAX_DELAY); - bool paused = playerPaused; - xSemaphoreGive(playerStateMux); - state_cb(paused); - } -} - -/** - * - */ -int8_t player_set_snapcast_settings(snapcastSetting_t *setting) { - int8_t ret = pdPASS; - - xSemaphoreTake(snapcastSettingsMux, portMAX_DELAY); - - memcpy(¤tSnapcastSetting, setting, sizeof(snapcastSetting_t)); - - xSemaphoreGive(snapcastSettingsMux); - - return ret; -} - -/** - * - */ -int8_t player_get_snapcast_settings(snapcastSetting_t *setting) { - int8_t ret = pdPASS; - - xSemaphoreTake(snapcastSettingsMux, portMAX_DELAY); - - memcpy(setting, ¤tSnapcastSetting, sizeof(snapcastSetting_t)); - - xSemaphoreGive(snapcastSettingsMux); - - return ret; -} - #if USE_TIMEFILTER /** * @@ -737,42 +680,23 @@ int32_t player_latency_insert(int64_t newValue) { /** * */ -int32_t player_send_snapcast_setting(snapcastSetting_t *setting) { +int32_t player_send_snapcast_setting(playerSetting_t *setting) { int ret; - snapcastSetting_t curSet; - uint8_t settingChanged = 1; - ret = player_get_snapcast_settings(&curSet); - - if ((curSet.bits != setting->bits) || (curSet.buf_ms != setting->buf_ms) || - (curSet.ch != setting->ch) || - (curSet.chkInFrames != setting->chkInFrames) || - (curSet.codec != setting->codec) || (curSet.sr != setting->sr) || - (curSet.cDacLat_ms != setting->cDacLat_ms)) { - ret = player_set_snapcast_settings(setting); + if ((playerTaskHandle != NULL) && (snapcastSettingQueueHandle != NULL)) { + ret = xQueueOverwrite(snapcastSettingQueueHandle, setting); if (ret != pdPASS) { ESP_LOGE(TAG, - "player_send_snapcast_setting: couldn't change " - "snapcast setting"); - } - - // notify needed - if ((playerTaskHandle != NULL) && (snapcastSettingQueueHandle != NULL)) { - ret = xQueueOverwrite(snapcastSettingQueueHandle, &settingChanged); - if (ret != pdPASS) { - ESP_LOGE(TAG, - "player_send_snapcast_setting: couldn't notify " - "snapcast setting"); - } else { - ESP_LOGI(TAG, - "got settings and notified player_task"); - } + "player_send_snapcast_setting: couldn't notify " + "snapcast setting"); + } else { + ESP_LOGI(TAG, + "got settings and notified player_task"); } - } - - if (!gotSettings && (setting->bits > 0) && ( setting->buf_ms > 0) && (setting->ch > 0) && - (setting->chkInFrames > 0) && (setting->sr > 0)) { - gotSettings = true; + } else if (scSet == NULL) { + scSet = setting; + ESP_LOGI(TAG, + "got initial settings"); } return pdPASS; @@ -1416,14 +1340,8 @@ int32_t insert_pcm_chunk(pcm_chunk_message_t *pcmChunk) { } if (pcmChkQHdl == NULL) { ESP_LOGW(TAG, "pcm chunk queue not created. Player started: %s", playerStarted ? "True": "False"); - free_pcm_chunk(pcmChunk); - - snapcastSetting_t curSet; - player_get_snapcast_settings(&curSet); - if (gotSettings) { - start_player(&curSet); - } + start_player(); return -2; } @@ -1485,8 +1403,7 @@ static void player_task(void *pvParameters) { char *p_payload = NULL; size_t size = 0; uint32_t notifiedValue; - snapcastSetting_t scSet; - uint8_t scSetChgd = 0; + playerSetting_t scSet; uint64_t timer_val; int initialSync = 0; int dir = 0; @@ -1505,8 +1422,7 @@ static void player_task(void *pvParameters) { uint64_t samples_written = 0; UBaseType_t uxHighWaterMark; - memset(&scSet, 0, sizeof(snapcastSetting_t)); - player_get_snapcast_settings(&scSet); + memcpy(&scSet, (playerSetting_t*)pvParameters, sizeof(playerSetting_t)); ESP_LOGI(TAG, "started sync task"); @@ -1566,11 +1482,9 @@ static void player_task(void *pvParameters) { // check if we got changed setting available, if so we need to // reinitialize - ret = xQueueReceive(snapcastSettingQueueHandle, &scSetChgd, 0); + playerSetting_t __scSet; + ret = xQueueReceive(snapcastSettingQueueHandle, &__scSet, 0); if (ret == pdTRUE) { - snapcastSetting_t __scSet; - - player_get_snapcast_settings(&__scSet); if ((__scSet.buf_ms > 0) && (__scSet.chkInFrames > 0) && (__scSet.sr > 0)) { @@ -1580,6 +1494,8 @@ static void player_task(void *pvParameters) { if ((scSet.sr != __scSet.sr) || (scSet.bits != __scSet.bits) || (scSet.ch != __scSet.ch)) { + ESP_LOGI(TAG, "reinitializing i2s with new settings: sample rate %ld, ch %d, bits %d", + __scSet.sr, __scSet.ch, __scSet.bits); my_i2s_channel_enable(tx_chan); audio_set_mute(true); my_i2s_channel_disable(tx_chan); @@ -2179,11 +2095,6 @@ static void player_task(void *pvParameters) { lock_i2s(false, 0); } xSemaphoreTake(playerStateMux, portMAX_DELAY); - xSemaphoreTake(snapcastSettingsMux, portMAX_DELAY); - // delete the queue - vQueueDelete(snapcastSettingQueueHandle); - snapcastSettingQueueHandle = NULL; - xSemaphoreGive(snapcastSettingsMux); #if CONFIG_PM_ENABLE esp_pm_lock_release(player_pm_lock_handle); diff --git a/main/main.c b/main/main.c index c895c49e..07850886 100644 --- a/main/main.c +++ b/main/main.c @@ -109,6 +109,13 @@ SemaphoreHandle_t timeSyncSemaphoreHandle = NULL; static SemaphoreHandle_t idCounterSemaphoreHandle = NULL; static SemaphoreHandle_t snapcastStateChangedMutex = NULL; +typedef struct snapcastSetting_s { + playerSetting_t playerSetting; + + bool muted; + uint32_t volume; +} snapcastSetting_t; + typedef struct audioDACdata_s { bool playerMute; bool stateMute; @@ -317,10 +324,10 @@ void time_sync_msg_received(base_message_t *base_message_rx, static FLAC__StreamDecoderReadStatus read_callback( const FLAC__StreamDecoder *decoder, FLAC__byte buffer[], size_t *bytes, void *client_data) { - snapcastSetting_t *scSet = (snapcastSetting_t *)client_data; + //snapcastSetting_t *scSet = (snapcastSetting_t *)client_data; // decoderData_t *flacData; - (void)scSet; + //(void)scSet; // xQueueReceive(decoderReadQHdl, &flacData, portMAX_DELAY); // if (xQueueReceive(decoderReadQHdl, &flacData, pdMS_TO_TICKS(100))) @@ -386,7 +393,7 @@ static FLAC__StreamDecoderWriteStatus write_callback( const FLAC__StreamDecoder *decoder, const FLAC__Frame *frame, const FLAC__int32 *const buffer[], void *client_data) { size_t i; - snapcastSetting_t *scSet = (snapcastSetting_t *)client_data; + playerSetting_t *scSet = (playerSetting_t *)client_data; size_t bytes = frame->header.blocksize * frame->header.channels * frame->header.bits_per_sample / 8; @@ -473,7 +480,7 @@ static FLAC__StreamDecoderWriteStatus write_callback( void metadata_callback(const FLAC__StreamDecoder *decoder, const FLAC__StreamMetadata *metadata, void *client_data) { - snapcastSetting_t *scSet = (snapcastSetting_t *)client_data; + playerSetting_t *scSet = (playerSetting_t *)client_data; (void)decoder; @@ -626,12 +633,12 @@ void server_settings_msg_received( scSet->muted = server_settings_message->muted; scSet->volume = server_settings_message->volume; - if (scSet->cDacLat_ms != server_settings_message->latency || - scSet->buf_ms != server_settings_message->buffer_ms) { - scSet->cDacLat_ms = server_settings_message->latency; - scSet->buf_ms = server_settings_message->buffer_ms; + if (scSet->playerSetting.cDacLat_ms != server_settings_message->latency || + scSet->playerSetting.buf_ms != server_settings_message->buffer_ms) { + scSet->playerSetting.cDacLat_ms = server_settings_message->latency; + scSet->playerSetting.buf_ms = server_settings_message->buffer_ms; - if (player_send_snapcast_setting(scSet) != pdPASS) { + if (playing && player_send_snapcast_setting(&(scSet->playerSetting)) != pdPASS) { ESP_LOGE(TAG, "Failed to notify sync task. " "Did you init player?"); @@ -646,7 +653,7 @@ void server_settings_msg_received( * */ void codec_header_received(char *codecPayload, uint32_t codecPayloadLen, - codec_type_t codec, snapcastSetting_t *scSet, + codec_type_t codec, playerSetting_t *scSet, time_sync_data_t *time_sync_data) { // first ensure everything is set up // correctly and resources are @@ -672,7 +679,6 @@ void codec_header_received(char *codecPayload, uint32_t codecPayloadLen, memcpy(&bits, codecPayload + 8, sizeof(bits)); memcpy(&channels, codecPayload + 10, sizeof(channels)); - scSet->codec = codec; scSet->bits = bits; scSet->ch = channels; scSet->sr = rate; @@ -731,7 +737,6 @@ void codec_header_received(char *codecPayload, uint32_t codecPayloadLen, memcpy(&rate, codecPayload + 24, sizeof(rate)); memcpy(&bits, codecPayload + 34, sizeof(bits)); - scSet->codec = codec; scSet->bits = bits; scSet->ch = channels; scSet->sr = rate; @@ -769,9 +774,10 @@ void codec_header_received(char *codecPayload, uint32_t codecPayloadLen, /** * */ -void handle_chunk_message(codec_type_t codec, snapcastSetting_t *scSet, +void handle_chunk_message(codec_type_t codec, playerSetting_t *scSet, pcm_chunk_message_t **pcmData, wire_chunk_message_t *wire_chnk) { + static uint32_t chkInFrames = 0; switch (codec) { case OPUS: { int frame_size = -1; @@ -827,6 +833,20 @@ void handle_chunk_message(codec_type_t codec, snapcastSetting_t *scSet, // ESP_LOGW(TAG, "OPUS decode: %d", frame_size); + if (chkInFrames != scSet->chkInFrames) { + if (player_send_snapcast_setting(scSet) != pdPASS) { + ESP_LOGE(TAG, + "Failed to notify " + "sync task about " + "codec. Did you " + "init player?"); + + // critical error + esp_restart(); + } + chkInFrames = scSet->chkInFrames; + } + if (allocate_pcm_chunk_memory(&new_pcmChunk, bytes) < 0) { *pcmData = NULL; } else { @@ -853,24 +873,15 @@ void handle_chunk_message(codec_type_t codec, snapcastSetting_t *scSet, #if CONFIG_USE_DSP_PROCESSOR if (new_pcmChunk->fragment->payload) { - dsp_processor_worker((void *)new_pcmChunk, (void *)scSet); + dsp_processor_worker(new_pcmChunk->fragment->payload, + new_pcmChunk->fragment->size / ((scSet->bits / 8) * scSet->ch), + scSet->sr, scSet->ch); } #endif 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?"); - - // critical error - esp_restart(); - } - break; } @@ -914,6 +925,21 @@ void handle_chunk_message(codec_type_t codec, snapcastSetting_t *scSet, // ESP_LOGI(TAG, "new_pcmChunk with size %ld", // new_pcmChunk->totalSize); + + if (chkInFrames != scSet->chkInFrames) { + if (player_send_snapcast_setting(scSet) != pdPASS) { + ESP_LOGE(TAG, + "Failed to notify " + "sync task about " + "codec. Did you " + "init player?"); + + // critical error + esp_restart(); + } + chkInFrames = scSet->chkInFrames; + } + if (ret == 0) { pcm_chunk_fragment_t *fragment = new_pcmChunk->fragment; uint32_t fragmentCnt = 0; @@ -948,7 +974,9 @@ void handle_chunk_message(codec_type_t codec, snapcastSetting_t *scSet, #if CONFIG_USE_DSP_PROCESSOR if (new_pcmChunk->fragment->payload) { - dsp_processor_worker((void *)new_pcmChunk, (void *)scSet); + dsp_processor_worker(new_pcmChunk->fragment->payload, + new_pcmChunk->fragment->size / ((scSet->bits / 8) * scSet->ch), + scSet->sr, scSet->ch); } #endif @@ -965,19 +993,6 @@ void handle_chunk_message(codec_type_t codec, snapcastSetting_t *scSet, 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?"); - - // critical error - esp_restart(); - } - break; } @@ -999,20 +1014,26 @@ void handle_chunk_message(codec_type_t codec, snapcastSetting_t *scSet, // "got PCM decoded chunk size: %ld // frames", scSet->chkInFrames); - if (player_send_snapcast_setting(scSet) != pdPASS) { - ESP_LOGE(TAG, - "Failed to notify " - "sync task about " - "codec. Did you " - "init player?"); - // critical error - esp_restart(); + if (chkInFrames != scSet->chkInFrames) { + if (player_send_snapcast_setting(scSet) != pdPASS) { + ESP_LOGE(TAG, + "Failed to notify " + "sync task about " + "codec. Did you " + "init player?"); + + // critical error + esp_restart(); + } + chkInFrames = scSet->chkInFrames; } #if CONFIG_USE_DSP_PROCESSOR if ((*pcmData) && ((*pcmData)->fragment->payload)) { - dsp_processor_worker((void *)(*pcmData), (void *)scSet); + dsp_processor_worker((*pcmData)->fragment->payload, + (*pcmData)->fragment->size / ((scSet->bits / 8) * scSet->ch), + scSet->sr, scSet->ch); } #endif if (*pcmData) { @@ -1115,7 +1136,7 @@ int process_data(snapcast_protocol_parser_t *parser, if (parse_wire_chunk_message(parser, &base_message_rx, *codec, pcmData, &wire_chnk, &decoderChunk) != PARSER_OK) { return -1; } - handle_chunk_message(*codec, scSet, pcmData, &wire_chnk); + handle_chunk_message(*codec, &(scSet->playerSetting), pcmData, &wire_chnk); return 0; } @@ -1126,7 +1147,7 @@ int process_data(snapcast_protocol_parser_t *parser, if (parse_codec_header_message(parser, received_codec_header, codec, &codecPayload, &codecPayloadLen) != PARSER_OK) { return_value = -1; } else { - codec_header_received(codecPayload, codecPayloadLen, *codec, scSet, time_sync_data); + codec_header_received(codecPayload, codecPayloadLen, *codec, &(scSet->playerSetting), time_sync_data); } // in all cases: free Payload @@ -1368,12 +1389,11 @@ static void http_get_task(void *pvParameters) { hello_message_serialized = NULL; // init default setting - scSet.buf_ms = 500; - scSet.codec = NONE; - scSet.bits = 16; - scSet.ch = 2; - scSet.sr = 44100; - scSet.chkInFrames = 0; + scSet.playerSetting.buf_ms = 0; + scSet.playerSetting.bits = 16; + scSet.playerSetting.ch = 2; + scSet.playerSetting.sr = 44100; + scSet.playerSetting.chkInFrames = 0; scSet.muted = true; snapcast_protocol_parser_t parser; From 5170a177be0c9eab13e7eced2be33318ef8989ff Mon Sep 17 00:00:00 2001 From: craigmillard86 Date: Fri, 3 Apr 2026 22:18:30 +0100 Subject: [PATCH 22/28] Fix build break and correctness issues from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove stale player.h include and get_player_state() fallback from network_is_playback_active() — luar123 removed these APIs; the event-bit path (kept in sync by network_state_cb) is sufficient - Debounce network_state_cb: only signal playback_stopped when transitioning from PLAYING/PAUSED, preventing transient IDLE during reconnect cycles from triggering premature ETH takeover - Change outer-loop reconnect log from ESP_LOGD to ESP_LOGI for visibility at default log level - Make playback_old non-static and reset on reconnect to prevent stale state from skipping volume/mute reapplication --- components/network_interface/network_interface.c | 16 ++++------------ main/main.c | 11 ++++++++--- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/components/network_interface/network_interface.c b/components/network_interface/network_interface.c index 94e10b59..55efb742 100644 --- a/components/network_interface/network_interface.c +++ b/components/network_interface/network_interface.c @@ -32,7 +32,6 @@ #endif #include "wifi_interface.h" -#include "../lightsnapcast/include/player.h" static const char *TAG = "NET_IF"; @@ -123,18 +122,11 @@ esp_err_t network_playback_stopped(void) { } bool network_is_playback_active(void) { - // Primary check: Event bits (fast path, set by Layer 1 synchronously) - if (network_event_group) { - EventBits_t bits = xEventGroupGetBits(network_event_group); - if (bits & EVENT_PLAYBACK_STARTED_BIT) { - return true; - } + if (!network_event_group) { + return false; } - - // Fallback: Direct player state check (catches edge cases) - // This protects against race conditions if event bit isn't set yet - player_state_e state = get_player_state(); - return (state == PLAYING); + EventBits_t bits = xEventGroupGetBits(network_event_group); + return (bits & EVENT_PLAYBACK_STARTED_BIT) != 0; } /* types of ipv6 addresses to be displayed on ipv6 events */ diff --git a/main/main.c b/main/main.c index 59ed5e2a..7a7865e5 100644 --- a/main/main.c +++ b/main/main.c @@ -1214,12 +1214,17 @@ void before_receive_callback(before_receive_callback_data_t *data) { void network_state_cb(void) { + static snapcast_state_t prev_state = STOPPED; snapcast_state_t state = sc_get_snapcast_state(); if (state == PLAYING || state == PAUSED) { network_playback_started(); - } else { + } else if (prev_state == PLAYING || prev_state == PAUSED) { + // Only signal stopped when transitioning from an active playback state. + // Prevents transient IDLE during reconnect cycles from triggering + // premature ETH takeover. network_playback_stopped(); } + prev_state = state; } /** @@ -1293,7 +1298,7 @@ static void http_get_task(void *pvParameters) { // If a reconnect was requested but the inner loop exited via TCP error // instead, the server still needs time to tear down the old session. if (network_check_and_clear_reconnect()) { - ESP_LOGD(TAG, "Pending reconnect; waiting 2s for server cleanup"); + ESP_LOGI(TAG, "Pending reconnect; waiting 2s for server cleanup"); vTaskDelay(pdMS_TO_TICKS(2000)); } @@ -1311,6 +1316,7 @@ static void http_get_task(void *pvParameters) { xSemaphoreGive(snapcastStateMux); sc_call_state_cb(); playback = false; + bool playback_old = false; // NETWORK setup ends here ( or before getting mac address ) setup_network(&connection.netif); @@ -1465,7 +1471,6 @@ static void http_get_task(void *pvParameters) { } bool restart = false; - static bool playback_old = false; if (xTaskNotifyWait(0, 0, &command, 1) == pdTRUE) { switch(command) { case STOP: From 5c1977176889ad20d919c156507a69eccc24cd69 Mon Sep 17 00:00:00 2001 From: craigmillard86 Date: Fri, 3 Apr 2026 23:35:49 +0100 Subject: [PATCH 23/28] Replace reconnect polling with sc_restart_snapcast() commands Remove the network_check_and_clear_reconnect() event-bit polling mechanism and use direct sc_restart_snapcast() calls instead. The command-based state machine already has a RESTART command, so a separate polling system is unnecessary. - Replace 5 network_request_reconnect() calls in eth_interface.c with sc_restart_snapcast() - Remove network_request_reconnect() and network_check_and_clear_reconnect() from network_interface.c and headers - Remove 3 polling call sites from main.c inner/outer loops - Move process_data() before command xTaskNotifyWait for better RESTART responsiveness - Add 2s server cleanup delay to RESTART path with cross-reference comments - Add NULL checks after xQueueCreate in player.c (start_player, player_task, and snapcastSettingQueueHandle in init_player) - Add fallthrough annotation for STOP->RESTART switch case - Improve unsupported codec error log with string length --- components/lightsnapcast/player.c | 12 +++ .../lightsnapcast/snapcast_protocol_parser.c | 2 +- components/network_interface/eth_interface.c | 27 +++---- .../include/network_interface.h | 3 - .../network_interface/network_interface.c | 26 ------- .../priv_include/network_interface_priv.h | 3 - main/main.c | 73 ++++++++----------- 7 files changed, 53 insertions(+), 93 deletions(-) diff --git a/components/lightsnapcast/player.c b/components/lightsnapcast/player.c index 3ad0a9dd..cf8aa35c 100644 --- a/components/lightsnapcast/player.c +++ b/components/lightsnapcast/player.c @@ -493,6 +493,10 @@ int init_player(i2s_std_gpio_config_t pin_config0_, i2s_port_t i2sNum_, void (*s // create message queue to inform task of changed settings snapcastSettingQueueHandle = xQueueCreate(1, sizeof(playerSetting_t)); + if (snapcastSettingQueueHandle == NULL) { + ESP_LOGE(TAG, "Failed to create snapcast settings queue"); + return -1; + } if (playerStateMux == NULL) { playerStateMux = xSemaphoreCreateMutex(); @@ -587,6 +591,11 @@ int start_player() { entries -= ((i2sDmaBufMaxLen * i2sDmaBufCnt) / scSet->chkInFrames); pcmChkQHdl = xQueueCreate(entries, sizeof(pcm_chunk_message_t *)); + if (pcmChkQHdl == NULL) { + ESP_LOGE(TAG, "Failed to create pcm chunk queue (%d entries)", entries); + playerStarted = false; + return -1; + } ESP_LOGI(TAG, "created new queue with %d", entries); } @@ -1535,6 +1544,9 @@ static void player_task(void *pvParameters) { queueCreatedWithChkInFrames = __scSet.chkInFrames; pcmChkQHdl = xQueueCreate(entries, sizeof(pcm_chunk_message_t *)); + if (pcmChkQHdl == NULL) { + ESP_LOGE(TAG, "Failed to create pcm chunk queue (%d entries)", entries); + } ESP_LOGI(TAG, "created new queue with %d", entries); } diff --git a/components/lightsnapcast/snapcast_protocol_parser.c b/components/lightsnapcast/snapcast_protocol_parser.c index 53944ffe..92b1154f 100644 --- a/components/lightsnapcast/snapcast_protocol_parser.c +++ b/components/lightsnapcast/snapcast_protocol_parser.c @@ -193,7 +193,7 @@ parser_return_state_t parse_codec_header_message( if (!read_data(parser, (uint8_t *)codecString, sizeof(codecString)-1)) return PARSER_RESTART_CONNECTION; codecString[sizeof(codecString)-1] = 0; // null terminate - ESP_LOGE(TAG, "Codec : %s... not supported", codecString); + ESP_LOGE(TAG, "Codec : %s... not supported (length: %lu)", codecString, codecStringLen); ESP_LOGI(TAG, "Change encoder codec to " "opus, flac or pcm in " diff --git a/components/network_interface/eth_interface.c b/components/network_interface/eth_interface.c index 4d6f9acb..68913aa7 100644 --- a/components/network_interface/eth_interface.c +++ b/components/network_interface/eth_interface.c @@ -31,6 +31,8 @@ #include "network_interface_priv.h" #include "settings_manager.h" +extern void sc_restart_snapcast(void); + static const char *TAG = "ETH_IF"; /* ============ Event Bit for Playback Monitor Shutdown ============ */ @@ -866,17 +868,13 @@ static void eth_check_and_apply_takeover(esp_netif_t *netif) { esp_netif_dhcpc_stop(netif); esp_netif_dhcpc_start(netif); - if (network_request_reconnect() != ESP_OK) { - ESP_LOGW(TAG, "Failed to request reconnect after takeover"); - } + sc_restart_snapcast(); } else { // No MAC unification needed - just complete takeover 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"); - } + sc_restart_snapcast(); } } else if (want_eth_takeover && network_is_playback_active()) { ESP_LOGI(TAG, "Playback active; deferring Ethernet takeover until playback stops"); @@ -1176,9 +1174,7 @@ static void eth_event_handler(void *arg, esp_event_base_t event_base, esp_netif_set_default_netif(sta_netif); } /* 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"); - } + sc_restart_snapcast(); } else { /* Preserve want_eth_takeover on brief disconnect - if Ethernet reconnects * quickly, we still want to complete the takeover. Only clear if we @@ -1379,11 +1375,12 @@ static void eth_on_playback_stopped(void) { } } - if (network_request_reconnect() != ESP_OK) { - ESP_LOGW(TAG, "Failed to request reconnect"); - } + sc_restart_snapcast(); - // Wait for main loop to close old connection (2s) + server cleanup + // Wait for main loop to close old connection + server cleanup. + // The main loop's RESTART path delays 2000ms after socket close + // (see http_get_task in main.c). This must exceed that to ensure + // the socket is fully torn down before we suppress WiFi below. vTaskDelay(pdMS_TO_TICKS(2500)); // Now safe to suppress WiFi and apply unified MAC @@ -1478,9 +1475,7 @@ static void eth_on_playback_stopped(void) { 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"); - } + sc_restart_snapcast(); } else { ESP_LOGE(TAG, "Failed to set default netif: %s", esp_err_to_name(err)); // Restore takeover intent so it can be retried diff --git a/components/network_interface/include/network_interface.h b/components/network_interface/include/network_interface.h index c1905dab..b046bcdc 100644 --- a/components/network_interface/include/network_interface.h +++ b/components/network_interface/include/network_interface.h @@ -47,9 +47,6 @@ void network_if_init(void); /** 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); diff --git a/components/network_interface/network_interface.c b/components/network_interface/network_interface.c index 55efb742..f0d9f89c 100644 --- a/components/network_interface/network_interface.c +++ b/components/network_interface/network_interface.c @@ -41,7 +41,6 @@ static bool unified_mac_initialized = false; static SemaphoreHandle_t mac_mutex = NULL; /* ============ 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 */ @@ -76,31 +75,6 @@ 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"); diff --git a/components/network_interface/priv_include/network_interface_priv.h b/components/network_interface/priv_include/network_interface_priv.h index 8ab018ff..fdba0ee7 100644 --- a/components/network_interface/priv_include/network_interface_priv.h +++ b/components/network_interface/priv_include/network_interface_priv.h @@ -23,9 +23,6 @@ /** 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); diff --git a/main/main.c b/main/main.c index 7a7865e5..5f963068 100644 --- a/main/main.c +++ b/main/main.c @@ -1295,13 +1295,6 @@ static void http_get_task(void *pvParameters) { } } - // If a reconnect was requested but the inner loop exited via TCP error - // instead, the server still needs time to tear down the old session. - if (network_check_and_clear_reconnect()) { - ESP_LOGI(TAG, "Pending reconnect; waiting 2s for server cleanup"); - vTaskDelay(pdMS_TO_TICKS(2000)); - } - // block if state = STOPPED xSemaphoreTake(snapcastStateMux, portMAX_DELAY); if (sc_state == STOPPED) { @@ -1452,22 +1445,33 @@ static void http_get_task(void *pvParameters) { netconn_set_recvtimeout(lwipNetconn, time_sync_data.timeout / 1000); // timeout in ms - // Drain any reconnect request that arrived while we were connecting - // (e.g., boot-time MAC unification fires reconnect during mDNS/TCP setup). - // No delay needed: no prior server session exists to clean up. - network_check_and_clear_reconnect(); - // Main connection loop - state machine + data processing paused = false; while (1) { - // Check if external module requested reconnect (e.g., ethernet takeover) - if (network_check_and_clear_reconnect()) { - ESP_LOGI(TAG, "Reconnect requested, closing connection"); - netconn_close(lwipNetconn); - netconn_delete(lwipNetconn); - lwipNetconn = NULL; - vTaskDelay(pdMS_TO_TICKS(2000)); // let server clean up - break; + // Process network data first — makes RESTART more responsive by + // handling it immediately after the blocking recv returns. + int result = + process_data(&parser, &time_sync_data, &received_codec_header, &codec, + &scSet, &pcmData, &playback, paused); + if (result != 0) { + break; // restart connection + } + + if (playback_old != playback) { + if (playback) { + // need to apply settings when starting to play +#if SNAPCAST_USE_SOFT_VOL + if (!scSet.muted) { + dsp_processor_set_volome((double)scSet.volume / 100); + } else { + dsp_processor_set_volome(0.0); + } +#else + set_volume_cb(scSet.volume); +#endif + set_mute_state(scSet.muted); + } + playback_old = playback; } bool restart = false; @@ -1478,6 +1482,7 @@ static void http_get_task(void *pvParameters) { sc_state = STOPPED; xSemaphoreGive(snapcastStateMux); sc_call_state_cb(); + /* fall through — STOP also needs to close the connection */ case RESTART: restart = true; break; @@ -1497,32 +1502,12 @@ static void http_get_task(void *pvParameters) { netconn_close(lwipNetconn); netconn_delete(lwipNetconn); lwipNetconn = NULL; + // Let server detect disconnect and clean up old session. + // Note: eth_interface.c's deferred takeover path waits 2500ms for this + // to complete before suppressing WiFi — keep this >= that expectation. + vTaskDelay(pdMS_TO_TICKS(2000)); break; // restart connection } - - if (playback_old != playback) { - if (playback) { - // need to apply settings when starting to play -#if SNAPCAST_USE_SOFT_VOL - if (!scSet.muted) { - dsp_processor_set_volome((double)scSet.volume / 100); - } else { - dsp_processor_set_volome(0.0); - } -#else - set_volume_cb(scSet.volume); -#endif - set_mute_state(scSet.muted); - } - playback_old = playback; - } - - int result = - process_data(&parser, &time_sync_data, &received_codec_header, &codec, - &scSet, &pcmData, &playback, paused); - if (result != 0) { - break; // restart connection - } } } } From c4250e8fd8648bf36c1623a623917fce4581a13f Mon Sep 17 00:00:00 2001 From: craigmillard86 Date: Tue, 7 Apr 2026 23:30:15 +0100 Subject: [PATCH 24/28] Resolve luar123 review comments on reconnect polling PR - Add timer/PM cleanup on queue creation failure in start_player() - Move queue info log inside else block in player_task - Only treat PLAYING (not PAUSED) as active in network_state_cb - Preserve paused state across reconnects - Reorder inner loop: check commands before process_data, add post-recv RESTART detection with 2s delay - Move network init before audio/codec init for faster startup - Restore develop branch PR text in README --- README.md | 2 +- components/lightsnapcast/player.c | 8 +++-- main/main.c | 50 +++++++++++++------------------ 3 files changed, 27 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index bb52d5c7..a12d6e62 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,7 @@ Replace `snapclient.local` with your clients IP address. If you have multiple cl You are very welcome to help and provide [Pull Requests](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests) -to the project. +to the project. Use [develop](https://github.com/jorgenkraghjakobsen/snapclient/tree/develop) branch for your PRs as this is the place where new features will go. We strongly suggest you activate [pre-commit](https://pre-commit.com) hooks in this git repository before starting to hack and make commits. diff --git a/components/lightsnapcast/player.c b/components/lightsnapcast/player.c index cf8aa35c..0906fb30 100644 --- a/components/lightsnapcast/player.c +++ b/components/lightsnapcast/player.c @@ -593,6 +593,10 @@ int start_player() { pcmChkQHdl = xQueueCreate(entries, sizeof(pcm_chunk_message_t *)); if (pcmChkQHdl == NULL) { ESP_LOGE(TAG, "Failed to create pcm chunk queue (%d entries)", entries); + tg0_timer_deinit(); +#if CONFIG_PM_ENABLE + esp_pm_lock_release(player_pm_lock_handle); +#endif playerStarted = false; return -1; } @@ -1546,9 +1550,9 @@ static void player_task(void *pvParameters) { pcmChkQHdl = xQueueCreate(entries, sizeof(pcm_chunk_message_t *)); if (pcmChkQHdl == NULL) { ESP_LOGE(TAG, "Failed to create pcm chunk queue (%d entries)", entries); + } else { + ESP_LOGI(TAG, "created new queue with %d", entries); } - - ESP_LOGI(TAG, "created new queue with %d", entries); } if ((scSet.sr != __scSet.sr) || (scSet.bits != __scSet.bits) || diff --git a/main/main.c b/main/main.c index 5f963068..7b507123 100644 --- a/main/main.c +++ b/main/main.c @@ -1216,12 +1216,9 @@ void before_receive_callback(before_receive_callback_data_t *data) { void network_state_cb(void) { static snapcast_state_t prev_state = STOPPED; snapcast_state_t state = sc_get_snapcast_state(); - if (state == PLAYING || state == PAUSED) { + if (state == PLAYING) { network_playback_started(); - } else if (prev_state == PLAYING || prev_state == PAUSED) { - // Only signal stopped when transitioning from an active playback state. - // Prevents transient IDLE during reconnect cycles from triggering - // premature ETH takeover. + } else if (prev_state == PLAYING) { network_playback_stopped(); } prev_state = state; @@ -1446,17 +1443,7 @@ static void http_get_task(void *pvParameters) { // Main connection loop - state machine + data processing - paused = false; while (1) { - // Process network data first — makes RESTART more responsive by - // handling it immediately after the blocking recv returns. - int result = - process_data(&parser, &time_sync_data, &received_codec_header, &codec, - &scSet, &pcmData, &playback, paused); - if (result != 0) { - break; // restart connection - } - if (playback_old != playback) { if (playback) { // need to apply settings when starting to play @@ -1495,18 +1482,25 @@ static void http_get_task(void *pvParameters) { default: break; } - //ESP_LOGI(TAG, "http got cb. %s", paused ? "paused" : "playing/idle"); } if (restart) { - //restart required netconn_close(lwipNetconn); netconn_delete(lwipNetconn); lwipNetconn = NULL; - // Let server detect disconnect and clean up old session. - // Note: eth_interface.c's deferred takeover path waits 2500ms for this - // to complete before suppressing WiFi — keep this >= that expectation. vTaskDelay(pdMS_TO_TICKS(2000)); - break; // restart connection + break; + } + + int result = + process_data(&parser, &time_sync_data, &received_codec_header, &codec, + &scSet, &pcmData, &playback, paused); + if (result != 0) { + // Check if a RESTART arrived during the blocking recv + if (xTaskNotifyWait(0, 0, &command, 0) == pdTRUE && + (command == RESTART || command == STOP)) { + vTaskDelay(pdMS_TO_TICKS(2000)); + } + break; } } } @@ -1678,6 +1672,11 @@ void app_main(void) { board_i2s_pin_t pin_config0; get_i2s_pins(I2S_NUM_0, &pin_config0); + // Initialize settings and network early so connection starts during codec init + settings_manager_init(); + network_events_init(); + network_if_init(); + #if CONFIG_AUDIO_BOARD_CUSTOM && CONFIG_DAC_ADAU1961 // some codecs need i2s mclk for initialization @@ -1804,15 +1803,6 @@ void app_main(void) { } #endif - // 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}; if (settings_get_hostname(mdns_hostname, sizeof(mdns_hostname)) != ESP_OK) { From f840181a3f7c2eaa4989e921a2530fa8d8148c65 Mon Sep 17 00:00:00 2001 From: craigmillard86 Date: Wed, 8 Apr 2026 23:35:49 +0100 Subject: [PATCH 25/28] Fix audio not restarting after network reconnection Static variables in update_state() and process_data() persisted across connection cycles, causing the playback state tracker to remain PLAYING after a disconnect. On reconnect, update_state() never transitioned to PLAYING again so the codec was never started and audio was silent. Move received_wire_chnk, state tracker, and last-update timestamp from static locals to connection-scoped variables in http_get_task(), passed by pointer through process_data() and update_state(). They now reset naturally each time the outer connection loop iterates. --- main/main.c | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/main/main.c b/main/main.c index 7b507123..4257008b 100644 --- a/main/main.c +++ b/main/main.c @@ -1060,10 +1060,9 @@ void handle_chunk_message(codec_type_t codec, playerSetting_t *scSet, } } -void update_state(bool *received_wire_chnk, bool *playback, bool paused) { - static int64_t last = 0; - static snapcast_state_t state = IDLE; //Todo - if ((paused || state != PLAYING) && (!paused || state != PAUSED) && *received_wire_chnk) { +void update_state(bool *received_wire_chnk, bool *playback, bool paused, + snapcast_state_t *state, int64_t *last) { + if ((paused || *state != PLAYING) && (!paused || *state != PAUSED) && *received_wire_chnk) { xSemaphoreTake(snapcastStateMux, portMAX_DELAY); if (paused) { sc_state = PAUSED; @@ -1074,29 +1073,29 @@ void update_state(bool *received_wire_chnk, bool *playback, bool paused) { ESP_LOGI(TAG, "Set playing"); *playback = true; } - state = sc_state; + *state = sc_state; xSemaphoreGive(snapcastStateMux); sc_call_state_cb(); - last = esp_timer_get_time(); + *last = esp_timer_get_time(); *received_wire_chnk = false; } - else if (state == PLAYING || state == PAUSED) { + else if (*state == PLAYING || *state == PAUSED) { int64_t now = esp_timer_get_time(); - if (now-last > 1000000) { //update once per sec + if (now - *last > 1000000) { //update once per sec if (!(*received_wire_chnk)) { xSemaphoreTake(snapcastStateMux, portMAX_DELAY); sc_state = IDLE; *playback = false; - state = sc_state; + *state = sc_state; xSemaphoreGive(snapcastStateMux); sc_call_state_cb(); ESP_LOGI(TAG, "Set idle"); } - last = now; + *last = now; *received_wire_chnk = false; } } - + } @@ -1108,11 +1107,12 @@ void update_state(bool *received_wire_chnk, bool *playback, bool paused) { int process_data(snapcast_protocol_parser_t *parser, time_sync_data_t *time_sync_data, bool *received_codec_header, codec_type_t *codec, snapcastSetting_t *scSet, - pcm_chunk_message_t **pcmData, bool *playback, bool paused) { + pcm_chunk_message_t **pcmData, bool *playback, bool paused, + bool *received_wire_chnk, snapcast_state_t *update_state_tracker, + int64_t *update_last) { base_message_t base_message_rx; - static bool received_wire_chnk = false; - update_state(&received_wire_chnk, playback, paused); + update_state(received_wire_chnk, playback, paused, update_state_tracker, update_last); if (parse_base_message(parser, &base_message_rx) != PARSER_OK) { return -1; // restart connection @@ -1125,7 +1125,7 @@ int process_data(snapcast_protocol_parser_t *parser, switch (base_message_rx.type) { case SNAPCAST_MESSAGE_WIRE_CHUNK: { wire_chunk_message_t wire_chnk = {{0, 0}, 0, NULL}; // is wire_chnk.payload ever used? - received_wire_chnk = true; + *received_wire_chnk = true; // skip this wires chunk message if codec header message was not received yet! if (*received_codec_header == false || paused) { if (parser_skip_typed_message(parser, &base_message_rx) != PARSER_OK) { @@ -1442,6 +1442,11 @@ static void http_get_task(void *pvParameters) { netconn_set_recvtimeout(lwipNetconn, time_sync_data.timeout / 1000); // timeout in ms + // Connection-scoped state for update_state/process_data + bool received_wire_chnk = false; + snapcast_state_t update_state_tracker = IDLE; + int64_t update_last = 0; + // Main connection loop - state machine + data processing while (1) { if (playback_old != playback) { @@ -1493,7 +1498,8 @@ static void http_get_task(void *pvParameters) { int result = process_data(&parser, &time_sync_data, &received_codec_header, &codec, - &scSet, &pcmData, &playback, paused); + &scSet, &pcmData, &playback, paused, + &received_wire_chnk, &update_state_tracker, &update_last); if (result != 0) { // Check if a RESTART arrived during the blocking recv if (xTaskNotifyWait(0, 0, &command, 0) == pdTRUE && From f138c63dc64c96d15c0ec7762eaa87b10c8f5c91 Mon Sep 17 00:00:00 2001 From: craigmillard86 Date: Thu, 9 Apr 2026 00:07:47 +0100 Subject: [PATCH 26/28] Fix Ethernet reconnection: restart DHCP and clean up IPv6 on disconnect Two issues prevented reliable Ethernet reconnection: 1. DHCP was stopped in the DISCONNECTED handler but never restarted in the CONNECTED handler for DHCP mode. On reconnect, ESP-IDF's internal handler took the static-IP path ("invalid static ip") and no IPv4 address was obtained. 2. IPv6 link-local addresses were not removed on disconnect. On the next Link Up, esp_netif_create_ip6_linklocal() operated on stale IPv6 state, increasing stack usage and causing a sys_evt stack overflow. --- components/network_interface/eth_interface.c | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/components/network_interface/eth_interface.c b/components/network_interface/eth_interface.c index 68913aa7..51b44484 100644 --- a/components/network_interface/eth_interface.c +++ b/components/network_interface/eth_interface.c @@ -1105,6 +1105,16 @@ static void eth_event_handler(void *arg, esp_event_base_t event_base, mac_unification_pending = true; mac_unification_netif = netif; xSemaphoreGive(connIpSemaphoreHandle); + + // Restart DHCP client — it was stopped on disconnect (see + // ETHERNET_EVENT_DISCONNECTED) and won't resume automatically. + // Without this, ESP-IDF's internal connected handler takes the + // static-IP path ("invalid static ip") and no IPv4 is obtained. + 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_LOGW(TAG, "Failed to restart DHCP on reconnect: %s", esp_err_to_name(dhcp_err)); + } + ESP_LOGI(TAG, "DHCP mode: MAC unification deferred until takeover..."); } @@ -1134,6 +1144,20 @@ static void eth_event_handler(void *arg, esp_event_base_t event_base, mac_unification_pending = false; mac_unification_netif = NULL; + // Clear stale IPv6 addresses so the next Link Up starts fresh. + // Without this, esp_netif_create_ip6_linklocal() operates on a + // netif with leftover IPv6 state, increasing stack usage in the + // sys_evt task and causing a stack overflow on reconnection. + { + esp_ip6_addr_t ip6; + if (esp_netif_get_ip6_linklocal(netif, &ip6) == ESP_OK) { + esp_netif_remove_ip6_address(netif, &ip6); + } + if (esp_netif_get_ip6_global(netif, &ip6) == ESP_OK) { + esp_netif_remove_ip6_address(netif, &ip6); + } + } + // Revert Ethernet to temp MAC so next Link Up doesn't have the same // MAC as WiFi (which causes switch MAC flapping on both ports) { From 2842a65fcaf6a9d76b21103cab18521ab6b22ae9 Mon Sep 17 00:00:00 2001 From: craigmillard86 Date: Tue, 26 May 2026 23:24:00 +0100 Subject: [PATCH 27/28] Revert reset_connection_state helper and hoisted statics The reset helper was added in the upstream/develop merge to clear update_state / process_data state between snapclient connections (the previously-removed connection-scoped locals had served this purpose). On review, upstream's function-local statics work fine in practice -- sc_state remains the source of truth, callbacks self-correct on next chunk, and the ~1s staleness window after reconnect has no observable impact. Reverting reduces drift from upstream and removes a future merge-conflict hotspot. Build: idf:v5.5.1 / esp32s3 OK (snapclient.bin 0x134ba0 bytes). Co-Authored-By: Claude Opus 4.7 (1M context) --- main/main.c | 35 ++++++++++++----------------------- 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/main/main.c b/main/main.c index ae28373e..a79a1e7d 100644 --- a/main/main.c +++ b/main/main.c @@ -1079,21 +1079,10 @@ void handle_chunk_message(codec_type_t codec, playerSetting_t *scSet, } } -// File-scope statics for update_state / process_data so reset_connection_state() -// can clear them between snapclient connections (otherwise stale "playing" state -// from the previous connection can survive for ~1s into the new one). -static int64_t update_state_last = 0; -static snapclient_state_t update_state_local = IDLE; -static bool process_data_received_wire_chnk = false; - -void reset_connection_state(void) { - update_state_last = 0; - update_state_local = IDLE; - process_data_received_wire_chnk = false; -} - void update_state(bool *received_wire_chnk, bool *playback, bool paused) { - if ((paused || update_state_local != PLAYING) && (!paused || update_state_local != PAUSED) && *received_wire_chnk) { + static int64_t last = 0; + static snapclient_state_t state = IDLE; //Todo + if ((paused || state != PLAYING) && (!paused || state != PAUSED) && *received_wire_chnk) { xSemaphoreTake(snapclientStateMux, portMAX_DELAY); if (paused) { sc_state = PAUSED; @@ -1104,25 +1093,25 @@ void update_state(bool *received_wire_chnk, bool *playback, bool paused) { ESP_LOGI(TAG, "Set playing"); *playback = true; } - update_state_local = sc_state; + state = sc_state; xSemaphoreGive(snapclientStateMux); sc_call_state_cb(); - update_state_last = esp_timer_get_time(); + last = esp_timer_get_time(); *received_wire_chnk = false; } - else if (update_state_local == PLAYING || update_state_local == PAUSED) { + else if (state == PLAYING || state == PAUSED) { int64_t now = esp_timer_get_time(); - if (now - update_state_last > 1000000) { //update once per sec + if (now-last > 1000000) { //update once per sec if (!(*received_wire_chnk)) { xSemaphoreTake(snapclientStateMux, portMAX_DELAY); sc_state = IDLE; *playback = false; - update_state_local = sc_state; + state = sc_state; xSemaphoreGive(snapclientStateMux); sc_call_state_cb(); ESP_LOGI(TAG, "Set idle"); } - update_state_last = now; + last = now; *received_wire_chnk = false; } } @@ -1141,7 +1130,8 @@ int process_data(snapcast_protocol_parser_t *parser, pcm_chunk_message_t **pcmData, bool *playback, bool paused) { base_message_t base_message_rx; - update_state(&process_data_received_wire_chnk, playback, paused); + static bool received_wire_chnk = false; + update_state(&received_wire_chnk, playback, paused); if (parse_base_message(parser, &base_message_rx) != PARSER_OK) { return -1; // restart connection @@ -1154,7 +1144,7 @@ int process_data(snapcast_protocol_parser_t *parser, switch (base_message_rx.type) { case SNAPCAST_MESSAGE_WIRE_CHUNK: { wire_chunk_message_t wire_chnk = {{0, 0}, 0, NULL}; // is wire_chnk.payload ever used? - process_data_received_wire_chnk = true; + received_wire_chnk = true; // skip this wires chunk message if codec header message was not received yet! if (*received_codec_header == false || paused) { if (parser_skip_typed_message(parser, &base_message_rx) != PARSER_OK) { @@ -1470,7 +1460,6 @@ static void http_get_task(void *pvParameters) { time_sync_data.timeout = FAST_SYNC_LATENCY_BUF; netconn_set_recvtimeout(lwipNetconn, time_sync_data.timeout / 1000); // timeout in ms - reset_connection_state(); // Main connection loop - state machine + data processing while (1) { From 9afbedd6bf47a66da0ac05e25932fbb76c844298 Mon Sep 17 00:00:00 2001 From: craigmillard86 Date: Wed, 27 May 2026 00:01:16 +0100 Subject: [PATCH 28/28] Address Copilot review comments on PR #225 - main.c: replace two sprintf() MAC formatters with snprintf() - settings_manager.c: treat ESP_ERR_NVS_NOT_FOUND as success on erase across the four eth_* setters (idempotent clear) - ui_http_server.c: switch on returned err in five eth_* POST handlers so ESP_ERR_INVALID_ARG maps to 400 Bad Request and storage failures map to 500 Internal Server Error - general-settings.html: always send eth_dns on save (empty string is treated by backend as erase) so clearing the field actually clears the persisted value - eth_interface.c: defer s_eth_handles assignment until after all netif setup succeeds, avoiding dangling-pointer use-after-free in eth_apply_unified_mac() if a later init step fails and cleans up the handle array - player.c: use temp + swap when recreating pcmChkQHdl in player_task; abort the task on creation failure rather than continuing with a NULL handle (matches existing pattern after player_setup_i2s failure) - README.md: drop all README changes vs upstream/develop -- the file is upstream-owned content and this feature branch should not be modifying it. Copilot's environnement->environment typo catch is valid but should be addressed via a separate upstream PR rather than carried here. Not changed: the xTaskNotifyWait(0, 0, ...) call at main.c:1521. Notifications here are value-semantics (eSetValueWithOverwrite); FreeRTOS clears the notification pending state on successful xTaskNotifyWait return regardless of ulBitsToClearOnExit, so the comment's "re-observed repeatedly" concern does not apply here. Build: idf:v5.5.1 / esp32s3 OK. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 36 ++++++++++-------- components/lightsnapcast/player.c | 17 +++++---- components/network_interface/eth_interface.c | 10 +++-- .../settings_manager/settings_manager.c | 4 ++ .../ui_http_server/html/general-settings.html | 8 ++-- components/ui_http_server/ui_http_server.c | 38 ++++++++++++++----- main/main.c | 10 +++-- 7 files changed, 80 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index a12d6e62..1b0ddf48 100644 --- a/README.md +++ b/README.md @@ -108,11 +108,23 @@ Update third party code (opus, flac, esp-dsp, improv_wifi): ``` git submodule update --init ``` +Copy one of the template sdkconfig files and rename it to sdkconfig... -### ESP-IDF environnement configuration +...on Linux: +``` +cp sdkconfig_lyrat_v4.3 sdkconfig +``` + +...on Windows: +``` +copy sdkconfig_lyrat_v4.3 sdkconfig +``` + +### ESP-IDF environment setup (required for configuration, compiling and flashing) - If you're on Windows : Install [ESP-IDF v5.5.1](https://github.com/espressif/esp-idf/releases/tag/v5.5.1) locally ([More info](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/get-started/windows-setup-update.html)). -- If you're on Linux (docker) : Use the image for ESP-IDF by following [docker build](doc/docker_build.md) doc +- If you're on Linux (docker) : Use the image for ESP-IDF by following [docker build](doc/docker_build.md) doc (you won't need any of the remaining commands/steps below up until the Test section then) - If you're on Linux : follow [official Espressif](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/get-started/linux-macos-setup.html) instructions + For debian based systems you'll need to do the following: ``` sudo apt-get install git wget flex bison gperf python3 python3-pip python3-venv cmake ninja-build ccache libffi-dev libssl-dev dfu-util libusb-1.0-0 @@ -124,24 +136,16 @@ git submodule update --init . ./export.sh ``` - -### Snapcast ESP Configuration -Start with the default config (remove any existing sdkconfig file) or copy one of the template sdkconfig files and rename it to sdkconfig +### Snapcast ESP Configuration (Non-Docker-Linux and Windows) -``` -rm sdkconfig -``` -or -``` -cp sdkconfig_lyrat_v4.3 sdkconfig -``` - -then configure your platform: +Configure your platform: ``` idf.py menuconfig ``` -Configure to match your setup + + +Choose configuration options to match your setup - Audio HAL : Choose your audio board - Lyrat (4.3, 4.2) - Lyrat TD (2.2, 2.1) @@ -231,7 +235,7 @@ Replace `snapclient.local` with your clients IP address. If you have multiple cl You are very welcome to help and provide [Pull Requests](https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/about-pull-requests) -to the project. Use [develop](https://github.com/jorgenkraghjakobsen/snapclient/tree/develop) branch for your PRs as this is the place where new features will go. +to the project. Use [develop](https://github.com/CarlosDerSeher/snapclient/tree/develop) branch for your PRs as this is the place where new features will go. We strongly suggest you activate [pre-commit](https://pre-commit.com) hooks in this git repository before starting to hack and make commits. diff --git a/components/lightsnapcast/player.c b/components/lightsnapcast/player.c index a4bf1ec1..e32b6da6 100644 --- a/components/lightsnapcast/player.c +++ b/components/lightsnapcast/player.c @@ -1558,14 +1558,17 @@ static void player_task(void *pvParameters) { // so we can save a little RAM here entries -= (i2sDmaBufMaxLen * i2sDmaBufCnt) / __scSet.chkInFrames; - queueCreatedWithChkInFrames = __scSet.chkInFrames; - - pcmChkQHdl = xQueueCreate(entries, sizeof(pcm_chunk_message_t *)); - if (pcmChkQHdl == NULL) { - ESP_LOGE(TAG, "Failed to create pcm chunk queue (%d entries)", entries); - } else { - ESP_LOGI(TAG, "created new queue with %d", entries); + QueueHandle_t newQHdl = xQueueCreate(entries, sizeof(pcm_chunk_message_t *)); + if (newQHdl == NULL) { + // Don't overwrite pcmChkQHdl with NULL — surrounding code (and other + // tasks) would then dereference a NULL handle. Abort the player task + // here, same pattern as the player_setup_i2s failure above. + ESP_LOGE(TAG, "Failed to create pcm chunk queue (%d entries); aborting player task", entries); + return; } + queueCreatedWithChkInFrames = __scSet.chkInFrames; + pcmChkQHdl = newQHdl; + ESP_LOGI(TAG, "created new queue with %d", entries); } if ((scSet.sr != __scSet.sr) || (scSet.bits != __scSet.bits) || diff --git a/components/network_interface/eth_interface.c b/components/network_interface/eth_interface.c index 397fe3a3..99d1ea7c 100644 --- a/components/network_interface/eth_interface.c +++ b/components/network_interface/eth_interface.c @@ -1563,9 +1563,6 @@ void eth_start(void) { return; } - // Save handles for deferred MAC unification - s_eth_handles = eth_handles; - #if CONFIG_SNAPCLIENT_USE_INTERNAL_ETHERNET || CONFIG_SNAPCLIENT_USE_SPI_ETHERNET esp_netif_t *eth_netif = NULL; @@ -1670,6 +1667,13 @@ void eth_start(void) { } } + // All netif creation/glue/attach paths above succeeded — only now is it safe + // to publish the driver handle array. Earlier cleanup paths call + // eth_cleanup_drivers(eth_handles, ...) which frees the array; assigning + // earlier would leave s_eth_handles dangling and crash later callers of + // eth_apply_unified_mac(). + s_eth_handles = eth_handles; + // Register event handlers - non-fatal if these fail ret = esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, ð_event_handler, eth_netif); diff --git a/components/settings_manager/settings_manager.c b/components/settings_manager/settings_manager.c index 41cc8081..f489029d 100644 --- a/components/settings_manager/settings_manager.c +++ b/components/settings_manager/settings_manager.c @@ -663,6 +663,7 @@ esp_err_t settings_set_eth_static_ip(const char *ip) { if (ip == NULL || ip[0] == '\0') { err = nvs_erase_key(h, NVS_KEY_ETH_IP); + if (err == ESP_ERR_NVS_NOT_FOUND) err = ESP_OK; // absent == cleared if (err == ESP_OK) err = nvs_commit(h); } else { err = nvs_set_str(h, NVS_KEY_ETH_IP, ip); @@ -733,6 +734,7 @@ esp_err_t settings_set_eth_netmask(const char *netmask) { if (netmask == NULL || netmask[0] == '\0') { err = nvs_erase_key(h, NVS_KEY_ETH_NETMASK); + if (err == ESP_ERR_NVS_NOT_FOUND) err = ESP_OK; // absent == cleared if (err == ESP_OK) err = nvs_commit(h); } else { err = nvs_set_str(h, NVS_KEY_ETH_NETMASK, netmask); @@ -799,6 +801,7 @@ esp_err_t settings_set_eth_gateway(const char *gw) { if (gw == NULL || gw[0] == '\0') { err = nvs_erase_key(h, NVS_KEY_ETH_GATEWAY); + if (err == ESP_ERR_NVS_NOT_FOUND) err = ESP_OK; // absent == cleared if (err == ESP_OK) err = nvs_commit(h); } else { err = nvs_set_str(h, NVS_KEY_ETH_GATEWAY, gw); @@ -865,6 +868,7 @@ esp_err_t settings_set_eth_dns(const char *dns) { if (dns == NULL || dns[0] == '\0') { err = nvs_erase_key(h, NVS_KEY_ETH_DNS); + if (err == ESP_ERR_NVS_NOT_FOUND) err = ESP_OK; // absent == cleared if (err == ESP_OK) err = nvs_commit(h); } else { err = nvs_set_str(h, NVS_KEY_ETH_DNS, dns); diff --git a/components/ui_http_server/html/general-settings.html b/components/ui_http_server/html/general-settings.html index b02910ec..310cacf7 100644 --- a/components/ui_http_server/html/general-settings.html +++ b/components/ui_http_server/html/general-settings.html @@ -641,10 +641,10 @@

General Settings

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'); - } + // Always send DNS so clearing the field erases the persisted value + // (backend treats empty string as erase). + const dnsSuccess = await setParameter('eth_dns', dns || ''); + if (!dnsSuccess) throw new Error('Failed to save DNS'); } // Update current values diff --git a/components/ui_http_server/ui_http_server.c b/components/ui_http_server/ui_http_server.c index d74cbf5b..ebcaf6db 100644 --- a/components/ui_http_server/ui_http_server.c +++ b/components/ui_http_server/ui_http_server.c @@ -246,9 +246,13 @@ static esp_err_t root_post_handler(httpd_req_t *req) { 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) { + esp_err_t r = settings_set_eth_mode((int32_t)v); + if (r == ESP_OK) { httpd_resp_set_status(req, "200 OK"); httpd_resp_sendstr(req, "ok"); + } else if (r == ESP_ERR_INVALID_ARG) { + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_sendstr(req, "Invalid eth_mode value"); } else { httpd_resp_set_status(req, "500 Internal Server Error"); httpd_resp_sendstr(req, "error"); @@ -261,12 +265,16 @@ static esp_err_t root_post_handler(httpd_req_t *req) { 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) { + esp_err_t r = settings_set_eth_static_ip(decoded_ip); + if (r == ESP_OK) { httpd_resp_set_status(req, "200 OK"); httpd_resp_sendstr(req, "ok"); - } else { + } else if (r == ESP_ERR_INVALID_ARG) { httpd_resp_set_status(req, "400 Bad Request"); httpd_resp_sendstr(req, "Invalid IP address"); + } else { + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "error"); } return ESP_OK; } @@ -276,12 +284,16 @@ static esp_err_t root_post_handler(httpd_req_t *req) { 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) { + esp_err_t r = settings_set_eth_netmask(decoded_netmask); + if (r == ESP_OK) { httpd_resp_set_status(req, "200 OK"); httpd_resp_sendstr(req, "ok"); - } else { + } else if (r == ESP_ERR_INVALID_ARG) { httpd_resp_set_status(req, "400 Bad Request"); httpd_resp_sendstr(req, "Invalid netmask"); + } else { + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "error"); } return ESP_OK; } @@ -291,12 +303,16 @@ static esp_err_t root_post_handler(httpd_req_t *req) { 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) { + esp_err_t r = settings_set_eth_gateway(decoded_gw); + if (r == ESP_OK) { httpd_resp_set_status(req, "200 OK"); httpd_resp_sendstr(req, "ok"); - } else { + } else if (r == ESP_ERR_INVALID_ARG) { httpd_resp_set_status(req, "400 Bad Request"); httpd_resp_sendstr(req, "Invalid gateway"); + } else { + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "error"); } return ESP_OK; } @@ -306,12 +322,16 @@ static esp_err_t root_post_handler(httpd_req_t *req) { 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) { + esp_err_t r = settings_set_eth_dns(decoded_dns); + if (r == ESP_OK) { httpd_resp_set_status(req, "200 OK"); httpd_resp_sendstr(req, "ok"); - } else { + } else if (r == ESP_ERR_INVALID_ARG) { httpd_resp_set_status(req, "400 Bad Request"); httpd_resp_sendstr(req, "Invalid DNS"); + } else { + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_sendstr(req, "error"); } return ESP_OK; } diff --git a/main/main.c b/main/main.c index a79a1e7d..d73906a6 100644 --- a/main/main.c +++ b/main/main.c @@ -1349,15 +1349,17 @@ static void http_get_task(void *pvParameters) { esp_read_mac(base_mac, ESP_MAC_ETH); // fallback to eFuse } } - sprintf(eth_mac_address, "%02X:%02X:%02X:%02X:%02X:%02X", base_mac[0], - base_mac[1], base_mac[2], base_mac[3], base_mac[4], base_mac[5]); + snprintf(eth_mac_address, sizeof(eth_mac_address), + "%02X:%02X:%02X:%02X:%02X:%02X", base_mac[0], + base_mac[1], base_mac[2], base_mac[3], base_mac[4], base_mac[5]); ESP_LOGI(TAG, "eth mac: %s", eth_mac_address); #endif // Get MAC address for WiFi station char mac_address[18]; esp_read_mac(base_mac, ESP_MAC_WIFI_STA); - sprintf(mac_address, "%02X:%02X:%02X:%02X:%02X:%02X", base_mac[0], - base_mac[1], base_mac[2], base_mac[3], base_mac[4], base_mac[5]); + snprintf(mac_address, sizeof(mac_address), + "%02X:%02X:%02X:%02X:%02X:%02X", base_mac[0], + base_mac[1], base_mac[2], base_mac[3], base_mac[4], base_mac[5]); ESP_LOGI(TAG, "sta mac: %s", mac_address); time_sync_data.now = esp_timer_get_time();