From e0b112c584ed91b799308840f935f139c11e4a86 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 11 Aug 2026 17:03:27 -0400 Subject: [PATCH] [sendspin] Clear metadata and controller state on disconnect (#18289) Co-authored-by: J. Nick Koston --- .../media_player/sendspin_media_player.cpp | 22 ++++++++++--- .../media_player/sendspin_media_player.h | 3 ++ esphome/components/sendspin/sendspin_hub.cpp | 12 +++++++ esphome/components/sendspin/sendspin_hub.h | 19 +++++++++-- .../sendspin/sensor/sendspin_sensor.cpp | 26 ++++++++++++--- .../text_sensor/sendspin_text_sensor.cpp | 32 +++++++++---------- 6 files changed, 87 insertions(+), 27 deletions(-) diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.cpp b/esphome/components/sendspin/media_player/sendspin_media_player.cpp index beb2028689..fe0bda6f42 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.cpp +++ b/esphome/components/sendspin/media_player/sendspin_media_player.cpp @@ -34,11 +34,7 @@ void SendspinMediaPlayer::setup() { new_state = media_player::MEDIA_PLAYER_STATE_IDLE; break; } - if (this->state != new_state) { - this->state = new_state; - this->publish_state(); - ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state)); - } + this->set_playback_state_(new_state); } }); @@ -52,11 +48,27 @@ void SendspinMediaPlayer::setup() { } }); + // The connection dropped, so nothing is playing. The server never gets to send a final "stopped" group update, so + // without this the entity keeps reporting playing indefinitely. Volume and mute keep their last values, since + // media_player has no way to express an unknown volume. + this->parent_->add_controller_state_clear_callback( + [this]() { this->set_playback_state_(media_player::MEDIA_PLAYER_STATE_IDLE); }); + // Publish an initial state this->state = media_player::MEDIA_PLAYER_STATE_IDLE; this->publish_state(); } +// THREAD CONTEXT: Main loop (called from the callbacks registered in setup()) +void SendspinMediaPlayer::set_playback_state_(media_player::MediaPlayerState new_state) { + if (this->state == new_state) { + return; + } + this->state = new_state; + this->publish_state(); + ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state)); +} + // THREAD CONTEXT: Main loop (invoked by the media_player framework) media_player::MediaPlayerTraits SendspinMediaPlayer::get_traits() { auto traits = media_player::MediaPlayerTraits(); diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.h b/esphome/components/sendspin/media_player/sendspin_media_player.h index 651e1562be..ff76473189 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.h +++ b/esphome/components/sendspin/media_player/sendspin_media_player.h @@ -25,6 +25,9 @@ class SendspinMediaPlayer final : public SendspinChild, public media_player::Med // Receives commands from HA void control(const media_player::MediaPlayerCall &call) override; + /// @brief Publishes @p new_state if it differs from the current state. + void set_playback_state_(media_player::MediaPlayerState new_state); + float volume_increment_{0.05f}; bool muted_{false}; }; diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 2d2f646382..028491284a 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -239,6 +239,12 @@ void SendspinHub::send_client_command(sendspin::SendspinControllerCommand comman void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObject &state) { this->controller_state_callbacks_.call(state); } + +// THREAD CONTEXT: Main loop (ControllerRoleListener override, fired from client_->loop()) +// Unlike metadata, this cannot be fanned out as a default-constructed state object: volume and muted are plain values +// rather than optionals, so children would read a real-looking 0% volume where we mean no value at all. A separate +// callback lets each child clear only what it can represent. +void SendspinHub::on_controller_state_clear() { this->controller_state_clear_callbacks_.call(); } #endif #ifdef USE_SENDSPIN_METADATA @@ -247,6 +253,12 @@ void SendspinHub::on_metadata(const sendspin::ServerMetadataStateObject &metadat this->metadata_update_callbacks_.call(metadata); } +// THREAD CONTEXT: Main loop (MetadataRoleListener override, fired from client_->loop()) +// The cached metadata was dropped because the connection to the server was lost, so what the children now mirror is +// the empty state. Fanning that out as a default-constructed state object rather than through a separate callback +// keeps one code path in the children: every field is nullopt, which they already publish as empty/unknown. +void SendspinHub::on_metadata_clear() { this->metadata_update_callbacks_.call(sendspin::ServerMetadataStateObject{}); } + // THREAD CONTEXT: Main loop (invoked from Sendspin components) uint32_t SendspinHub::get_track_progress_ms() const { if (this->is_ready()) { diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index a495fdcf37..7c50c3eb80 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -155,9 +155,18 @@ class SendspinHub final : public Component, template void add_controller_state_callback(F &&callback) { this->controller_state_callbacks_.add(std::forward(callback)); } + + /// @brief Registers a callback that fires when the connection is lost and the cached controller state is dropped. + template void add_controller_state_clear_callback(F &&callback) { + this->controller_state_clear_callbacks_.add(std::forward(callback)); + } #endif #ifdef USE_SENDSPIN_METADATA + /// @brief Registers a callback that fires when the server sends metadata. + /// + /// Also fires when the connection is lost, with an all-empty state object (every field nullopt, timestamp 0) meaning + /// the cached metadata was dropped. Subscribers must treat an absent field as cleared, not as no update. template void add_metadata_update_callback(F &&callback) { this->metadata_update_callbacks_.add(std::forward(callback)); } @@ -220,8 +229,12 @@ class SendspinHub final : public Component, void on_controller_state(const sendspin::ServerStateControllerObject &state) override; - // Callback fan-out to child components; they filter as needed - CallbackManager controller_state_callbacks_{}; + void on_controller_state_clear() override; + + // Callback fan-out to child components; they filter as needed. Only a media_player subscribes, while the switch + // action and the media source enable the controller role without one, so keep the idle cost to a single pointer. + LazyCallbackManager controller_state_callbacks_{}; + LazyCallbackManager controller_state_clear_callbacks_{}; #endif #ifdef USE_SENDSPIN_METADATA @@ -229,6 +242,8 @@ class SendspinHub final : public Component, void on_metadata(const sendspin::ServerMetadataStateObject &metadata) override; + void on_metadata_clear() override; + // Callback fan-out to child components; they filter as needed CallbackManager metadata_update_callbacks_{}; #endif diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.cpp b/esphome/components/sendspin/sensor/sendspin_sensor.cpp index 68848a6f3e..dcbab75b65 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.cpp +++ b/esphome/components/sendspin/sensor/sendspin_sensor.cpp @@ -4,6 +4,8 @@ #include +#include + namespace esphome::sendspin_ { static const char *const TAG = "sendspin.sensor"; @@ -20,6 +22,13 @@ void SendspinTrackProgressSensor::dump_config() { void SendspinTrackProgressSensor::setup() { this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { if (!metadata.progress.has_value()) { + // Progress is unknown: the server has not reported it, or it was cleared (e.g. on disconnect). Stop polling and + // report unknown rather than leaving the last position frozen on the frontend. Only the transition is published; + // NAN never compares equal to itself, so an unguarded publish would repeat on every metadata update. + this->stop_poller(); + if (!std::isnan(this->get_raw_state())) { + this->publish_state(NAN); + } return; } const auto &progress = metadata.progress.value(); @@ -34,6 +43,11 @@ void SendspinTrackProgressSensor::setup() { this->start_poller(); } }); + + // PollingComponent starts the poller before setup(), but there is nothing to interpolate yet: + // get_track_progress_ms() returns 0 until the server reports a position, so polling now would publish 0 every tick + // from boot until the first metadata arrives. The callback above starts it once playback is running. + this->stop_poller(); } // THREAD CONTEXT: Main loop. @@ -80,15 +94,19 @@ std::optional SendspinMetadataSensor::extract_value_(const sendspin::Serv // (SendspinHub dispatches metadata from client_->loop()). void SendspinMetadataSensor::setup() { this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (auto value = this->extract_value_(metadata)) { - this->publish_if_changed_(*value); - } + // A field the server has not provided, or has explicitly cleared, is published as NAN (the sensor convention for + // unknown) rather than skipped, so a value that goes away does not linger from the previous track. + this->publish_if_changed_(this->extract_value_(metadata).value_or(NAN)); }); } // Dedup to avoid frontend churn; Sensor::publish_state always notifies without checking for changes. void SendspinMetadataSensor::publish_if_changed_(float value) { - if (this->get_raw_state() != value) { + const float current = this->get_raw_state(); + // The raw state starts as NAN, so a field that is already cleared when the first update arrives is suppressed here + // as well: the frontend still shows the sensor as unknown, which is what a clear means. NAN never compares equal to + // itself, so a field that stays cleared would republish on every metadata update without the second check. + if (current != value && !(std::isnan(current) && std::isnan(value))) { this->publish_state(value); } } diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp index 9843fb966e..554e01cf88 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp @@ -12,40 +12,40 @@ static const char *const TAG = "sendspin.text_sensor"; void SendspinTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sendspin", this); } +// A field is nullopt when the server has not provided it or has explicitly cleared it. Both mean there is nothing to +// show, so return the empty string and let the caller publish it; returning early would leave the previous track's +// value on display. +// +// The empty string is not the same as unknown. A text sensor reports unknown through the API's missing_state flag, +// which follows has_state(), and has_state() is only ever set, never cleared. Once a real value has been published, +// an empty state is the closest we can get. The numeric sensors publish NAN, which does read as unknown. const char *SendspinTextSensor::extract_value_(const sendspin::ServerMetadataStateObject &metadata) const { switch (this->metadata_type_) { case SendspinTextMetadataTypes::TITLE: - if (metadata.title.has_value()) - return metadata.title.value().c_str(); - return nullptr; + return metadata.title.has_value() ? metadata.title.value().c_str() : ""; case SendspinTextMetadataTypes::ARTIST: - if (metadata.artist.has_value()) - return metadata.artist.value().c_str(); - return nullptr; + return metadata.artist.has_value() ? metadata.artist.value().c_str() : ""; case SendspinTextMetadataTypes::ALBUM: - if (metadata.album.has_value()) - return metadata.album.value().c_str(); - return nullptr; + return metadata.album.has_value() ? metadata.album.value().c_str() : ""; case SendspinTextMetadataTypes::ALBUM_ARTIST: - if (metadata.album_artist.has_value()) - return metadata.album_artist.value().c_str(); - return nullptr; + return metadata.album_artist.has_value() ? metadata.album_artist.value().c_str() : ""; } - return nullptr; + return ""; } // THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop // (SendspinHub dispatches metadata from client_->loop()). void SendspinTextSensor::setup() { this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (const char *value = this->extract_value_(metadata)) { - this->publish_if_changed_(value); - } + this->publish_if_changed_(this->extract_value_(metadata)); }); } // Dedup to avoid frontend churn; TextSensor::publish_state already dedups the string assign but still notifies. void SendspinTextSensor::publish_if_changed_(const char *value) { + // The state starts empty, so a field that is already cleared when the first update arrives is suppressed here: the + // entity stays unknown rather than being dropped out of it for good by an empty publish. Later clears do publish the + // empty string and fire on_value with it. if (this->get_raw_state() != value) { this->publish_state(value); }