mirror of
https://github.com/esphome/esphome.git
synced 2026-10-09 04:16:01 +00:00
[sendspin] Update to sendspin-cpp v0.9.3 (#20347)
This commit is contained in:
@@ -76,7 +76,6 @@ OPUS_SAMPLE_RATE = 48000
|
||||
SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True)
|
||||
IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG")
|
||||
IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG")
|
||||
IMAGE_FORMAT_BMP = SendspinImageFormat.enum("BMP")
|
||||
|
||||
SendspinImageSource = sendspin_library_ns.enum("SendspinImageSource", is_class=True)
|
||||
IMAGE_SOURCE_ALBUM = SendspinImageSource.enum("ALBUM")
|
||||
@@ -211,6 +210,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
}
|
||||
),
|
||||
cv.only_on_esp32,
|
||||
# sendspin-cpp needs noise-c as an ESP-IDF component, which Arduino below IDF 6.0 cannot use.
|
||||
cv.only_with_framework("esp-idf"),
|
||||
_request_high_performance_networking,
|
||||
)
|
||||
|
||||
@@ -265,7 +266,9 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(setter(value))
|
||||
|
||||
# sendspin-cpp library
|
||||
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.8.0")
|
||||
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.9.3")
|
||||
# esp_websocket_client links esp_tls even for ws:// connections.
|
||||
esp32.request_tls()
|
||||
|
||||
cg.add_define("USE_SENDSPIN", True) # for MDNS
|
||||
|
||||
@@ -276,8 +279,9 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
data = _get_data()
|
||||
|
||||
# The color role is not yet wired up in ESPHome; disable it in the library for now.
|
||||
# The color and source roles are not yet wired up in ESPHome; disable them in the library for now.
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_COLOR", False)
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE", False)
|
||||
|
||||
# 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_*).
|
||||
@@ -351,7 +355,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
("audio_formats", audio_format_structs),
|
||||
("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]),
|
||||
("fixed_delay_us", player_cfg[CONF_FIXED_DELAY]),
|
||||
("initial_static_delay_ms", player_cfg[CONF_INITIAL_STATIC_DELAY]),
|
||||
# The released YAML key name is kept for compatibility.
|
||||
("initial_output_delay_ms", player_cfg[CONF_INITIAL_STATIC_DELAY]),
|
||||
("psram_stack", psram_stack),
|
||||
]
|
||||
if (decode_memory := player_cfg.get(CONF_DECODE_MEMORY)) is not None:
|
||||
@@ -366,6 +371,9 @@ async def to_code(config: ConfigType) -> None:
|
||||
else:
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_PLAYER", False)
|
||||
|
||||
if not data.player_support or CODEC_OPUS not in data.player_config[CONF_CODECS]:
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_OPUS", False)
|
||||
|
||||
if data.visualizer_support:
|
||||
cg.add_define("USE_SENDSPIN_VISUALIZER", True)
|
||||
else:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Sendspin image platform."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import runtime_image
|
||||
@@ -21,7 +23,6 @@ from esphome.types import ConfigType
|
||||
from .. import (
|
||||
CONF_DISPLAY_OFFSET,
|
||||
CONF_SENDSPIN_ID,
|
||||
IMAGE_FORMAT_BMP,
|
||||
IMAGE_FORMAT_JPEG,
|
||||
IMAGE_FORMAT_PNG,
|
||||
IMAGE_SOURCE_ALBUM,
|
||||
@@ -57,9 +58,16 @@ _FORMAT_TO_SENDSPIN_ENUM = {
|
||||
"JPEG": IMAGE_FORMAT_JPEG,
|
||||
"JPG": IMAGE_FORMAT_JPEG,
|
||||
"PNG": IMAGE_FORMAT_PNG,
|
||||
"BMP": IMAGE_FORMAT_BMP,
|
||||
}
|
||||
|
||||
|
||||
# Remove before 2027.4.0
|
||||
def _reject_bmp(value: Any) -> Any:
|
||||
if isinstance(value, str) and value.upper() == "BMP":
|
||||
raise cv.Invalid("BMP artwork is no longer supported, use JPEG or PNG instead.")
|
||||
return value
|
||||
|
||||
|
||||
# The library's SendspinImageSource::NONE is its internal "unset" sentinel; a slot advertising it
|
||||
# would never receive artwork while still paying for two frame buffers, so it is not offered here.
|
||||
IMAGE_SOURCES = {
|
||||
@@ -121,7 +129,9 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub),
|
||||
# Narrow runtime_image's format list to what the library can request, so the
|
||||
# accepted set and the enum map below cannot drift apart.
|
||||
cv.Required(CONF_FORMAT): cv.one_of(*_FORMAT_TO_SENDSPIN_ENUM, upper=True),
|
||||
cv.Required(CONF_FORMAT): cv.All(
|
||||
_reject_bmp, cv.one_of(*_FORMAT_TO_SENDSPIN_ENUM, upper=True)
|
||||
),
|
||||
cv.Required(CONF_RESIZE): cv.dimensions,
|
||||
cv.Required(CONF_CURRENT_IMAGE): _IMAGE_SCHEMA,
|
||||
cv.Optional(CONF_TRANSITION_IMAGE): _IMAGE_SCHEMA,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#include "sendspin_image.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK)
|
||||
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_ARTWORK)
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
@@ -125,7 +125,7 @@ bool SendspinImageSlot::decode_frame_(const uint8_t *data, size_t length, const
|
||||
}
|
||||
|
||||
// A decode that asked for other dimensions had the buffer taken away from it, so it painted
|
||||
// nothing (or stopped partway). JPEG and BMP report that as an error above; PNG carries on
|
||||
// nothing (or stopped partway). JPEG reports that as an error above; PNG carries on
|
||||
// regardless, so the frame is dropped here.
|
||||
return this->decode_sink_.decoded_into(target);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK)
|
||||
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_ARTWORK)
|
||||
|
||||
#include "esphome/components/image/image.h"
|
||||
#include "esphome/components/runtime_image/runtime_image.h"
|
||||
@@ -29,7 +29,7 @@ class ArtworkDecodeSink : public runtime_image::RuntimeImage {
|
||||
/// @brief True when the decode ended with the given buffer still in place.
|
||||
///
|
||||
/// An external buffer is dropped rather than resized, so a decode that wanted other dimensions
|
||||
/// leaves the sink holding nothing. The JPEG and BMP decoders report that as a decode error, but
|
||||
/// leaves the sink holding nothing. The JPEG decoder reports that as a decode error, but
|
||||
/// the PNG decoder ignores it and reports success, so the outcome is checked here as well.
|
||||
bool decoded_into(const uint8_t *buffer) const { return this->buffer_ == buffer; }
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#include "sendspin_media_player.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER)
|
||||
#if defined(USE_ESP_IDF) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER)
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER)
|
||||
#if defined(USE_ESP_IDF) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER)
|
||||
|
||||
#include "esphome/components/media_player/media_player.h"
|
||||
#include "esphome/components/sendspin/sendspin_hub.h"
|
||||
|
||||
@@ -11,7 +11,9 @@ from esphome.const import (
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
CODEC_FLAC,
|
||||
CODEC_OPUS,
|
||||
CODEC_PCM,
|
||||
CODECS,
|
||||
CONF_CODECS,
|
||||
CONF_DECODE_MEMORY,
|
||||
@@ -58,6 +60,11 @@ def _resolve_codecs(config: ConfigType) -> ConfigType:
|
||||
f"Codec '{CODEC_OPUS}' requires a {CONF_SAMPLE_RATE} of {OPUS_SAMPLE_RATE}",
|
||||
path=[CONF_CODECS],
|
||||
)
|
||||
if CODEC_FLAC not in codecs and CODEC_PCM not in codecs:
|
||||
raise cv.Invalid(
|
||||
f"Codecs must include '{CODEC_FLAC}' or '{CODEC_PCM}'",
|
||||
path=[CONF_CODECS],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#include "sendspin_media_source.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER)
|
||||
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER)
|
||||
|
||||
#include "esphome/components/audio/audio.h"
|
||||
#include "esphome/core/log.h"
|
||||
@@ -24,7 +24,7 @@ void SendspinMediaSource::setup() {
|
||||
// Push cached states to player role. They may have been set before setup() ran.
|
||||
this->player_role_->update_volume(std::roundf(this->cached_volume_ * 100.0f));
|
||||
this->player_role_->update_muted(this->cached_muted_);
|
||||
this->player_role_->set_static_delay_adjustable(this->static_delay_adjustable_);
|
||||
this->player_role_->set_output_delay_adjustable(this->static_delay_adjustable_);
|
||||
}
|
||||
|
||||
void SendspinMediaSource::dump_config() {
|
||||
@@ -35,7 +35,7 @@ void SendspinMediaSource::dump_config() {
|
||||
void SendspinMediaSource::set_static_delay_adjustable(bool adjustable) {
|
||||
this->static_delay_adjustable_ = adjustable;
|
||||
if (this->player_role_) {
|
||||
this->player_role_->set_static_delay_adjustable(adjustable);
|
||||
this->player_role_->set_output_delay_adjustable(adjustable);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,8 +102,8 @@ void SendspinMediaSource::handle_command(media_source::MediaSourceCommand comman
|
||||
if (!this->pending_start_) {
|
||||
// Ignore stop commands if we have a pending start, since the orchestrator may send a stop command before
|
||||
// play_uri
|
||||
ESP_LOGD(TAG, "Received STOP command, updating Sendspin state to EXTERNAL_SOURCE");
|
||||
this->parent_->update_state(sendspin::SendspinClientState::EXTERNAL_SOURCE);
|
||||
ESP_LOGD(TAG, "Received STOP command, leaving the Sendspin group");
|
||||
this->parent_->leave_group();
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -185,8 +185,6 @@ size_t SendspinMediaSource::on_audio_write(uint8_t *data, size_t length, uint32_
|
||||
|
||||
// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback)
|
||||
void SendspinMediaSource::on_stream_start() {
|
||||
this->parent_->update_state(sendspin::SendspinClientState::SYNCHRONIZED);
|
||||
|
||||
if (!this->pending_start_) {
|
||||
// Dedup rapid on_stream_start() calls
|
||||
this->pending_start_ = true;
|
||||
@@ -211,4 +209,4 @@ void SendspinMediaSource::on_mute_changed(bool muted) { this->request_mute_(mute
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif // USE_ESP32 && USE_SENDSPIN_PLAYER && USE_SENDSPIN_CONTROLLER
|
||||
#endif // USE_ESP_IDF && USE_SENDSPIN_PLAYER && USE_SENDSPIN_CONTROLLER
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER)
|
||||
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER)
|
||||
|
||||
#include "esphome/components/sendspin/sendspin_hub.h"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#include "sendspin_hub.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#ifdef USE_ESP_IDF
|
||||
|
||||
#include "esphome/components/network/util.h"
|
||||
#ifdef USE_ETHERNET
|
||||
@@ -17,26 +17,98 @@
|
||||
|
||||
#include <esp_log.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
static const char *const TAG = "sendspin.hub";
|
||||
|
||||
namespace {
|
||||
|
||||
// Zeroes through a volatile pointer so dead-store elimination cannot drop it.
|
||||
void secure_wipe(void *data, size_t len) {
|
||||
volatile auto *p = static_cast<volatile uint8_t *>(data);
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
p[i] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Call once per key, from setup().
|
||||
ESPPreferenceObject make_blob_pref(const std::string &name, size_t size) {
|
||||
return global_preferences->make_preference(size, fnv1a_hash("sendspin_" + name));
|
||||
}
|
||||
|
||||
// Loads straight into the returned buffer, which the library wipes after use. A failed load can
|
||||
// leave part of a stored private key or PSK behind, so that path wipes it here. This only covers
|
||||
// this buffer: the preference backend keeps its own copy of a pending write until it syncs.
|
||||
std::optional<std::vector<uint8_t>> read_blob(ESPPreferenceObject &pref, size_t size) {
|
||||
std::vector<uint8_t> out(size);
|
||||
if (!pref.load(out.data(), out.size())) {
|
||||
secure_wipe(out.data(), out.size());
|
||||
return std::nullopt;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The library always writes a key's fixed size; any other length is misuse and is not stored.
|
||||
bool write_blob(ESPPreferenceObject &pref, const char *key, size_t size, const uint8_t *data, size_t len) {
|
||||
if (len != size) {
|
||||
ESP_LOGW(TAG, "\"%s\" blob of %zu bytes does not match its size of %zu; rejecting write", key, len, size);
|
||||
return false;
|
||||
}
|
||||
if (!pref.save(data, len)) {
|
||||
ESP_LOGW(TAG, "Failed to persist \"%s\" blob (%zu bytes)", key, len);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// "rec_<n>" to n; nullopt for any other key or a slot with no storage here.
|
||||
std::optional<size_t> parse_record_slot(const std::string &key) {
|
||||
const char *const prefix = sendspin::persistence_keys::RECORD_SLOT_PREFIX;
|
||||
const size_t prefix_len = std::strlen(prefix);
|
||||
if (key.size() <= prefix_len || key.compare(0, prefix_len, prefix) != 0) {
|
||||
return std::nullopt;
|
||||
}
|
||||
size_t slot = 0;
|
||||
for (size_t i = prefix_len; i < key.size(); i++) {
|
||||
const char c = key[i];
|
||||
if (c < '0' || c > '9') {
|
||||
return std::nullopt;
|
||||
}
|
||||
slot = slot * 10 + static_cast<size_t>(c - '0');
|
||||
if (slot >= SENDSPIN_RECORD_SLOTS) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
return slot;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
#ifdef USE_SENDSPIN_ARTWORK
|
||||
// Indexed by the library enums, which start at zero and are contiguous.
|
||||
static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"};
|
||||
static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG", "BMP"};
|
||||
static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG"};
|
||||
#endif
|
||||
|
||||
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"));
|
||||
#ifdef USE_SENDSPIN_PLAYER
|
||||
this->static_delay_pref_ = global_preferences->make_preference<StaticDelayPref>(fnv1a_hash("sendspin_static_delay"));
|
||||
#endif
|
||||
// Set up persistence (preferences must be initialized before providers are added to the client). These key names
|
||||
// are frozen; see PREFERENCE KEYS in sendspin_hub.h.
|
||||
namespace keys = sendspin::persistence_keys;
|
||||
this->keypair_pref_ = make_blob_pref("keypair", keys::KEYPAIR_SIZE);
|
||||
this->pairing_psk_pref_ = make_blob_pref("pair_psk", keys::PAIRING_PSK_SIZE);
|
||||
this->last_played_pref_ = make_blob_pref("last_played", keys::LAST_PLAYED_SIZE);
|
||||
// Released firmware's name, so a calibrated delay survives.
|
||||
this->output_delay_pref_ = make_blob_pref("static_delay", keys::OUTPUT_DELAY_SIZE);
|
||||
for (size_t slot = 0; slot < SENDSPIN_RECORD_SLOTS; slot++) {
|
||||
this->record_slot_prefs_[slot] = make_blob_pref(keys::record_slot_key(slot), keys::RECORD_SLOT_SIZE);
|
||||
}
|
||||
this->record_order_pref_ = make_blob_pref(keys::RECORD_ORDER, SENDSPIN_RECORD_SLOTS);
|
||||
|
||||
// Wire providers and client listener
|
||||
this->client_->set_listener(this);
|
||||
@@ -62,6 +134,9 @@ void SendspinHub::setup() {
|
||||
this->client_->add_player(this->player_config_).set_listener(this->player_listener_);
|
||||
#endif
|
||||
|
||||
// Any server may play without pairing, as before rc1.
|
||||
this->client_->set_unpaired_access_enabled(true);
|
||||
|
||||
#ifndef USE_SENDSPIN_SWITCH
|
||||
this->enabled_ = true;
|
||||
#endif
|
||||
@@ -83,17 +158,35 @@ void SendspinHub::loop() {
|
||||
#endif
|
||||
}
|
||||
|
||||
// Sends each server a goodbye and writes what the client still owed its provider. Wi-Fi sets up
|
||||
// before this hub, so it shuts down after it and the goodbyes can still go out.
|
||||
void SendspinHub::on_shutdown() {
|
||||
// Keeps loop() from starting the client again.
|
||||
this->enabled_ = false;
|
||||
if (this->is_client_running()) {
|
||||
this->client_->stop();
|
||||
}
|
||||
}
|
||||
|
||||
void SendspinHub::dump_config() {
|
||||
// client_id exists only once start() has run.
|
||||
const char *client_id = "(unavailable)";
|
||||
if (this->client_ != nullptr && !this->client_->client_id().empty()) {
|
||||
client_id = this->client_->client_id().c_str();
|
||||
}
|
||||
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Sendspin Hub:\n"
|
||||
" Client ID: %s\n"
|
||||
" MAC address: %s\n"
|
||||
" Manufacturer: %s\n"
|
||||
" Model: %s\n"
|
||||
" Firmware version: %s\n"
|
||||
" Task stack in PSRAM: %s",
|
||||
get_client_id_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(),
|
||||
this->firmware_version_, YESNO(this->task_stack_in_psram_));
|
||||
" Task stack in PSRAM: %s\n"
|
||||
" Unpaired access: %s",
|
||||
client_id, get_mac_address_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(),
|
||||
this->firmware_version_, YESNO(this->task_stack_in_psram_),
|
||||
YESNO(this->client_ != nullptr && this->client_->is_unpaired_access_enabled()));
|
||||
|
||||
#ifdef USE_SENDSPIN_ARTWORK
|
||||
// Slot indices come from the order the image platform entries were declared, so the log is the
|
||||
@@ -154,22 +247,33 @@ void SendspinHub::disconnect_from_server(sendspin::SendspinGoodbyeReason reason)
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
|
||||
void SendspinHub::update_state(sendspin::SendspinClientState state) {
|
||||
if (this->is_client_running()) {
|
||||
this->client_->update_state(state);
|
||||
void SendspinHub::leave_group() {
|
||||
if (this->is_client_running() &&
|
||||
this->client_->get_group_state().playback_state == sendspin::SendspinPlaybackState::PLAYING) {
|
||||
this->client_->leave();
|
||||
}
|
||||
}
|
||||
|
||||
const char *SendspinHub::get_client_id_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) {
|
||||
// The server matches client_id against the L2 source MAC of the device's multicast traffic.
|
||||
const char *SendspinHub::get_mac_address_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) {
|
||||
// The server matches this MAC against the L2 source MAC of the device's multicast traffic.
|
||||
// ESP-IDF derives the ethernet MAC as base+3 by default on ESP32-S3, so we cannot use the
|
||||
// eFuse base MAC when ethernet is the active interface.
|
||||
#ifdef USE_ETHERNET
|
||||
if (ethernet::global_eth_component != nullptr) {
|
||||
return ethernet::global_eth_component->get_eth_mac_address_pretty_into_buffer(buf);
|
||||
ethernet::global_eth_component->get_eth_mac_address_pretty_into_buffer(buf);
|
||||
} else {
|
||||
get_mac_address_pretty_into_buffer(buf);
|
||||
}
|
||||
#else
|
||||
get_mac_address_pretty_into_buffer(buf);
|
||||
#endif
|
||||
return get_mac_address_pretty_into_buffer(buf);
|
||||
// The pretty format is uppercase, but SendspinClientConfig::mac_address must be lowercase.
|
||||
for (char &c : buf) {
|
||||
if (c >= 'A' && c <= 'F') {
|
||||
c += 'a' - 'A';
|
||||
}
|
||||
}
|
||||
return buf.data();
|
||||
}
|
||||
|
||||
const char *SendspinHub::get_product_name_() const {
|
||||
@@ -180,12 +284,14 @@ sendspin::SendspinClientConfig SendspinHub::build_client_config_() {
|
||||
sendspin::SendspinClientConfig config;
|
||||
|
||||
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
config.client_id = SendspinHub::get_client_id_into_buffer(mac_buf);
|
||||
config.mac_address = SendspinHub::get_mac_address_into_buffer(mac_buf);
|
||||
config.name = App.get_friendly_name();
|
||||
config.product_name = this->get_product_name_();
|
||||
config.manufacturer = this->manufacturer_;
|
||||
config.software_version = this->firmware_version_;
|
||||
config.httpd_psram_stack = this->task_stack_in_psram_;
|
||||
config.protocol_task_psram_stack = this->task_stack_in_psram_;
|
||||
config.max_pairing_records = SENDSPIN_RECORD_SLOTS;
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -217,33 +323,56 @@ void SendspinHub::on_release_high_performance() {
|
||||
|
||||
// --- SendspinNetworkProvider override ---
|
||||
|
||||
// THREAD CONTEXT: Main loop (polled by client_->loop())
|
||||
// THREAD CONTEXT: Main loop (polled by start() and client_->loop())
|
||||
bool SendspinHub::is_network_ready() { return network::is_connected(); }
|
||||
|
||||
// --- SendspinPersistenceProvider overrides ---
|
||||
// THREAD CONTEXT: Main loop (the library makes every provider call there, including from start() and stop())
|
||||
|
||||
// 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%08" PRIX32, hash);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Failed to persist last played server hash");
|
||||
std::pair<ESPPreferenceObject *, size_t> SendspinHub::pref_for_key_(const std::string &key) {
|
||||
namespace keys = sendspin::persistence_keys;
|
||||
if (auto slot = parse_record_slot(key); slot.has_value()) {
|
||||
return {&this->record_slot_prefs_[*slot], keys::RECORD_SLOT_SIZE};
|
||||
}
|
||||
return ok;
|
||||
if (key == keys::RECORD_ORDER) {
|
||||
return {&this->record_order_pref_, SENDSPIN_RECORD_SLOTS};
|
||||
}
|
||||
if (key == keys::KEYPAIR) {
|
||||
return {&this->keypair_pref_, keys::KEYPAIR_SIZE};
|
||||
}
|
||||
if (key == keys::PAIRING_PSK) {
|
||||
return {&this->pairing_psk_pref_, keys::PAIRING_PSK_SIZE};
|
||||
}
|
||||
if (key == keys::LAST_PLAYED) {
|
||||
return {&this->last_played_pref_, keys::LAST_PLAYED_SIZE};
|
||||
}
|
||||
if (key == keys::OUTPUT_DELAY) {
|
||||
return {&this->output_delay_pref_, keys::OUTPUT_DELAY_SIZE};
|
||||
}
|
||||
return {nullptr, 0};
|
||||
}
|
||||
|
||||
// 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%08" PRIX32, pref.server_id_hash);
|
||||
return pref.server_id_hash;
|
||||
std::optional<std::vector<uint8_t>> SendspinHub::load_blob(const std::string &key) {
|
||||
auto [pref, size] = this->pref_for_key_(key);
|
||||
if (pref == nullptr) {
|
||||
ESP_LOGW(TAG, "load_blob: unknown key \"%s\"", key.c_str());
|
||||
return std::nullopt;
|
||||
}
|
||||
return std::nullopt;
|
||||
return read_blob(*pref, size);
|
||||
}
|
||||
|
||||
bool SendspinHub::save_blob(const std::string &key, const uint8_t *data, size_t len) {
|
||||
auto [pref, size] = this->pref_for_key_(key);
|
||||
if (pref == nullptr) {
|
||||
ESP_LOGW(TAG, "save_blob: unknown key \"%s\"", key.c_str());
|
||||
return false;
|
||||
}
|
||||
return write_blob(*pref, key.c_str(), size, data, len);
|
||||
}
|
||||
|
||||
// Writes every component's queued preferences, so a failure may belong to another component.
|
||||
bool SendspinHub::commit() { return global_preferences->sync(); }
|
||||
|
||||
// --- Sendspin role specific methods/overrides ---
|
||||
|
||||
#ifdef USE_SENDSPIN_ARTWORK
|
||||
@@ -285,11 +414,7 @@ void SendspinHub::send_client_command(sendspin::SendspinControllerCommand comman
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (invoked from the sendspin.switch action)
|
||||
void SendspinHub::switch_client() {
|
||||
// Clear any EXTERNAL_SOURCE state so the switch command is followed
|
||||
this->update_state(sendspin::SendspinClientState::SYNCHRONIZED);
|
||||
this->send_client_command(sendspin::SendspinControllerCommand::SWITCH);
|
||||
}
|
||||
void SendspinHub::switch_client() { this->send_client_command(sendspin::SendspinControllerCommand::SWITCH); }
|
||||
|
||||
// THREAD CONTEXT: Main loop (ControllerRoleListener override, fired from client_->loop())
|
||||
void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObject &state) {
|
||||
@@ -332,31 +457,8 @@ sendspin::PlayerRole *SendspinHub::get_player_role() {
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override)
|
||||
bool SendspinHub::save_static_delay(uint16_t delay_ms) {
|
||||
StaticDelayPref pref{.delay_ms = delay_ms};
|
||||
bool ok = this->static_delay_pref_.save(&pref);
|
||||
if (ok) {
|
||||
ESP_LOGD(TAG, "Persisted static delay: %u ms", delay_ms);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Failed to persist static delay");
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override)
|
||||
std::optional<uint16_t> SendspinHub::load_static_delay() {
|
||||
StaticDelayPref pref{};
|
||||
if (this->static_delay_pref_.load(&pref)) {
|
||||
ESP_LOGI(TAG, "Loaded static delay: %u ms", pref.delay_ms);
|
||||
return pref.delay_ms;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif // USE_ESP32
|
||||
#endif // USE_ESP_IDF
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#ifdef USE_ESP_IDF
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
@@ -31,9 +31,15 @@
|
||||
#include <sendspin/player_role.h>
|
||||
#endif
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
@@ -44,22 +50,16 @@ namespace esphome::sendspin_ {
|
||||
/// one step later than hub so they can assume hub's setup() has already completed.
|
||||
namespace sendspin_priority {
|
||||
// AFTER_WIFI so the hub runs after the wifi/ethernet drivers are up and we can read the active
|
||||
// interface's MAC for client_id.
|
||||
// interface's MAC address for the device info.
|
||||
inline constexpr float HUB = esphome::setup_priority::AFTER_WIFI;
|
||||
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;
|
||||
};
|
||||
// PREFERENCE KEYS: each library persistence key has its own preference, hashed from "sendspin_" + name. Renaming
|
||||
// one erases it.
|
||||
|
||||
#ifdef USE_SENDSPIN_PLAYER
|
||||
/// @brief Persistent storage structure for player static delay.
|
||||
struct StaticDelayPref {
|
||||
uint16_t delay_ms;
|
||||
};
|
||||
#endif
|
||||
/// Pairing-record slots, handed to the client as max_pairing_records.
|
||||
inline constexpr size_t SENDSPIN_RECORD_SLOTS = sendspin::SendspinClientConfig::DEFAULT_MAX_PAIRING_RECORDS;
|
||||
|
||||
/// @brief Thin adapter over sendspin::SendspinClient.
|
||||
///
|
||||
@@ -93,6 +93,7 @@ class SendspinHub final : public Component,
|
||||
float get_setup_priority() const override { return sendspin_priority::HUB; }
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void on_shutdown() override;
|
||||
void dump_config() override;
|
||||
|
||||
/// @brief Connects the underlying client to the given Sendspin server.
|
||||
@@ -113,14 +114,11 @@ class SendspinHub final : public Component,
|
||||
/// - `USER_REQUEST`: user explicitly requested disconnect.
|
||||
void disconnect_from_server(sendspin::SendspinGoodbyeReason reason);
|
||||
|
||||
/// @brief Updates the client's reported playback state on the server.
|
||||
/// @brief Leaves the current group so another source can use the speaker.
|
||||
///
|
||||
/// Sends `client/leave` while the group is playing. A stopped group is left alone, so the device stays grouped.
|
||||
/// No-op if the hub's client is not running. 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);
|
||||
void leave_group();
|
||||
|
||||
// --- Configuration setters (called from codegen) ---
|
||||
|
||||
@@ -219,12 +217,16 @@ class SendspinHub final : public Component,
|
||||
/// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info.
|
||||
sendspin::SendspinClientConfig build_client_config_();
|
||||
|
||||
/// The preference a library key is stored in and its blob size, or {nullptr, 0} for a key with
|
||||
/// no storage here.
|
||||
std::pair<ESPPreferenceObject *, size_t> pref_for_key_(const std::string &key);
|
||||
|
||||
/// @brief Returns the product name reported to the server: the configured model, or the device name.
|
||||
const char *get_product_name_() const;
|
||||
|
||||
/// @brief Writes the active network interface's MAC into @p buf and returns its data pointer.
|
||||
/// @brief Writes the active network interface's MAC, in lowercase, into @p buf and returns its data pointer.
|
||||
/// Uses the ethernet MAC if ethernet is configured, otherwise the base MAC (used by wifi).
|
||||
static const char *get_client_id_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf);
|
||||
static const char *get_mac_address_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf);
|
||||
|
||||
#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE
|
||||
/// @brief Keeps the `_sendspin` mDNS service advertised while the client is running.
|
||||
@@ -242,8 +244,10 @@ class SendspinHub final : public Component,
|
||||
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;
|
||||
// The library calls commit() after writing pairing secrets, so they reach flash right away.
|
||||
std::optional<std::vector<uint8_t>> load_blob(const std::string &key) override;
|
||||
bool save_blob(const std::string &key, const uint8_t *data, size_t len) override;
|
||||
bool commit() override;
|
||||
|
||||
// --- Sendspin role specific methods/overrides/member variables ---
|
||||
|
||||
@@ -291,16 +295,17 @@ class SendspinHub final : public Component,
|
||||
#ifdef USE_SENDSPIN_PLAYER
|
||||
sendspin::PlayerRoleListener *player_listener_{nullptr};
|
||||
sendspin::PlayerRoleConfig player_config_{};
|
||||
|
||||
// Part of SendspinPersistenceProvider overrides
|
||||
ESPPreferenceObject static_delay_pref_;
|
||||
std::optional<uint16_t> load_static_delay() override;
|
||||
bool save_static_delay(uint16_t delay_ms) override;
|
||||
#endif
|
||||
|
||||
// --- Core member variables ---
|
||||
|
||||
ESPPreferenceObject last_played_server_pref_;
|
||||
// Built once in setup(): make_preference() allocates a backend that is never freed.
|
||||
ESPPreferenceObject keypair_pref_;
|
||||
ESPPreferenceObject pairing_psk_pref_;
|
||||
ESPPreferenceObject last_played_pref_;
|
||||
ESPPreferenceObject output_delay_pref_;
|
||||
std::array<ESPPreferenceObject, SENDSPIN_RECORD_SLOTS> record_slot_prefs_;
|
||||
ESPPreferenceObject record_order_pref_;
|
||||
|
||||
std::unique_ptr<sendspin::SendspinClient> client_;
|
||||
|
||||
@@ -349,4 +354,4 @@ class SendspinPollingChild : public PollingComponent, public Parented<SendspinHu
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif // USE_ESP32
|
||||
#endif // USE_ESP_IDF
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#include "sendspin_sensor.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR)
|
||||
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR)
|
||||
|
||||
#include <sendspin/metadata_role.h>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR)
|
||||
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR)
|
||||
|
||||
#include "esphome/components/sendspin/sendspin_hub.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#include "sendspin_switch.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#ifdef USE_ESP_IDF
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
@@ -23,4 +23,4 @@ void SendspinSwitch::write_state(bool state) {
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif // USE_ESP32
|
||||
#endif // USE_ESP_IDF
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#ifdef USE_ESP_IDF
|
||||
|
||||
#include "esphome/components/sendspin/sendspin_hub.h"
|
||||
#include "esphome/components/switch/switch.h"
|
||||
@@ -21,4 +21,4 @@ class SendspinSwitch final : public switch_::Switch, public SendspinChild {
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif // USE_ESP32
|
||||
#endif // USE_ESP_IDF
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#include "sendspin_text_sensor.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR)
|
||||
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR)
|
||||
|
||||
#include <sendspin/metadata_role.h>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR)
|
||||
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR)
|
||||
|
||||
#include "esphome/components/sendspin/sendspin_hub.h"
|
||||
#include "esphome/components/text_sensor/text_sensor.h"
|
||||
|
||||
@@ -101,8 +101,11 @@ dependencies:
|
||||
- if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32s31, esp32p4, esp32h4]"
|
||||
esp32async/asynctcp:
|
||||
version: 3.4.91
|
||||
# sendspin is ESP-IDF only (it needs noise-c as an ESP-IDF component, see below)
|
||||
sendspin/sendspin-cpp:
|
||||
version: 0.8.0
|
||||
version: 0.9.3
|
||||
rules:
|
||||
- if: "$ESPHOME_ARDUINO_COMPONENT == 0"
|
||||
lvgl/lvgl:
|
||||
version: 9.5.0
|
||||
fastled/FastLED:
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Validation tests for the frameworks the sendspin hub builds on.
|
||||
|
||||
sendspin-cpp needs noise-c as an ESP-IDF component, which Arduino below IDF 6.0
|
||||
cannot use, so the hub is ESP-IDF only.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.sendspin import CONFIG_SCHEMA
|
||||
from esphome.const import PlatformFramework
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
HUB_CONFIG = {"id": "sendspin_hub_id"}
|
||||
|
||||
|
||||
def test_arduino_rejected(set_core_config: SetCoreConfigCallable) -> None:
|
||||
set_core_config(PlatformFramework.ESP32_ARDUINO)
|
||||
|
||||
with pytest.raises(cv.Invalid, match="esp-idf"):
|
||||
CONFIG_SCHEMA(HUB_CONFIG)
|
||||
|
||||
|
||||
def test_esp_idf_accepted(set_core_config: SetCoreConfigCallable) -> None:
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
|
||||
assert CONFIG_SCHEMA(HUB_CONFIG)["id"].id == "sendspin_hub_id"
|
||||
@@ -53,6 +53,14 @@ def test_jpeg_alias_maps_to_one_enum(
|
||||
assert _get_data().artwork_preferences[0]["format"] == IMAGE_FORMAT_JPEG
|
||||
|
||||
|
||||
def test_bmp_rejected(set_core_config: SetCoreConfigCallable) -> None:
|
||||
"""The Sendspin protocol only allows JPEG and PNG artwork."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
|
||||
with pytest.raises(cv.Invalid, match="BMP artwork is no longer supported"):
|
||||
CONFIG_SCHEMA(_slot_config(format="BMP"))
|
||||
|
||||
|
||||
def test_too_many_slots_rejected(set_core_config: SetCoreConfigCallable) -> None:
|
||||
"""Slot numbers run out after MAX_ARTWORK_SLOTS entries."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
|
||||
@@ -88,3 +88,13 @@ def test_opus_at_wrong_sample_rate_rejected(
|
||||
|
||||
with pytest.raises(cv.Invalid, match="requires a sample_rate of 48000"):
|
||||
CONFIG_SCHEMA(_media_source_config(codecs=["opus"], sample_rate=44100))
|
||||
|
||||
|
||||
def test_opus_only_codec_list_rejected(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""Every server supports FLAC or PCM, so sendspin-cpp requires one of them."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
|
||||
with pytest.raises(cv.Invalid, match="must include 'flac' or 'pcm'"):
|
||||
CONFIG_SCHEMA(_media_source_config(codecs=["opus"]))
|
||||
|
||||
Reference in New Issue
Block a user