[sendspin] Clear metadata and controller state on disconnect (#18289)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Kevin Ahrendt
2026-08-11 21:03:27 +00:00
committed by GitHub
co-authored by J. Nick Koston
parent 7f0d6a8696
commit e0b112c584
6 changed files with 87 additions and 27 deletions
@@ -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();
@@ -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};
};
@@ -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()) {
+17 -2
View File
@@ -155,9 +155,18 @@ class SendspinHub final : public Component,
template<typename F> void add_controller_state_callback(F &&callback) {
this->controller_state_callbacks_.add(std::forward<F>(callback));
}
/// @brief Registers a callback that fires when the connection is lost and the cached controller state is dropped.
template<typename F> void add_controller_state_clear_callback(F &&callback) {
this->controller_state_clear_callbacks_.add(std::forward<F>(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<typename F> void add_metadata_update_callback(F &&callback) {
this->metadata_update_callbacks_.add(std::forward<F>(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<void(const sendspin::ServerStateControllerObject &)> 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<void(const sendspin::ServerStateControllerObject &)> controller_state_callbacks_{};
LazyCallbackManager<void()> 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<void(const sendspin::ServerMetadataStateObject &)> metadata_update_callbacks_{};
#endif
@@ -4,6 +4,8 @@
#include <sendspin/metadata_role.h>
#include <cmath>
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<float> 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);
}
}
@@ -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);
}