Merge remote-tracking branch 'upstream/sendspin-sensors' into integration

This commit is contained in:
J. Nick Koston
2026-04-24 08:52:29 -05:00
13 changed files with 507 additions and 0 deletions
+2
View File
@@ -443,6 +443,8 @@ 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
esphome/components/serial_proxy/* @kbx81
@@ -39,6 +39,11 @@ void SendspinHub::setup() {
this->controller_role_->set_listener(this);
#endif
#ifdef USE_SENDSPIN_METADATA
this->metadata_role_ = &this->client_->add_metadata();
this->metadata_role_->set_listener(this);
#endif
#ifdef USE_SENDSPIN_PLAYER
this->client_->add_player(this->player_config_).set_listener(this->player_listener_);
#endif
@@ -167,6 +172,21 @@ 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);
}
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
uint32_t SendspinHub::get_track_progress_ms() const {
if (this->is_ready()) {
return this->metadata_role_->get_track_progress_ms();
}
return 0;
}
#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() {
@@ -16,6 +16,9 @@
#ifdef USE_SENDSPIN_CONTROLLER
#include <sendspin/controller_role.h>
#endif
#ifdef USE_SENDSPIN_METADATA
#include <sendspin/metadata_role.h>
#endif
#ifdef USE_SENDSPIN_PLAYER
#include <sendspin/player_role.h>
#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,15 @@ class SendspinHub final : public Component,
}
#endif
#ifdef USE_SENDSPIN_METADATA
template<typename F> void add_metadata_update_callback(F &&callback) {
this->metadata_update_callbacks_.add(std::forward<F>(callback));
}
/// @brief Returns the interpolated track progress in milliseconds, or 0 if the hub is not yet ready.
uint32_t get_track_progress_ms() const;
#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 +174,15 @@ class SendspinHub final : public Component,
CallbackManager<void(const sendspin::ServerStateControllerObject &)> controller_state_callbacks_{};
#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
CallbackManager<void(const sendspin::ServerMetadataStateObject &)> metadata_update_callbacks_{};
#endif
#ifdef USE_SENDSPIN_PLAYER
sendspin::PlayerRoleListener *player_listener_{nullptr};
sendspin::PlayerRoleConfig player_config_{};
@@ -192,6 +216,16 @@ class SendspinChild : public Component, public Parented<SendspinHub> {
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<SendspinHub> {
public:
float get_setup_priority() const override { return sendspin_priority::CHILD; }
};
} // namespace esphome::sendspin_
#endif // USE_ESP32
@@ -0,0 +1,98 @@
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
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
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,
)
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."""
request_metadata_support()
return config
_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(
{
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,
),
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)
if (metadata_type := _METADATA_TYPE_ENUM.get(config[CONF_TYPE])) is not None:
cg.add(var.set_metadata_type(metadata_type))
@@ -0,0 +1,101 @@
#include "sendspin_sensor.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR)
#include <sendspin/metadata_role.h>
namespace esphome::sendspin_ {
static const char *const TAG = "sendspin.sensor";
// --- SendspinTrackProgressSensor ---
void SendspinTrackProgressSensor::dump_config() {
LOG_SENSOR("", "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()).
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 {
// 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();
}
});
}
// 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()); }
// --- SendspinMetadataSensor ---
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 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 SendspinMetadataSensor::publish_if_changed_(float value) {
if (this->get_raw_state() != value) {
this->publish_state(value);
}
}
} // namespace esphome::sendspin_
#endif
@@ -0,0 +1,39 @@
#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 SendspinPollingChild {
public:
void dump_config() override;
void setup() override;
void update() override;
};
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_
#endif
@@ -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]))
@@ -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 <sendspin/metadata_role.h>
#include <string>
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
@@ -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
@@ -0,0 +1,15 @@
<<: !include common.yaml
sensor:
- platform: sendspin
name: "Sendspin Track Progress"
type: track_progress
- platform: sendspin
name: "Sendspin Track Duration"
type: track_duration
- platform: sendspin
name: "Sendspin Year"
type: year
- platform: sendspin
name: "Sendspin Track"
type: track
@@ -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
@@ -0,0 +1 @@
<<: !include common-sensor.yaml
@@ -0,0 +1 @@
<<: !include common-text_sensor.yaml