mirror of
https://github.com/esphome/esphome.git
synced 2026-05-18 01:32:27 +08:00
[sendspin] Add initial Sendspin hub component (PR1) (#15924)
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user