Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ if(ESP_IDF_BUILD)
# Assemble source list from core + enabled roles (driven by Kconfig)
set(SENDSPIN_ALL_SOURCES ${SENDSPIN_CORE_SOURCES} ${SENDSPIN_ESP_SOURCES})

set(SENDSPIN_REQUIRES esp_http_server esp_timer esp_ringbuf pthread mbedtls bblanchon__arduinojson)
set(SENDSPIN_REQUIRES esp_http_server esp_netif esp_timer esp_ringbuf pthread mbedtls bblanchon__arduinojson)
set(SENDSPIN_COMPILE_DEFS "")

if(CONFIG_SENDSPIN_ENABLE_PLAYER)
Expand Down
2 changes: 2 additions & 0 deletions cmake/sources.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ function(sendspin_get_sources BASE_DIR)
${BASE_DIR}/src/esp/server_connection.cpp
${BASE_DIR}/src/esp/client_connection.cpp
${BASE_DIR}/src/esp/ws_server.cpp
${BASE_DIR}/src/esp/network_info.cpp

PARENT_SCOPE
)
Expand All @@ -80,6 +81,7 @@ function(sendspin_get_sources BASE_DIR)
${BASE_DIR}/src/host/ws_server.cpp
${BASE_DIR}/src/host/server_connection.cpp
${BASE_DIR}/src/host/client_connection.cpp
${BASE_DIR}/src/host/network_info.cpp

PARENT_SCOPE
)
Expand Down
13 changes: 7 additions & 6 deletions docs/integration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ SendspinClient::set_log_level(LogLevel::INFO);
SendspinClientConfig config;
config.client_id = "my-device-mac-addr"; // Unique identifier (e.g., MAC address)
config.name = "Living Room Speaker"; // Friendly display name
config.product_name = "My Speaker"; // Device product name
config.manufacturer = "My Company"; // Manufacturer name
config.software_version = "1.0.0"; // Software version string
config.product_name = "My Speaker"; // Device product name (optional)
config.manufacturer = "My Company"; // Manufacturer name (optional)
config.software_version = "1.0.0"; // Software version string (optional)

SendspinClient client(std::move(config));
```
Expand Down Expand Up @@ -692,9 +692,10 @@ Main client configuration passed to the `SendspinClient` constructor.
|---|---|---|---|
| `client_id` | `std::string` | — | Unique client identifier (e.g., MAC address) |
| `name` | `std::string` | — | Friendly display name shown in the Sendspin UI |
| `product_name` | `std::string` | — | Device product name |
| `manufacturer` | `std::string` | — | Manufacturer name (e.g., `"ESPHome"`) |
| `software_version` | `std::string` | — | Software version string |
| `product_name` | `std::optional<std::string>` | unset | Device product name; sent in `client/hello` only when set |
| `manufacturer` | `std::optional<std::string>` | unset | Manufacturer name (e.g., `"ESPHome"`); sent in `client/hello` only when set |
| `software_version` | `std::optional<std::string>` | unset | Software version string; sent in `client/hello` only when set |
| `mac_address` | `std::optional<std::string>` | auto-detected | MAC address of the network interface, lowercase colon-separated (e.g., `"aa:bb:cc:dd:ee:ff"`), sent in `client/hello`. Left unset, the library auto-detects it. ESP-IDF uses the default network interface (Wi-Fi or Ethernet). Host uses a best-effort from the active routable interface. Set explicitly to override (recommended on multi-homed hosts). |
| `httpd_psram_stack` | `bool` | `false` | Allocate HTTP server task stack in PSRAM (ESP-IDF only) |
| `httpd_priority` | `unsigned` | `17` | FreeRTOS priority for the HTTP server task (ESP-IDF only) |
| `websocket_priority` | `unsigned` | `5` | FreeRTOS priority for the WebSocket client task (ESP-IDF only) |
Expand Down
20 changes: 15 additions & 5 deletions include/sendspin/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,21 @@ namespace sendspin {
/// @brief Configuration for a SendspinClient instance
/// Filled in by the platform (e.g., ESPHome) before calling start_server()
struct SendspinClientConfig {
std::string client_id; ///< Unique client identifier (e.g., MAC address)
std::string name; ///< Friendly display name
std::string product_name; ///< Device product name
std::string manufacturer; ///< Manufacturer name (e.g., "ESPHome")
std::string software_version; ///< Software version string
std::string client_id; ///< Unique client identifier (e.g., MAC address)
std::string name; ///< Friendly display name

std::optional<std::string> product_name{}; ///< Device product name (optional)
std::optional<std::string> manufacturer{}; ///< Manufacturer name, e.g., "ESPHome" (optional)
std::optional<std::string> software_version{}; ///< Software version string (optional)

/// @brief MAC address of the network interface the connection is opened on.
/// Sent in the client/hello device_info object. Must be lowercase colon-separated
/// form (e.g., "aa:bb:cc:dd:ee:ff"). When left unset, the library auto-detects it:
/// from the default network interface (Wi-Fi or Ethernet) on ESP-IDF, and best-effort
/// from the active routable interface on host. Set this explicitly to override the
/// detected value (recommended on multi-homed hosts where detection may pick the wrong
/// interface).
std::optional<std::string> mac_address{};

bool httpd_psram_stack{false}; ///< Allocate httpd task stack in PSRAM (ESP-IDF only)

Expand Down
5 changes: 5 additions & 0 deletions src/client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
#include "platform/json_arena.h"
#include "platform/logging.h"
#include "platform/memory.h"
#include "platform/network_info.h"
#include "platform/shadow_slot.h"
#include "platform/thread_safe_queue.h"
#include "platform/time.h"
Expand Down Expand Up @@ -444,6 +445,10 @@ std::string SendspinClient::build_hello_message() {
device_info.product_name = this->config_.product_name;
device_info.manufacturer = this->config_.manufacturer;
device_info.software_version = this->config_.software_version;
// Use the explicitly configured MAC when provided; otherwise fall back to platform detection
// (reliable on ESP, best-effort on host). Leaves the field absent if neither is available.
device_info.mac_address =
this->config_.mac_address ? this->config_.mac_address : platform_get_interface_mac();
msg.device_info = device_info;

msg.version = 1;
Expand Down
79 changes: 79 additions & 0 deletions src/esp/network_info.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright 2026 Sendspin Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/// @file network_info.cpp
/// @brief ESP-IDF MAC detection: MAC of the default network interface (Wi-Fi or Ethernet)

#include "platform/network_info.h"

#include "platform/logging.h"
#include <esp_mac.h>
#include <esp_netif.h>

#include <cstdint>
#include <cstdio>

namespace sendspin {

static const char* const TAG = "sendspin.network_info";

namespace {

/// @brief Size of a formatted MAC string buffer: "aa:bb:cc:dd:ee:ff" plus null terminator.
constexpr size_t MAC_STR_BUF_SIZE = 18;

/// @brief Formats six MAC octets as lowercase colon-separated text, or nullopt if all zero.
std::optional<std::string> format_mac(const uint8_t* mac) {
bool all_zero = true;
for (int i = 0; i < 6; i++) {
if (mac[i] != 0) {
all_zero = false;
break;
}
}
if (all_zero) {
return std::nullopt; // emulators/unprovisioned efuse can report a zeroed MAC
}

char buf[MAC_STR_BUF_SIZE];
std::snprintf(buf, sizeof(buf), "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3],
mac[4], mac[5]);
return std::string(buf);
}

} // namespace

std::optional<std::string> platform_get_interface_mac() {
uint8_t mac[6] = {0};

// Prefer the MAC of the default network interface — the one outbound connections route over.
// This resolves to the correct address whether the device is on Wi-Fi or Ethernet.
esp_netif_t* netif = esp_netif_get_default_netif();
if (netif != nullptr && esp_netif_get_mac(netif, mac) == ESP_OK) {
if (std::optional<std::string> detected = format_mac(mac); detected.has_value()) {
return detected;
}
}

// Fallback: factory Wi-Fi STA address from efuse. This does not require esp_wifi/esp_netif to
// be initialized, so it still yields a stable identity if no default interface is up yet.
const esp_err_t err = esp_read_mac(mac, ESP_MAC_WIFI_STA);
if (err != ESP_OK) {
SS_LOGW(TAG, "MAC detection failed: %s", esp_err_to_name(err));
return std::nullopt;
}
return format_mac(mac);
}

} // namespace sendspin
160 changes: 160 additions & 0 deletions src/host/network_info.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Copyright 2026 Sendspin Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/// @file network_info.cpp
/// @brief Host MAC detection: best-effort lookup of an active interface that carries a routable IP

#include "platform/network_info.h"

#include "platform/logging.h"
#include <ifaddrs.h>
#include <net/if.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/types.h>

#include <cstdint>
#include <cstdio>
#include <set>
#include <string>

#if defined(__APPLE__)
#include <net/if_dl.h> // sockaddr_dl, LLADDR
#else
#include <netpacket/packet.h> // sockaddr_ll
#endif

namespace sendspin {

static const char* const TAG = "sendspin.network_info";

namespace {

/// @brief Size of a formatted MAC string buffer: "aa:bb:cc:dd:ee:ff" plus null terminator.
constexpr size_t MAC_STR_BUF_SIZE = 18;
/// @brief First octet of the IPv4 loopback range 127.0.0.0/8.
constexpr uint8_t IPV4_LOOPBACK_FIRST_OCTET = 127;
/// @brief High 16 bits of the IPv4 link-local range 169.254.0.0/16.
constexpr uint16_t IPV4_LINK_LOCAL_PREFIX = 0xA9FE;

/// @brief Formats six MAC octets as lowercase colon-separated text, or nullopt if all zero.
std::optional<std::string> format_mac(const uint8_t* mac) {
bool all_zero = true;
for (int i = 0; i < 6; i++) {
if (mac[i] != 0) {
all_zero = false;
break;
}
}
if (all_zero) {
return std::nullopt;
}

char buf[MAC_STR_BUF_SIZE];
std::snprintf(buf, sizeof(buf), "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3],
mac[4], mac[5]);
return std::string(buf);
}

/// @brief True if `sa` is a routable (non-loopback, non-link-local) IPv4/IPv6 address.
bool is_routable_ip(const struct sockaddr* sa) {
if (sa == nullptr) {
return false;
}
if (sa->sa_family == AF_INET) {
const auto* in = reinterpret_cast<const struct sockaddr_in*>(sa);
const uint32_t addr = ntohl(in->sin_addr.s_addr);
const uint8_t first = static_cast<uint8_t>(addr >> 24);
const uint16_t first_two = static_cast<uint16_t>(addr >> 16);
if (first == IPV4_LOOPBACK_FIRST_OCTET) {
return false; // loopback 127.0.0.0/8
}
if (first_two == IPV4_LINK_LOCAL_PREFIX) {
return false; // link-local 169.254.0.0/16
}
return true;
}
if (sa->sa_family == AF_INET6) {
const auto* in6 = reinterpret_cast<const struct sockaddr_in6*>(sa);
return !IN6_IS_ADDR_LOOPBACK(&in6->sin6_addr) && !IN6_IS_ADDR_LINKLOCAL(&in6->sin6_addr);
}
return false;
}

} // namespace

std::optional<std::string> platform_get_interface_mac() {
struct ifaddrs* ifaddr = nullptr;
if (getifaddrs(&ifaddr) != 0 || ifaddr == nullptr) {
SS_LOGW(TAG, "getifaddrs failed; cannot auto-detect MAC address");
return std::nullopt;
}

// First pass: collect interfaces that carry a routable IP. The MAC of such an interface is far
// more likely to be the one a connection actually uses than an arbitrary up-and-running
// interface (which on desktops includes bridges, VPN/utun, and AWDL links with no real route).
std::set<std::string> routable_ifaces;
for (struct ifaddrs* ifa = ifaddr; ifa != nullptr; ifa = ifa->ifa_next) {
if (ifa->ifa_name != nullptr && is_routable_ip(ifa->ifa_addr)) {
routable_ifaces.insert(ifa->ifa_name);
}
}

std::optional<std::string> result;
for (struct ifaddrs* ifa = ifaddr; ifa != nullptr; ifa = ifa->ifa_next) {
if (ifa->ifa_addr == nullptr || ifa->ifa_name == nullptr) {
continue;
}
// Skip loopback and interfaces that are not up and running.
if ((ifa->ifa_flags & IFF_LOOPBACK) != 0) {
continue;
}
if ((ifa->ifa_flags & (IFF_UP | IFF_RUNNING)) != (IFF_UP | IFF_RUNNING)) {
continue;
}
// Only consider interfaces that also have a routable IP address.
if (routable_ifaces.find(ifa->ifa_name) == routable_ifaces.end()) {
continue;
}

#if defined(__APPLE__)
if (ifa->ifa_addr->sa_family != AF_LINK) {
continue;
}
auto* sdl = reinterpret_cast<struct sockaddr_dl*>(ifa->ifa_addr);
if (sdl->sdl_alen != 6) {
continue;
}
result = format_mac(reinterpret_cast<const uint8_t*>(LLADDR(sdl)));
#else
if (ifa->ifa_addr->sa_family != AF_PACKET) {
continue;
}
auto* sll = reinterpret_cast<struct sockaddr_ll*>(ifa->ifa_addr);
if (sll->sll_halen != 6) {
continue;
}
result = format_mac(sll->sll_addr);
#endif
if (result.has_value()) {
SS_LOGD(TAG, "auto-detected MAC %s on interface %s", result->c_str(), ifa->ifa_name);
break;
}
}

freeifaddrs(ifaddr);
return result;
}

} // namespace sendspin
41 changes: 41 additions & 0 deletions src/platform/network_info.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright 2026 Sendspin Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/// @file network_info.h
/// @brief Platform-abstracted detection of the local network interface MAC address

#pragma once

#include <optional>
#include <string>

namespace sendspin {

/// @brief Best-effort lookup of the local MAC address used for Sendspin connections.
///
/// Returned as lowercase colon-separated form (e.g., "aa:bb:cc:dd:ee:ff"), suitable
/// for the `client/hello` `device_info.mac_address` field. Returns std::nullopt when no
/// suitable interface can be determined.
///
/// Platform behaviour:
/// - ESP-IDF: returns the MAC of the default network interface, so it resolves correctly whether
/// the device is on Wi-Fi or Ethernet. Falls back to the factory Wi-Fi STA MAC if no default
/// interface is up yet.
/// - Host: returns the MAC of the first active non-loopback interface that also carries a routable
/// (non-link-local) IP address. This is a heuristic (the "primary" interface), not necessarily
/// the interface a given connection is bound to; on multi-homed hosts it may differ. Prefer
/// setting SendspinClientConfig::mac_address explicitly when the exact interface matters.
std::optional<std::string> platform_get_interface_mac();

} // namespace sendspin
3 changes: 3 additions & 0 deletions src/protocol.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,9 @@ std::string format_client_hello_message(const ClientHelloMessage* msg) {
if (info.software_version.has_value()) {
root["payload"]["device_info"]["software_version"] = info.software_version.value();
}
if (info.mac_address.has_value()) {
root["payload"]["device_info"]["mac_address"] = info.mac_address.value();
}
}
root["payload"]["version"] = msg->version;
JsonArray supported_roles_list = root["payload"]["supported_roles"].to<JsonArray>();
Expand Down
1 change: 1 addition & 0 deletions src/protocol_messages.h
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ struct DeviceInfoObject {
std::optional<std::string> product_name{};
std::optional<std::string> manufacturer{};
std::optional<std::string> software_version{};
std::optional<std::string> mac_address{};
};

// --- player_role.h ---
Expand Down
Loading
Loading