From 1d86cc39e99722153c58e87cbb0af230401e00b6 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 8 Oct 2026 12:33:46 -0400 Subject: [PATCH] [sendspin] Update to sendspin-cpp v0.9.3 (#20347) --- esphome/components/sendspin/__init__.py | 16 +- esphome/components/sendspin/image/__init__.py | 16 +- .../sendspin/image/sendspin_image.cpp | 4 +- .../sendspin/image/sendspin_image.h | 4 +- .../media_player/sendspin_media_player.cpp | 2 +- .../media_player/sendspin_media_player.h | 2 +- .../sendspin/media_source/__init__.py | 7 + .../media_source/sendspin_media_source.cpp | 14 +- .../media_source/sendspin_media_source.h | 2 +- esphome/components/sendspin/sendspin_hub.cpp | 232 +++++++++++++----- esphome/components/sendspin/sendspin_hub.h | 63 ++--- .../sendspin/sensor/sendspin_sensor.cpp | 2 +- .../sendspin/sensor/sendspin_sensor.h | 2 +- .../sendspin/switch/sendspin_switch.cpp | 4 +- .../sendspin/switch/sendspin_switch.h | 4 +- .../text_sensor/sendspin_text_sensor.cpp | 2 +- .../text_sensor/sendspin_text_sensor.h | 2 +- esphome/idf_component.yml | 5 +- .../sendspin/test_framework.py | 27 ++ tests/component_tests/sendspin/test_image.py | 8 + .../sendspin/test_media_source.py | 10 + 21 files changed, 303 insertions(+), 125 deletions(-) create mode 100644 tests/component_tests/sendspin/test_framework.py diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 42dae464ed..2fb8d4a17e 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -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: diff --git a/esphome/components/sendspin/image/__init__.py b/esphome/components/sendspin/image/__init__.py index e26e8204aa..a5336d5e05 100644 --- a/esphome/components/sendspin/image/__init__.py +++ b/esphome/components/sendspin/image/__init__.py @@ -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, diff --git a/esphome/components/sendspin/image/sendspin_image.cpp b/esphome/components/sendspin/image/sendspin_image.cpp index 558a292d5b..8605048f33 100644 --- a/esphome/components/sendspin/image/sendspin_image.cpp +++ b/esphome/components/sendspin/image/sendspin_image.cpp @@ -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); } diff --git a/esphome/components/sendspin/image/sendspin_image.h b/esphome/components/sendspin/image/sendspin_image.h index 2f6f4e8a4d..43dd4ed66b 100644 --- a/esphome/components/sendspin/image/sendspin_image.h +++ b/esphome/components/sendspin/image/sendspin_image.h @@ -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; } }; diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.cpp b/esphome/components/sendspin/media_player/sendspin_media_player.cpp index 59ead1bb53..ec5d5b2cbc 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.cpp +++ b/esphome/components/sendspin/media_player/sendspin_media_player.cpp @@ -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" diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.h b/esphome/components/sendspin/media_player/sendspin_media_player.h index ff76473189..bc07254efe 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.h +++ b/esphome/components/sendspin/media_player/sendspin_media_player.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" diff --git a/esphome/components/sendspin/media_source/__init__.py b/esphome/components/sendspin/media_source/__init__.py index 743d0bb62c..31c454fb71 100644 --- a/esphome/components/sendspin/media_source/__init__.py +++ b/esphome/components/sendspin/media_source/__init__.py @@ -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 diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.cpp b/esphome/components/sendspin/media_source/sendspin_media_source.cpp index c3fb1fe1cb..f1706a260b 100644 --- a/esphome/components/sendspin/media_source/sendspin_media_source.cpp +++ b/esphome/components/sendspin/media_source/sendspin_media_source.cpp @@ -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 diff --git a/esphome/components/sendspin/media_source/sendspin_media_source.h b/esphome/components/sendspin/media_source/sendspin_media_source.h index 1c5cb625bf..f0800dbcb1 100644 --- a/esphome/components/sendspin/media_source/sendspin_media_source.h +++ b/esphome/components/sendspin/media_source/sendspin_media_source.h @@ -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" diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 15d358d50f..0348850225 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -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 +#include +#include + 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(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> read_blob(ESPPreferenceObject &pref, size_t size) { + std::vector 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_" to n; nullopt for any other key or a slot with no storage here. +std::optional 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(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(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(fnv1a_hash("sendspin_last_played")); -#ifdef USE_SENDSPIN_PLAYER - this->static_delay_pref_ = global_preferences->make_preference(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 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 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 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 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> 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 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 diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index ea09dafe68..43c56feca7 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -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 #endif +#include +#include +#include #include #include #include +#include +#include +#include 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 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 buf); + static const char *get_mac_address_into_buffer(std::span 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 load_last_server_hash() override; + // The library calls commit() after writing pairing secrets, so they reach flash right away. + std::optional> 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 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 record_slot_prefs_; + ESPPreferenceObject record_order_pref_; std::unique_ptr client_; @@ -349,4 +354,4 @@ class SendspinPollingChild : public PollingComponent, public Parented diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.h b/esphome/components/sendspin/sensor/sendspin_sensor.h index 5b29fff55f..77bb835598 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.h +++ b/esphome/components/sendspin/sensor/sendspin_sensor.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" diff --git a/esphome/components/sendspin/switch/sendspin_switch.cpp b/esphome/components/sendspin/switch/sendspin_switch.cpp index 0bf029d4c7..af4d053854 100644 --- a/esphome/components/sendspin/switch/sendspin_switch.cpp +++ b/esphome/components/sendspin/switch/sendspin_switch.cpp @@ -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 diff --git a/esphome/components/sendspin/switch/sendspin_switch.h b/esphome/components/sendspin/switch/sendspin_switch.h index 253d952b22..f9173c362d 100644 --- a/esphome/components/sendspin/switch/sendspin_switch.h +++ b/esphome/components/sendspin/switch/sendspin_switch.h @@ -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 diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp index 554e01cf88..69dd32e785 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp @@ -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 diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h index d38f360d94..9fe1c9381e 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.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" diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 6bae823acb..ed1202fdb9 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -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: diff --git a/tests/component_tests/sendspin/test_framework.py b/tests/component_tests/sendspin/test_framework.py new file mode 100644 index 0000000000..0add0d0f0a --- /dev/null +++ b/tests/component_tests/sendspin/test_framework.py @@ -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" diff --git a/tests/component_tests/sendspin/test_image.py b/tests/component_tests/sendspin/test_image.py index be3b7d6684..dc9e4978c6 100644 --- a/tests/component_tests/sendspin/test_image.py +++ b/tests/component_tests/sendspin/test_image.py @@ -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) diff --git a/tests/component_tests/sendspin/test_media_source.py b/tests/component_tests/sendspin/test_media_source.py index 6c2f79198d..db576b37af 100644 --- a/tests/component_tests/sendspin/test_media_source.py +++ b/tests/component_tests/sendspin/test_media_source.py @@ -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"]))