[sendspin] Add initial Sendspin hub component (PR1) (#15924)

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Kevin Ahrendt
2026-04-23 18:09:36 -04:00
committed by GitHub
parent 90d7bfe02e
commit ddf1426f86
8 changed files with 445 additions and 0 deletions
+1
View File
@@ -440,6 +440,7 @@ esphome/components/sen0321/* @notjj
esphome/components/sen21231/* @shreyaskarnik
esphome/components/sen5x/* @martgras
esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct
esphome/components/sendspin/* @kahrendt
esphome/components/sensirion_common/* @martgras
esphome/components/sensor/* @esphome/core
esphome/components/serial_proxy/* @kbx81
+146
View File
@@ -0,0 +1,146 @@
from dataclasses import dataclass
import esphome.codegen as cg
from esphome.components import esp32, network, psram, socket, wifi
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_TASK_STACK_IN_PSRAM
from esphome.core import CORE
from esphome.types import ConfigType
# mdns for autodiscovery
AUTO_LOAD = ["mdns"]
CODEOWNERS = ["@kahrendt"]
DEPENDENCIES = ["network"]
DOMAIN = "sendspin"
# Trailing underscore avoids clashing with sendspin-cpp's global `sendspin` namespace.
# Analysis tools strip the trailing underscore (same pattern as `template_`).
sendspin_ns = cg.esphome_ns.namespace("sendspin_")
SendspinHub = sendspin_ns.class_(
"SendspinHub",
cg.Component,
)
@dataclass
class SendspinConfiguration:
artwork_support: bool = False
controller_support: bool = False
metadata_support: bool = False
player_support: bool = False
visualizer_support: bool = False
def _get_data() -> SendspinConfiguration:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = SendspinConfiguration()
return CORE.data[DOMAIN]
def request_artwork_support() -> None:
"""Request artwork role support for Sendspin."""
_get_data().artwork_support = True
def request_controller_support() -> None:
"""Request controller role support for Sendspin."""
_get_data().controller_support = True
def request_metadata_support() -> None:
"""Request metadata role support for Sendspin."""
_get_data().metadata_support = True
def request_player_support() -> None:
"""Request player role support for Sendspin."""
_get_data().player_support = True
def request_visualizer_support() -> None:
"""Request visualizer role support for Sendspin."""
_get_data().visualizer_support = True
def _validate_task_stack_in_psram(value):
value = cv.boolean(value)
if value:
return cv.requires_component(psram.DOMAIN)(value)
return value
def _request_high_performance_networking(config: ConfigType) -> ConfigType:
"""Request high performance networking for Sendspin streaming.
Also enables wake_loop_threadsafe support for fast defer() callbacks
from background threads (WebSocket handler, image decoder).
"""
network.require_high_performance_networking()
# Socket consumption varies by mode:
# - Server mode: 1 listening socket + 2 client connections (for handoff)
# - Client mode: 1 outbound connection
socket.consume_sockets(
1, "sendspin_websocket_server", socket.SocketType.TCP_LISTEN
)(config)
socket.consume_sockets(2, "sendspin_websocket_server")(config)
socket.consume_sockets(1, "sendspin_websocket_client")(config)
wifi.enable_runtime_power_save_control()
return config
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(SendspinHub),
cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram,
}
),
cv.only_on_esp32,
_request_high_performance_networking,
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
if config.get(CONF_TASK_STACK_IN_PSRAM):
cg.add(var.set_task_stack_in_psram(True))
esp32.add_idf_sdkconfig_option(
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
)
# sendspin-cpp library
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.3.0")
cg.add_define("USE_SENDSPIN", True) # for MDNS
data = _get_data()
# Configure Sendspin roles based on requested features (ESPHome internally via USE_SENDSPIN_*)
# and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*).
if data.artwork_support:
cg.add_define("USE_SENDSPIN_ARTWORK", True)
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_ARTWORK", False)
if data.controller_support:
cg.add_define("USE_SENDSPIN_CONTROLLER", True)
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_CONTROLLER", False)
if data.metadata_support:
cg.add_define("USE_SENDSPIN_METADATA", True)
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_METADATA", False)
if data.player_support:
cg.add_define("USE_SENDSPIN_PLAYER", True)
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_PLAYER", False)
if data.visualizer_support:
cg.add_define("USE_SENDSPIN_VISUALIZER", True)
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_VISUALIZER", False)
@@ -0,0 +1,143 @@
#include "sendspin_hub.h"
#ifdef USE_ESP32
#include "esphome/components/network/util.h"
#ifdef USE_WIFI
#include "esphome/components/wifi/wifi_component.h"
#endif
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/version.h"
#include <esp_log.h>
namespace esphome::sendspin_ {
static const char *const TAG = "sendspin.hub";
void SendspinHub::setup() {
auto config = this->build_client_config_();
this->client_ = std::make_unique<sendspin::SendspinClient>(std::move(config));
// Set up persistence (preferences must be initialized before providers are added to the client)
this->last_played_server_pref_ =
global_preferences->make_preference<LastPlayedServerPref>(fnv1a_hash("sendspin_last_played"));
// Wire providers and client listener
this->client_->set_listener(this);
this->client_->set_network_provider(this);
this->client_->set_persistence_provider(this);
if (!this->client_->start_server()) {
ESP_LOGE(TAG, "Failed to start Sendspin server");
this->mark_failed();
return;
}
}
void SendspinHub::loop() { this->client_->loop(); }
void SendspinHub::dump_config() {
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
ESP_LOGCONFIG(TAG,
"Sendspin Hub:\n"
" Client ID: %s\n"
" Task stack in PSRAM: %s",
get_mac_address_pretty_into_buffer(mac_buf), YESNO(this->task_stack_in_psram_));
}
// --- Delegating methods ---
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
void SendspinHub::connect_to_server(const std::string &url) {
if (this->is_ready()) {
this->client_->connect_to(url);
}
}
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
void SendspinHub::disconnect_from_server(sendspin::SendspinGoodbyeReason reason) {
if (this->is_ready()) {
this->client_->disconnect(reason);
}
}
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
void SendspinHub::update_state(sendspin::SendspinClientState state) {
if (this->is_ready()) {
this->client_->update_state(state);
}
}
sendspin::SendspinClientConfig SendspinHub::build_client_config_() {
sendspin::SendspinClientConfig config;
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
config.client_id = get_mac_address_pretty_into_buffer(mac_buf);
config.name = App.get_friendly_name();
config.product_name = App.get_name();
config.manufacturer = "ESPHome";
config.software_version = ESPHOME_VERSION;
config.httpd_psram_stack = this->task_stack_in_psram_;
return config;
}
// --- SendspinClientListener overrides ---
// THREAD CONTEXT: Main loop (fired from client_->loop())
void SendspinHub::on_group_update(const sendspin::GroupUpdateObject &group) {
this->group_update_callbacks_.call(group);
}
void SendspinHub::on_request_high_performance() {
#ifdef USE_WIFI
if (wifi::global_wifi_component != nullptr) {
wifi::global_wifi_component->request_high_performance();
}
#endif
}
void SendspinHub::on_release_high_performance() {
#ifdef USE_WIFI
if (wifi::global_wifi_component != nullptr) {
wifi::global_wifi_component->release_high_performance();
}
#endif
}
// --- SendspinNetworkProvider override ---
// THREAD CONTEXT: Main loop (polled by client_->loop())
bool SendspinHub::is_network_ready() { return network::is_connected(); }
// --- SendspinPersistenceProvider overrides ---
// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events)
bool SendspinHub::save_last_server_hash(uint32_t hash) {
LastPlayedServerPref pref{.server_id_hash = hash};
bool ok = this->last_played_server_pref_.save(&pref);
if (ok) {
ESP_LOGD(TAG, "Persisted last played server hash: 0x%08X", hash);
} else {
ESP_LOGW(TAG, "Failed to persist last played server hash");
}
return ok;
}
// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events)
std::optional<uint32_t> SendspinHub::load_last_server_hash() {
LastPlayedServerPref pref{};
if (this->last_played_server_pref_.load(&pref)) {
ESP_LOGI(TAG, "Loaded last played server hash: 0x%08X", pref.server_id_hash);
return pref.server_id_hash;
}
return std::nullopt;
}
} // namespace esphome::sendspin_
#endif // USE_ESP32
+138
View File
@@ -0,0 +1,138 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/preferences.h"
#include <sendspin/client.h>
#include <sendspin/config.h>
#include <sendspin/types.h>
#include <functional>
#include <memory>
#include <optional>
namespace esphome::sendspin_ {
/// @brief Setup priorities for the sendspin hub and its child components.
///
/// Centralized here so every sendspin component orders itself relative to the hub
/// without each subcomponent having to pick a priority independently. Children run
/// one step later than hub so they can assume hub's setup() has already completed.
namespace sendspin_priority {
inline constexpr float HUB = esphome::setup_priority::PROCESSOR;
inline constexpr float CHILD = HUB - 1.0f;
} // namespace sendspin_priority
/// @brief Persistent storage structure for last played server hash.
struct LastPlayedServerPref {
uint32_t server_id_hash;
};
/// @brief Thin adapter over sendspin::SendspinClient.
///
/// The hub owns a SendspinClient instance and bridges its listener/provider interfaces to ESPHome's CallbackManager for
/// fan-out to child components.
/// - Provides persistence via ESPPreferenceObject and WiFi power management integration.
/// - Handles Sendspin roles that apply to multiple child components (artwork, controller, metadata) so their events
/// can be fanned out. Roles specific to a single component (player) are configured by the hub but owned by the
/// child thereafter, since no fan-out is needed.
///
/// The sendspin-cpp library follows this design:
/// - Core and role configuration are passed at client/role construction time as structs. Built in our `setup()`.
/// - Library -> user code communication happens via two interface types the user implements and registers in our
/// `setup()`: listener interfaces (for events the library pushes; e.g., group updates) and provider interfaces
/// (for services the library pulls; e.g., persistence, network readiness).
/// - User -> library communication uses exposed functions on the client and role objects that the user calls.
class SendspinHub final : public Component,
public sendspin::SendspinClientListener,
public sendspin::SendspinNetworkProvider,
public sendspin::SendspinPersistenceProvider {
public:
float get_setup_priority() const override { return sendspin_priority::HUB; }
void setup() override;
void loop() override;
void dump_config() override;
/// @brief Connects the underlying client to the given Sendspin server.
///
/// No-op if the hub's client is not ready (e.g. setup() has not completed).
/// Must be called from the main loop thread.
/// @param url WebSocket URL of the Sendspin server, starting with `ws://` (e.g. `ws://host:port/path`).
void connect_to_server(const std::string &url);
/// @brief Disconnects the underlying client from the current server.
///
/// Sends a `client/goodbye` message with the given reason before closing the connection.
/// No-op if the hub's client is not ready. Must be called from the main loop thread.
/// @param reason Reason reported to the server:
/// - `ANOTHER_SERVER`: client is switching to another server.
/// - `SHUTDOWN`: client is shutting down.
/// - `RESTART`: client is restarting.
/// - `USER_REQUEST`: user explicitly requested disconnect.
void disconnect_from_server(sendspin::SendspinGoodbyeReason reason);
/// @brief Updates the client's reported playback state on the server.
///
/// No-op if the hub's client is not ready. Must be called from the main loop thread.
/// @param state New client state:
/// - `SYNCHRONIZED`: client is synchronized and playing from the server.
/// - `ERROR`: client encountered a playback error.
/// - `EXTERNAL_SOURCE`: client is playing from a non-Sendspin source.
void update_state(sendspin::SendspinClientState state);
// --- Configuration setters (called from codegen) ---
template<typename F> void add_group_update_callback(F &&callback) {
this->group_update_callbacks_.add(std::forward<F>(callback));
}
void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; }
protected:
/// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info.
sendspin::SendspinClientConfig build_client_config_();
// --- SendspinClientListener overrides ---
void on_group_update(const sendspin::GroupUpdateObject &group) override;
void on_request_high_performance() override;
void on_release_high_performance() override;
// --- SendspinNetworkProvider override ---
bool is_network_ready() override;
// --- SendspinPersistenceProvider overrides ---
bool save_last_server_hash(uint32_t hash) override;
std::optional<uint32_t> load_last_server_hash() override;
ESPPreferenceObject last_played_server_pref_;
std::unique_ptr<sendspin::SendspinClient> client_;
// Callback fan-out to child components
CallbackManager<void(const sendspin::GroupUpdateObject &)> group_update_callbacks_{};
bool task_stack_in_psram_{false};
};
/// @brief Base class for all sendspin subcomponents.
///
/// Consolidates the Component + Parented<SendspinHub> inheritance and pins the setup
/// priority so the hub's setup() always runs before any child. Subcomponents should
/// inherit from this instead of listing Component/Parented individually and must not
/// override get_setup_priority().
class SendspinChild : public Component, public Parented<SendspinHub> {
public:
float get_setup_priority() const override { return sendspin_priority::CHILD; }
};
} // namespace esphome::sendspin_
#endif // USE_ESP32
+5
View File
@@ -257,6 +257,11 @@
#define USE_MICROPHONE
#define USE_PSRAM
#define USE_SENDSPIN
#define USE_SENDSPIN_ARTWORK
#define USE_SENDSPIN_CONTROLLER
#define USE_SENDSPIN_METADATA
#define USE_SENDSPIN_PLAYER
#define USE_SENDSPIN_VISUALIZER
#define USE_SENDSPIN_PORT 8928 // NOLINT
#define USE_SOCKET_IMPL_BSD_SOCKETS
#define USE_LWIP_FAST_SELECT
+2
View File
@@ -91,5 +91,7 @@ dependencies:
- if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32p4]"
esp32async/asynctcp:
version: 3.4.91
sendspin/sendspin-cpp:
version: 0.3.0
lvgl/lvgl:
version: 9.5.0
+9
View File
@@ -0,0 +1,9 @@
wifi:
ap:
psram:
mode: quad
sendspin:
id: sendspin_hub_id
task_stack_in_psram: true
@@ -0,0 +1 @@
<<: !include common.yaml