From ac7f0f0b74549d4add4f97cf53186f289b01cbe9 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 07:07:00 -0400 Subject: [PATCH 1/8] [sendspin] Add a metadata text sensor component (#15969) --- CODEOWNERS | 1 + esphome/components/sendspin/sendspin_hub.cpp | 11 +++ esphome/components/sendspin/sendspin_hub.h | 19 +++++ .../sendspin/text_sensor/__init__.py | 55 ++++++++++++ .../text_sensor/sendspin_text_sensor.cpp | 85 +++++++++++++++++++ .../text_sensor/sendspin_text_sensor.h | 35 ++++++++ .../sendspin/common-text_sensor.yaml | 21 +++++ .../sendspin/test-text_sensor.esp32-idf.yaml | 1 + 8 files changed, 228 insertions(+) create mode 100644 esphome/components/sendspin/text_sensor/__init__.py create mode 100644 esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp create mode 100644 esphome/components/sendspin/text_sensor/sendspin_text_sensor.h create mode 100644 tests/components/sendspin/common-text_sensor.yaml create mode 100644 tests/components/sendspin/test-text_sensor.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 822b0e973c..f4b288b23d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -443,6 +443,7 @@ esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct esphome/components/sendspin/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt +esphome/components/sendspin/text_sensor/* @kahrendt esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core esphome/components/serial_proxy/* @kbx81 diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 25e541a493..da298feb86 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -39,6 +39,10 @@ void SendspinHub::setup() { this->controller_role_->set_listener(this); #endif +#ifdef USE_SENDSPIN_METADATA + this->client_->add_metadata().set_listener(this); +#endif + #ifdef USE_SENDSPIN_PLAYER this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif @@ -167,6 +171,13 @@ void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObjec } #endif +#ifdef USE_SENDSPIN_METADATA +// THREAD CONTEXT: Main loop (MetadataRoleListener override, fired from client_->loop()) +void SendspinHub::on_metadata(const sendspin::ServerMetadataStateObject &metadata) { + this->metadata_update_callbacks_.call(metadata); +} +#endif + #ifdef USE_SENDSPIN_PLAYER // THREAD CONTEXT: Main loop, called from child component setup() after player role is created and configured sendspin::PlayerRole *SendspinHub::get_player_role() { diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index c9266bd4d1..8d9c58a3ab 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -16,6 +16,9 @@ #ifdef USE_SENDSPIN_CONTROLLER #include #endif +#ifdef USE_SENDSPIN_METADATA +#include +#endif #ifdef USE_SENDSPIN_PLAYER #include #endif @@ -66,6 +69,9 @@ struct StaticDelayPref { class SendspinHub final : public Component, #ifdef USE_SENDSPIN_CONTROLLER public sendspin::ControllerRoleListener, +#endif +#ifdef USE_SENDSPIN_METADATA + public sendspin::MetadataRoleListener, #endif public sendspin::SendspinClientListener, public sendspin::SendspinNetworkProvider, @@ -122,6 +128,12 @@ class SendspinHub final : public Component, } #endif +#ifdef USE_SENDSPIN_METADATA + template void add_metadata_update_callback(F &&callback) { + this->metadata_update_callbacks_.add(std::forward(callback)); + } +#endif + #ifdef USE_SENDSPIN_PLAYER void set_listener(sendspin::PlayerRoleListener *listener) { this->player_listener_ = listener; } void set_player_config(const sendspin::PlayerRoleConfig &config) { this->player_config_ = config; } @@ -159,6 +171,13 @@ class SendspinHub final : public Component, CallbackManager controller_state_callbacks_{}; #endif +#ifdef USE_SENDSPIN_METADATA + void on_metadata(const sendspin::ServerMetadataStateObject &metadata) override; + + // Callback fan-out to child components; they filter as needed + CallbackManager metadata_update_callbacks_{}; +#endif + #ifdef USE_SENDSPIN_PLAYER sendspin::PlayerRoleListener *player_listener_{nullptr}; sendspin::PlayerRoleConfig player_config_{}; diff --git a/esphome/components/sendspin/text_sensor/__init__.py b/esphome/components/sendspin/text_sensor/__init__.py new file mode 100644 index 0000000000..b7f216ca0c --- /dev/null +++ b/esphome/components/sendspin/text_sensor/__init__.py @@ -0,0 +1,55 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_TYPE +from esphome.types import ConfigType + +from .. import CONF_SENDSPIN_ID, SendspinHub, request_metadata_support, sendspin_ns + +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["sendspin"] + +SendspinTextSensor = sendspin_ns.class_( + "SendspinTextSensor", + text_sensor.TextSensor, + cg.Component, +) + +SendspinTextMetadataTypes = sendspin_ns.enum("SendspinTextMetadataTypes", is_class=True) +SENDSPIN_TEXT_METADATA_TYPES = { + "title": SendspinTextMetadataTypes.TITLE, + "artist": SendspinTextMetadataTypes.ARTIST, + "album": SendspinTextMetadataTypes.ALBUM, + "album_artist": SendspinTextMetadataTypes.ALBUM_ARTIST, + "year": SendspinTextMetadataTypes.YEAR, + "track": SendspinTextMetadataTypes.TRACK, +} + + +def _request_roles(config: ConfigType) -> ConfigType: + """Request the necessary Sendspin roles for the text sensor.""" + request_metadata_support() + + return config + + +CONFIG_SCHEMA = cv.All( + text_sensor.text_sensor_schema().extend( + { + cv.GenerateID(): cv.declare_id(SendspinTextSensor), + cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), + cv.Required(CONF_TYPE): cv.enum(SENDSPIN_TEXT_METADATA_TYPES), + } + ), + cv.only_on_esp32, + _request_roles, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + await text_sensor.register_text_sensor(var, config) + + cg.add(var.set_metadata_type(config[CONF_TYPE])) diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp new file mode 100644 index 0000000000..d16d51f63c --- /dev/null +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp @@ -0,0 +1,85 @@ +#include "sendspin_text_sensor.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) + +#include "esphome/core/helpers.h" + +#include + +#include + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.text_sensor"; + +void SendspinTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sendspin", this); } + +// THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop +// (SendspinHub dispatches metadata from client_->loop()). +void SendspinTextSensor::setup() { + switch (this->metadata_type_) { + case SendspinTextMetadataTypes::TITLE: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.title.has_value()) { + this->publish_if_changed_(metadata.title.value().c_str()); + } + }); + break; + } + case SendspinTextMetadataTypes::ARTIST: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.artist.has_value()) { + this->publish_if_changed_(metadata.artist.value().c_str()); + } + }); + break; + } + case SendspinTextMetadataTypes::ALBUM: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.album.has_value()) { + this->publish_if_changed_(metadata.album.value().c_str()); + } + }); + break; + } + case SendspinTextMetadataTypes::ALBUM_ARTIST: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.album_artist.has_value()) { + this->publish_if_changed_(metadata.album_artist.value().c_str()); + } + }); + break; + } + case SendspinTextMetadataTypes::YEAR: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.year.has_value() && metadata.year.value() <= 9999) { + char buf[UINT32_MAX_STR_SIZE]; + uint32_to_str(buf, metadata.year.value()); + this->publish_if_changed_(buf); + } + }); + break; + } + case SendspinTextMetadataTypes::TRACK: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.track.has_value() && metadata.track.value() <= 9999) { + char buf[UINT32_MAX_STR_SIZE]; + uint32_to_str(buf, metadata.track.value()); + this->publish_if_changed_(buf); + } + }); + break; + } + } +} + +// Dedup to avoid frontend churn; TextSensor::publish_state already dedups the string assign but still notifies. +void SendspinTextSensor::publish_if_changed_(const char *value) { + if (this->get_raw_state() != value) { + this->publish_state(value); + } +} + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h new file mode 100644 index 0000000000..d9ef49c938 --- /dev/null +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR) + +#include "esphome/components/sendspin/sendspin_hub.h" +#include "esphome/components/text_sensor/text_sensor.h" + +namespace esphome::sendspin_ { + +enum class SendspinTextMetadataTypes { + TITLE, + ARTIST, + ALBUM, + ALBUM_ARTIST, + YEAR, + TRACK, +}; + +class SendspinTextSensor : public SendspinChild, public text_sensor::TextSensor { + public: + void dump_config() override; + void setup() override; + + void set_metadata_type(SendspinTextMetadataTypes metadata_type) { this->metadata_type_ = metadata_type; } + + protected: + void publish_if_changed_(const char *value); + + SendspinTextMetadataTypes metadata_type_; +}; + +} // namespace esphome::sendspin_ +#endif diff --git a/tests/components/sendspin/common-text_sensor.yaml b/tests/components/sendspin/common-text_sensor.yaml new file mode 100644 index 0000000000..0bfbf45757 --- /dev/null +++ b/tests/components/sendspin/common-text_sensor.yaml @@ -0,0 +1,21 @@ +<<: !include common.yaml + +text_sensor: + - platform: sendspin + name: "Title" + type: title + - platform: sendspin + name: "Artist" + type: artist + - platform: sendspin + name: "Album" + type: album + - platform: sendspin + name: "Album Artist" + type: album_artist + - platform: sendspin + name: "Year" + type: year + - platform: sendspin + name: "Track Number" + type: track diff --git a/tests/components/sendspin/test-text_sensor.esp32-idf.yaml b/tests/components/sendspin/test-text_sensor.esp32-idf.yaml new file mode 100644 index 0000000000..8998b8896e --- /dev/null +++ b/tests/components/sendspin/test-text_sensor.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-text_sensor.yaml From 42f0b1a7e229564f9ba53cfcf93f2ec2cc89f995 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 08:21:29 -0400 Subject: [PATCH 2/8] add Sendspin sensor component --- CODEOWNERS | 1 + esphome/components/sendspin/sendspin_hub.cpp | 11 +++- esphome/components/sendspin/sendspin_hub.h | 5 ++ .../components/sendspin/sensor/__init__.py | 62 +++++++++++++++++++ .../sendspin/sensor/sendspin_sensor.cpp | 62 +++++++++++++++++++ .../sendspin/sensor/sendspin_sensor.h | 31 ++++++++++ tests/components/sendspin/common-sensor.yaml | 9 +++ .../sendspin/test-sensor.esp32-idf.yaml | 1 + 8 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 esphome/components/sendspin/sensor/__init__.py create mode 100644 esphome/components/sendspin/sensor/sendspin_sensor.cpp create mode 100644 esphome/components/sendspin/sensor/sendspin_sensor.h create mode 100644 tests/components/sendspin/common-sensor.yaml create mode 100644 tests/components/sendspin/test-sensor.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index f4b288b23d..20c19a7dfa 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -443,6 +443,7 @@ esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct esphome/components/sendspin/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt +esphome/components/sendspin/sensor/* @kahrendt esphome/components/sendspin/text_sensor/* @kahrendt esphome/components/sensirion_common/* @martgras esphome/components/sensor/* @esphome/core diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index da298feb86..c2fcd520ba 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -40,7 +40,8 @@ void SendspinHub::setup() { #endif #ifdef USE_SENDSPIN_METADATA - this->client_->add_metadata().set_listener(this); + this->metadata_role_ = &this->client_->add_metadata(); + this->metadata_role_->set_listener(this); #endif #ifdef USE_SENDSPIN_PLAYER @@ -176,6 +177,14 @@ void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObjec void SendspinHub::on_metadata(const sendspin::ServerMetadataStateObject &metadata) { this->metadata_update_callbacks_.call(metadata); } + +// THREAD CONTEXT: Main loop (invoked from Sendspin components) +uint32_t SendspinHub::get_track_progress_ms() { + if (this->is_ready()) { + return this->metadata_role_->get_track_progress_ms(); + } + return 0; +} #endif #ifdef USE_SENDSPIN_PLAYER diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 8d9c58a3ab..79b7018645 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -132,6 +132,9 @@ class SendspinHub final : public Component, template void add_metadata_update_callback(F &&callback) { this->metadata_update_callbacks_.add(std::forward(callback)); } + + /// @brief Returns the interpolated track progress in milliseconds, or 0 if the hub is not yet ready. + uint32_t get_track_progress_ms(); #endif #ifdef USE_SENDSPIN_PLAYER @@ -172,6 +175,8 @@ class SendspinHub final : public Component, #endif #ifdef USE_SENDSPIN_METADATA + sendspin::MetadataRole *metadata_role_{nullptr}; + void on_metadata(const sendspin::ServerMetadataStateObject &metadata) override; // Callback fan-out to child components; they filter as needed diff --git a/esphome/components/sendspin/sensor/__init__.py b/esphome/components/sendspin/sensor/__init__.py new file mode 100644 index 0000000000..cdf1c0da35 --- /dev/null +++ b/esphome/components/sendspin/sensor/__init__.py @@ -0,0 +1,62 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_TYPE, STATE_CLASS_MEASUREMENT, UNIT_MILLISECOND +from esphome.types import ConfigType + +from .. import CONF_SENDSPIN_ID, SendspinHub, request_metadata_support, sendspin_ns + +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["sendspin"] + +SendspinTrackProgressSensor = sendspin_ns.class_( + "SendspinTrackProgressSensor", + sensor.Sensor, + cg.PollingComponent, +) +SendspinTrackDurationSensor = sendspin_ns.class_( + "SendspinTrackDurationSensor", + sensor.Sensor, + cg.Component, +) + + +def _request_roles(config: ConfigType) -> ConfigType: + """Request the necessary Sendspin roles for the sensor.""" + request_metadata_support() + + return config + + +_SENSOR_SCHEMA = sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=UNIT_MILLISECOND, +).extend( + { + cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), + } +) + +CONFIG_SCHEMA = cv.All( + cv.typed_schema( + { + "track_progress": _SENSOR_SCHEMA.extend( + {cv.GenerateID(): cv.declare_id(SendspinTrackProgressSensor)} + ).extend(cv.polling_component_schema("1s")), + "track_duration": _SENSOR_SCHEMA.extend( + {cv.GenerateID(): cv.declare_id(SendspinTrackDurationSensor)} + ).extend(cv.COMPONENT_SCHEMA), + }, + key=CONF_TYPE, + ), + cv.only_on_esp32, + _request_roles, +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + await sensor.register_sensor(var, config) diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.cpp b/esphome/components/sendspin/sensor/sendspin_sensor.cpp new file mode 100644 index 0000000000..5d0ab21340 --- /dev/null +++ b/esphome/components/sendspin/sensor/sendspin_sensor.cpp @@ -0,0 +1,62 @@ +#include "sendspin_sensor.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR) + +#include + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.sensor"; + +// --- SendspinTrackProgressSensor --- + +void SendspinTrackProgressSensor::dump_config() { LOG_SENSOR("", "Sendspin Track Progress", this); } + +// THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop +// (SendspinHub dispatches metadata from client_->loop()). +void SendspinTrackProgressSensor::setup() { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (!metadata.progress.has_value()) { + return; + } + const auto &progress = metadata.progress.value(); + if (progress.playback_speed == 0) { + // Paused: freeze progress at the reported position and stop polling to save cycles. + this->stop_poller(); + this->publish_state(progress.track_progress); + } else { + this->start_poller(); + } + }); +} + +// THREAD CONTEXT: Main loop. +// Sendspin only pushes progress on state changes (play/pause/seek/speed change), not continuously during +// playback. The hub helper interpolates the current position from the last server update and the playback +// speed, giving us a fresh value on every poll. +void SendspinTrackProgressSensor::update() { this->publish_state(this->parent_->get_track_progress_ms()); } + +// --- SendspinTrackDurationSensor --- + +void SendspinTrackDurationSensor::dump_config() { LOG_SENSOR("", "Sendspin Track Duration", this); } + +// THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop +// (SendspinHub dispatches metadata from client_->loop()). +void SendspinTrackDurationSensor::setup() { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.progress.has_value()) { + this->publish_if_changed_(metadata.progress.value().track_duration); + } + }); +} + +// Dedup to avoid frontend churn; Sensor::publish_state always notifies without checking for changes. +void SendspinTrackDurationSensor::publish_if_changed_(float value) { + if (this->get_raw_state() != value) { + this->publish_state(value); + } +} + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.h b/esphome/components/sendspin/sensor/sendspin_sensor.h new file mode 100644 index 0000000000..a13ce5e039 --- /dev/null +++ b/esphome/components/sendspin/sensor/sendspin_sensor.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR) + +#include "esphome/components/sendspin/sendspin_hub.h" +#include "esphome/components/sensor/sensor.h" + +namespace esphome::sendspin_ { + +class SendspinTrackProgressSensor : public sensor::Sensor, public PollingComponent, public Parented { + public: + float get_setup_priority() const override { return sendspin_priority::CHILD; } + void dump_config() override; + void setup() override; + void update() override; +}; + +class SendspinTrackDurationSensor : public sensor::Sensor, public Component, public Parented { + public: + float get_setup_priority() const override { return sendspin_priority::CHILD; } + void dump_config() override; + void setup() override; + + protected: + void publish_if_changed_(float value); +}; + +} // namespace esphome::sendspin_ +#endif diff --git a/tests/components/sendspin/common-sensor.yaml b/tests/components/sendspin/common-sensor.yaml new file mode 100644 index 0000000000..dc335b6842 --- /dev/null +++ b/tests/components/sendspin/common-sensor.yaml @@ -0,0 +1,9 @@ +<<: !include common.yaml + +sensor: + - platform: sendspin + name: "Sendspin Track Progress" + type: track_progress + - platform: sendspin + name: "Sendspin Track Duration" + type: track_duration diff --git a/tests/components/sendspin/test-sensor.esp32-idf.yaml b/tests/components/sendspin/test-sensor.esp32-idf.yaml new file mode 100644 index 0000000000..f9127d47bc --- /dev/null +++ b/tests/components/sendspin/test-sensor.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-sensor.yaml From 52eeb52d0e5e6af32183f89dd744445c5f43561a Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 08:43:06 -0400 Subject: [PATCH 3/8] mark get_track_progress_ms as const --- esphome/components/sendspin/sendspin_hub.cpp | 2 +- esphome/components/sendspin/sendspin_hub.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index c2fcd520ba..d27c5672eb 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -179,7 +179,7 @@ void SendspinHub::on_metadata(const sendspin::ServerMetadataStateObject &metadat } // THREAD CONTEXT: Main loop (invoked from Sendspin components) -uint32_t SendspinHub::get_track_progress_ms() { +uint32_t SendspinHub::get_track_progress_ms() const { if (this->is_ready()) { return this->metadata_role_->get_track_progress_ms(); } diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 79b7018645..42c4425d62 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -134,7 +134,7 @@ class SendspinHub final : public Component, } /// @brief Returns the interpolated track progress in milliseconds, or 0 if the hub is not yet ready. - uint32_t get_track_progress_ms(); + uint32_t get_track_progress_ms() const; #endif #ifdef USE_SENDSPIN_PLAYER From 8f3a2691fa4a6679cdd78872f498c8ef9fb36700 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 08:43:42 -0400 Subject: [PATCH 4/8] Add a SendspinPolloingChild class to enforce uniform setup priority --- esphome/components/sendspin/sendspin_hub.h | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 42c4425d62..12fbf156ea 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -216,6 +216,16 @@ class SendspinChild : public Component, public Parented { float get_setup_priority() const override { return sendspin_priority::CHILD; } }; +/// @brief Base class for sendspin subcomponents that need polling behavior. +/// +/// Same purpose as SendspinChild but inherits from PollingComponent for subcomponents +/// that poll on a fixed interval. Subcomponents should inherit from this instead of +/// listing PollingComponent/Parented individually and must not override get_setup_priority(). +class SendspinPollingChild : public PollingComponent, public Parented { + public: + float get_setup_priority() const override { return sendspin_priority::CHILD; } +}; + } // namespace esphome::sendspin_ #endif // USE_ESP32 From 6b278e3eb4532390c9433ca6644a88a8ddc5e006 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 08:44:24 -0400 Subject: [PATCH 5/8] make both sensors inherit from the hub's defined child classes to enforce setup priority --- esphome/components/sendspin/sensor/sendspin_sensor.h | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.h b/esphome/components/sendspin/sensor/sendspin_sensor.h index a13ce5e039..0a9c60dee3 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.h +++ b/esphome/components/sendspin/sensor/sendspin_sensor.h @@ -9,17 +9,15 @@ namespace esphome::sendspin_ { -class SendspinTrackProgressSensor : public sensor::Sensor, public PollingComponent, public Parented { +class SendspinTrackProgressSensor : public sensor::Sensor, public SendspinPollingChild { public: - float get_setup_priority() const override { return sendspin_priority::CHILD; } void dump_config() override; void setup() override; void update() override; }; -class SendspinTrackDurationSensor : public sensor::Sensor, public Component, public Parented { +class SendspinTrackDurationSensor : public sensor::Sensor, public SendspinChild { public: - float get_setup_priority() const override { return sendspin_priority::CHILD; } void dump_config() override; void setup() override; From f395e29c59d966d47dfb4de0dcc39c4880a3de2f Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 08:44:41 -0400 Subject: [PATCH 6/8] publish immediately on resume --- esphome/components/sendspin/sensor/sendspin_sensor.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.cpp b/esphome/components/sendspin/sensor/sendspin_sensor.cpp index 5d0ab21340..4f201f95ef 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.cpp +++ b/esphome/components/sendspin/sensor/sendspin_sensor.cpp @@ -25,6 +25,9 @@ void SendspinTrackProgressSensor::setup() { this->stop_poller(); this->publish_state(progress.track_progress); } else { + // Resumed: publish the fresh interpolated position immediately so the frontend doesn't show a stale + // paused value until the next poll tick. + this->publish_state(this->parent_->get_track_progress_ms()); this->start_poller(); } }); From a179a0de22cf03e309bbe08000ef5d23931b9bee Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 08:46:11 -0400 Subject: [PATCH 7/8] log the update interval for the track progress sensor --- esphome/components/sendspin/sensor/sendspin_sensor.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.cpp b/esphome/components/sendspin/sensor/sendspin_sensor.cpp index 4f201f95ef..8710d6dbe6 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.cpp +++ b/esphome/components/sendspin/sensor/sendspin_sensor.cpp @@ -10,7 +10,10 @@ static const char *const TAG = "sendspin.sensor"; // --- SendspinTrackProgressSensor --- -void SendspinTrackProgressSensor::dump_config() { LOG_SENSOR("", "Sendspin Track Progress", this); } +void SendspinTrackProgressSensor::dump_config() { + LOG_SENSOR("", "Sendspin Track Progress", this); + LOG_UPDATE_INTERVAL(this); +} // THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop // (SendspinHub dispatches metadata from client_->loop()). From 66019feb8f7e9d4e0e82d4d9dc08b29452a62bd1 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 09:27:37 -0400 Subject: [PATCH 8/8] add year and track number sensors (will remove them from text_sensor in a future PR) --- .../components/sendspin/sensor/__init__.py | 72 ++++++++++++++----- .../sendspin/sensor/sendspin_sensor.cpp | 51 ++++++++++--- .../sendspin/sensor/sendspin_sensor.h | 12 +++- tests/components/sendspin/common-sensor.yaml | 6 ++ 4 files changed, 113 insertions(+), 28 deletions(-) diff --git a/esphome/components/sendspin/sensor/__init__.py b/esphome/components/sendspin/sensor/__init__.py index cdf1c0da35..dc9b86c2a3 100644 --- a/esphome/components/sendspin/sensor/__init__.py +++ b/esphome/components/sendspin/sensor/__init__.py @@ -1,7 +1,13 @@ import esphome.codegen as cg from esphome.components import sensor import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_TYPE, STATE_CLASS_MEASUREMENT, UNIT_MILLISECOND +from esphome.const import ( + CONF_ID, + CONF_TYPE, + CONF_YEAR, + STATE_CLASS_MEASUREMENT, + UNIT_MILLISECOND, +) from esphome.types import ConfigType from .. import CONF_SENDSPIN_ID, SendspinHub, request_metadata_support, sendspin_ns @@ -9,17 +15,30 @@ from .. import CONF_SENDSPIN_ID, SendspinHub, request_metadata_support, sendspin CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["sendspin"] +CONF_TRACK = "track" +CONF_TRACK_PROGRESS = "track_progress" +CONF_TRACK_DURATION = "track_duration" + SendspinTrackProgressSensor = sendspin_ns.class_( "SendspinTrackProgressSensor", sensor.Sensor, cg.PollingComponent, ) -SendspinTrackDurationSensor = sendspin_ns.class_( - "SendspinTrackDurationSensor", +SendspinMetadataSensor = sendspin_ns.class_( + "SendspinMetadataSensor", sensor.Sensor, cg.Component, ) +SendspinNumericMetadataTypes = sendspin_ns.enum( + "SendspinNumericMetadataTypes", is_class=True +) +_METADATA_TYPE_ENUM = { + CONF_TRACK_DURATION: SendspinNumericMetadataTypes.TRACK_DURATION, + CONF_YEAR: SendspinNumericMetadataTypes.YEAR, + CONF_TRACK: SendspinNumericMetadataTypes.TRACK, +} + def _request_roles(config: ConfigType) -> ConfigType: """Request the necessary Sendspin roles for the sensor.""" @@ -28,25 +47,39 @@ def _request_roles(config: ConfigType) -> ConfigType: return config -_SENSOR_SCHEMA = sensor.sensor_schema( - accuracy_decimals=0, - state_class=STATE_CLASS_MEASUREMENT, - unit_of_measurement=UNIT_MILLISECOND, -).extend( - { - cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), - } -) +_HUB_ID_SCHEMA = cv.Schema({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) + + +def _metadata_schema(**sensor_kwargs): + """Schema for event-driven numeric metadata sensors (duration/year/track).""" + return ( + sensor.sensor_schema( + SendspinMetadataSensor, + accuracy_decimals=0, + **sensor_kwargs, + ) + .extend(_HUB_ID_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) + ) + CONFIG_SCHEMA = cv.All( cv.typed_schema( { - "track_progress": _SENSOR_SCHEMA.extend( - {cv.GenerateID(): cv.declare_id(SendspinTrackProgressSensor)} - ).extend(cv.polling_component_schema("1s")), - "track_duration": _SENSOR_SCHEMA.extend( - {cv.GenerateID(): cv.declare_id(SendspinTrackDurationSensor)} - ).extend(cv.COMPONENT_SCHEMA), + CONF_TRACK_PROGRESS: sensor.sensor_schema( + SendspinTrackProgressSensor, + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=UNIT_MILLISECOND, + ) + .extend(_HUB_ID_SCHEMA) + .extend(cv.polling_component_schema("1s")), + CONF_TRACK_DURATION: _metadata_schema( + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=UNIT_MILLISECOND, + ), + CONF_YEAR: _metadata_schema(), + CONF_TRACK: _metadata_schema(), }, key=CONF_TYPE, ), @@ -60,3 +93,6 @@ async def to_code(config: ConfigType) -> None: await cg.register_component(var, config) await cg.register_parented(var, config[CONF_SENDSPIN_ID]) await sensor.register_sensor(var, config) + + if (metadata_type := _METADATA_TYPE_ENUM.get(config[CONF_TYPE])) is not None: + cg.add(var.set_metadata_type(metadata_type)) diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.cpp b/esphome/components/sendspin/sensor/sendspin_sensor.cpp index 8710d6dbe6..4efbd72a38 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.cpp +++ b/esphome/components/sendspin/sensor/sendspin_sensor.cpp @@ -11,7 +11,7 @@ static const char *const TAG = "sendspin.sensor"; // --- SendspinTrackProgressSensor --- void SendspinTrackProgressSensor::dump_config() { - LOG_SENSOR("", "Sendspin Track Progress", this); + LOG_SENSOR("", "Track Progress", this); LOG_UPDATE_INTERVAL(this); } @@ -42,22 +42,55 @@ void SendspinTrackProgressSensor::setup() { // speed, giving us a fresh value on every poll. void SendspinTrackProgressSensor::update() { this->publish_state(this->parent_->get_track_progress_ms()); } -// --- SendspinTrackDurationSensor --- +// --- SendspinMetadataSensor --- -void SendspinTrackDurationSensor::dump_config() { LOG_SENSOR("", "Sendspin Track Duration", this); } +void SendspinMetadataSensor::dump_config() { + switch (this->metadata_type_) { + case SendspinNumericMetadataTypes::TRACK_DURATION: + LOG_SENSOR("", "Track Duration", this); + break; + case SendspinNumericMetadataTypes::YEAR: + LOG_SENSOR("", "Year", this); + break; + case SendspinNumericMetadataTypes::TRACK: + LOG_SENSOR("", "Track", this); + break; + } +} // THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop // (SendspinHub dispatches metadata from client_->loop()). -void SendspinTrackDurationSensor::setup() { - this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (metadata.progress.has_value()) { - this->publish_if_changed_(metadata.progress.value().track_duration); +void SendspinMetadataSensor::setup() { + switch (this->metadata_type_) { + case SendspinNumericMetadataTypes::TRACK_DURATION: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.progress.has_value()) { + this->publish_if_changed_(metadata.progress.value().track_duration); + } + }); + break; } - }); + case SendspinNumericMetadataTypes::YEAR: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.year.has_value() && metadata.year.value() <= 9999) { + this->publish_if_changed_(metadata.year.value()); + } + }); + break; + } + case SendspinNumericMetadataTypes::TRACK: { + this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { + if (metadata.track.has_value() && metadata.track.value() <= 9999) { + this->publish_if_changed_(metadata.track.value()); + } + }); + break; + } + } } // Dedup to avoid frontend churn; Sensor::publish_state always notifies without checking for changes. -void SendspinTrackDurationSensor::publish_if_changed_(float value) { +void SendspinMetadataSensor::publish_if_changed_(float value) { if (this->get_raw_state() != value) { this->publish_state(value); } diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.h b/esphome/components/sendspin/sensor/sendspin_sensor.h index 0a9c60dee3..969c127283 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.h +++ b/esphome/components/sendspin/sensor/sendspin_sensor.h @@ -16,13 +16,23 @@ class SendspinTrackProgressSensor : public sensor::Sensor, public SendspinPollin void update() override; }; -class SendspinTrackDurationSensor : public sensor::Sensor, public SendspinChild { +enum class SendspinNumericMetadataTypes { + TRACK_DURATION, + YEAR, + TRACK, +}; + +class SendspinMetadataSensor : public sensor::Sensor, public SendspinChild { public: void dump_config() override; void setup() override; + void set_metadata_type(SendspinNumericMetadataTypes metadata_type) { this->metadata_type_ = metadata_type; } + protected: void publish_if_changed_(float value); + + SendspinNumericMetadataTypes metadata_type_; }; } // namespace esphome::sendspin_ diff --git a/tests/components/sendspin/common-sensor.yaml b/tests/components/sendspin/common-sensor.yaml index dc335b6842..6d9745cff9 100644 --- a/tests/components/sendspin/common-sensor.yaml +++ b/tests/components/sendspin/common-sensor.yaml @@ -7,3 +7,9 @@ sensor: - platform: sendspin name: "Sendspin Track Duration" type: track_duration + - platform: sendspin + name: "Sendspin Year" + type: year + - platform: sendspin + name: "Sendspin Track" + type: track