diff --git a/README.md b/README.md
index 1b0ddf48..a12d6e62 100644
--- a/README.md
+++ b/README.md
@@ -108,23 +108,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
@@ -136,16 +124,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)
@@ -235,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. 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. 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 3ad0a9dd..0906fb30 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,15 @@ 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);
+ tg0_timer_deinit();
+#if CONFIG_PM_ENABLE
+ esp_pm_lock_release(player_pm_lock_handle);
+#endif
+ playerStarted = false;
+ return -1;
+ }
ESP_LOGI(TAG, "created new queue with %d", entries);
}
@@ -1535,8 +1548,11 @@ static void player_task(void *pvParameters) {
queueCreatedWithChkInFrames = __scSet.chkInFrames;
pcmChkQHdl = xQueueCreate(entries, sizeof(pcm_chunk_message_t *));
-
- ESP_LOGI(TAG, "created new queue with %d", entries);
+ 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);
+ }
}
if ((scSet.sr != __scSet.sr) || (scSet.bits != __scSet.bits) ||
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/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..51b44484 100644
--- a/components/network_interface/eth_interface.c
+++ b/components/network_interface/eth_interface.c
@@ -13,24 +13,225 @@
#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"
#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"
+
+extern void sc_restart_snapcast(void);
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
+#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;
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.
+ * - 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;
+
+/*
+ * 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;
+
+/*
+ * 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 4096
+#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_LOGI(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_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();
+ }
+
+ 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
@@ -124,6 +325,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;
}
@@ -272,6 +476,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) {
@@ -303,23 +515,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.
@@ -342,15 +558,445 @@ 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 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()) {
+ do_takeover = true;
+ do_mac_unify = mac_unification_pending;
+ want_eth_takeover = false;
+ }
+ 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) {
+ 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();
+
+ 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;
+ 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);
+
+ sc_restart_snapcast();
+ } else {
+ // No MAC unification needed - just complete takeover
+ xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY);
+ we_changed_default_netif = true;
+ xSemaphoreGive(connIpSemaphoreHandle);
+ sc_restart_snapcast();
+ }
+ } 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) {
- 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;
@@ -363,13 +1009,205 @@ 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));
+ // 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_LOGI(TAG, "Ethernet using default MAC (will unify when ready)");
+ }
+ }
+
+ // 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);
+
+ // 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;
+ }
+
+ // 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 and MAC unification 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: 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);
+
+ // 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...");
+ }
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;
+
+ // Reset MAC unification state on disconnect
+ 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)
+ {
+ 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) {
+ 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
+ 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 */
+ 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
+ * 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);
+ }
ESP_LOGI(TAG, "Ethernet Link Down");
break;
@@ -384,21 +1222,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,21 +1251,19 @@ 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);
+ // 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;
- xSemaphoreGive(connIpSemaphoreHandle);
-
ESP_LOGI(TAG, "Ethernet Got IP Address");
ESP_LOGI(TAG, "~~~~~~~~~~~");
ESP_LOGI(TAG, "ETHIP:" IPSTR, IP2STR(&ip_info.ip));
@@ -440,12 +1271,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 +1302,221 @@ 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
+ *
+ * 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;
+ bool do_mac_unify = false;
+ esp_netif_t *pending_netif = NULL;
+
+ xSemaphoreTake(connIpSemaphoreHandle, portMAX_DELAY);
+
+ // 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;
+ static_ip_pending = false;
+ static_ip_in_progress = true;
+ }
+ // Check for pending takeover (DHCP path or already-configured static IP)
+ // ✓ 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));
+ }
+ }
+
+ sc_restart_snapcast();
+
+ // 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
+ 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);
+ 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;
+ }
+
+ 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");
+ 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) {
+ // 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);
+ we_changed_default_netif = true;
+ xSemaphoreGive(connIpSemaphoreHandle);
+ 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
+ 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 +1533,41 @@ 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;
+ }
+
+ // 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;
// Create instance(s) of esp-netif for Ethernet(s)
if (eth_port_cnt == 1) {
@@ -495,9 +1575,32 @@ 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 +1609,117 @@ 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));
+ }
+
+ 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
+ // 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/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/include/network_interface.h b/components/network_interface/include/network_interface.h
index 660b31eb..b046bcdc 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,38 @@
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);
+
+/** 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..f0d9f89c 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"
@@ -33,6 +35,74 @@
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_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_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 +136,88 @@ 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
+}
+
+/**
+ * @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
@@ -85,6 +237,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
new file mode 100644
index 00000000..fdba0ee7
--- /dev/null
+++ b/components/network_interface/priv_include/network_interface_priv.h
@@ -0,0 +1,44 @@
+/*
+ * 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);
+
+/** 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);
+
+/** 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 ae620b6c..9914edfc 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"
@@ -19,6 +20,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"
@@ -75,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 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
@@ -87,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");
@@ -132,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));
@@ -177,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/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);