mirror of
https://github.com/esphome/esphome.git
synced 2026-10-05 18:41:32 +00:00
[sendspin] Clear metadata and controller state on disconnect (#18289)
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
co-authored by
J. Nick Koston
parent
7f0d6a8696
commit
e0b112c584
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user