[sendspin] Add a switch platform to enable and disable the client (#19361)

This commit is contained in:
Kevin Ahrendt
2026-09-17 11:35:07 +12:00
committed by GitHub
parent b001514cf3
commit 8bcb5004da
11 changed files with 179 additions and 28 deletions
+1
View File
@@ -478,6 +478,7 @@ esphome/components/sendspin/image/* @kahrendt
esphome/components/sendspin/media_player/* @kahrendt
esphome/components/sendspin/media_source/* @kahrendt
esphome/components/sendspin/sensor/* @kahrendt
esphome/components/sendspin/switch/* @kahrendt
esphome/components/sendspin/text_sensor/* @kahrendt
esphome/components/sensirion_common/* @martgras
esphome/components/sensor/* @esphome/core
@@ -97,6 +97,10 @@ void SendspinMediaPlayer::control(const media_player::MediaPlayerCall &call) {
// Ignore any commands sent before the media player is setup
return;
}
if (!this->parent_->is_client_running()) {
ESP_LOGW(TAG, "Cannot control media player: Sendspin is disabled");
return;
}
auto volume = call.get_volume();
if (volume.has_value()) {
@@ -45,6 +45,8 @@ bool SendspinMediaSource::can_handle(const std::string &uri) const { return uri.
// THREAD CONTEXT: Main loop (media_source.h documents play_uri as main-loop only)
bool SendspinMediaSource::play_uri(const std::string &uri) {
// The queued request has been delivered, whatever the outcome, so the next stream start may request again
this->pending_start_ = false;
if (!this->is_ready() || this->is_failed() || !this->has_listener()) {
return false;
}
@@ -54,6 +56,11 @@ bool SendspinMediaSource::play_uri(const std::string &uri) {
return false;
}
if (!this->parent_->is_client_running()) {
ESP_LOGE(TAG, "Cannot play '%s': Sendspin is disabled", uri.c_str());
return false;
}
if (!uri.starts_with(URI_PREFIX)) {
ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str());
return false;
@@ -74,7 +81,6 @@ bool SendspinMediaSource::play_uri(const std::string &uri) {
}
// Tell the orchestrator we're now playing so it routes audio output from us
this->pending_start_ = false;
this->set_state_(media_source::MediaSourceState::PLAYING);
return true;
@@ -82,6 +88,15 @@ bool SendspinMediaSource::play_uri(const std::string &uri) {
// THREAD CONTEXT: Main loop (media_source.h documents handle_command as main-loop only)
void SendspinMediaSource::handle_command(media_source::MediaSourceCommand command) {
if (!this->parent_->is_client_running()) {
if (command == media_source::MediaSourceCommand::STOP) {
// Nothing is playing, so the orchestrator gets its pipeline back straight away
this->on_stream_end();
} else {
ESP_LOGW(TAG, "Cannot handle command: Sendspin is disabled");
}
return;
}
switch (command) {
case media_source::MediaSourceCommand::STOP: {
if (!this->pending_start_) {
+45 -22
View File
@@ -21,10 +21,6 @@ namespace esphome::sendspin_ {
static const char *const TAG = "sendspin.hub";
#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE
static constexpr uint32_t MDNS_ENABLE_RETRY_MS = 1000;
#endif
#ifdef USE_SENDSPIN_ARTWORK
// Indexed by the library enums, which start at zero and are contiguous.
static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"};
@@ -66,26 +62,24 @@ void SendspinHub::setup() {
this->client_->add_player(this->player_config_).set_listener(this->player_listener_);
#endif
if (!this->client_->start()) {
ESP_LOGE(TAG, "Failed to start Sendspin client");
this->mark_failed();
return;
}
#ifndef USE_SENDSPIN_SWITCH
this->enabled_ = true;
#endif
}
void SendspinHub::loop() {
if (this->enabled_.has_value() && this->enabled_.value() != this->client_->is_started() &&
!this->status_has_error()) {
if (!this->enabled_.value()) {
this->client_->stop();
} else if (!this->client_->start()) {
this->status_set_error(LOG_STR("Failed to start Sendspin client"));
}
}
this->client_->loop();
#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE
// mdns sets up after this hub, so the service is enabled here once mdns is ready. A failed enable retries,
// rate limited so a persistent failure does not flood the log or block on the mdns task every loop pass.
if (!this->mdns_advertised_ && this->mdns_->is_ready()) {
const uint32_t now = App.get_loop_component_start_time();
if (this->mdns_enable_attempt_ms_ == 0 || now - this->mdns_enable_attempt_ms_ >= MDNS_ENABLE_RETRY_MS) {
this->mdns_enable_attempt_ms_ = now;
this->mdns_advertised_ = this->mdns_->set_service_enabled("_sendspin", "_tcp", true);
}
}
this->update_mdns_service_();
#endif
}
@@ -114,25 +108,54 @@ void SendspinHub::dump_config() {
#endif
}
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
void SendspinHub::set_enabled(bool enabled) {
if (this->status_has_error()) {
ESP_LOGE(TAG, "Cannot %s: Sendspin failed to start, reboot to retry",
enabled ? LOG_STR_LITERAL("enable") : LOG_STR_LITERAL("disable"));
return;
}
this->enabled_ = enabled;
}
#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE
// THREAD CONTEXT: Main loop
void SendspinHub::update_mdns_service_() {
// Synced from loop() because mdns sets up after this hub and only builds its service list then.
if (!this->mdns_->is_ready()) {
return;
}
bool advertise = this->client_->is_started();
if (advertise == this->mdns_advertised_) {
return;
}
// One attempt per change
this->mdns_advertised_ = advertise;
if (!this->mdns_->set_service_enabled("_sendspin", "_tcp", advertise)) {
ESP_LOGE(TAG, "Failed to %s mDNS service", advertise ? LOG_STR_LITERAL("enable") : LOG_STR_LITERAL("disable"));
}
}
#endif
// --- Delegating methods ---
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
void SendspinHub::connect_to_server(const std::string &url) {
if (this->is_ready()) {
if (this->is_client_running()) {
this->client_->connect_to(url);
}
}
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
void SendspinHub::disconnect_from_server(sendspin::SendspinGoodbyeReason reason) {
if (this->is_ready()) {
if (this->is_client_running()) {
this->client_->disconnect(reason);
}
}
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
void SendspinHub::update_state(sendspin::SendspinClientState state) {
if (this->is_ready()) {
if (this->is_client_running()) {
this->client_->update_state(state);
}
}
@@ -251,7 +274,7 @@ void SendspinHub::artwork_frame_done(uint8_t slot) {
// THREAD CONTEXT: Main loop (invoked from ESPHome actions / other components)
void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional<uint8_t> volume,
std::optional<bool> mute) {
if (this->is_ready()) {
if (this->is_client_running()) {
sendspin::ClientCommandControllerObject obj = {
.command = command,
.volume = volume,
+23 -5
View File
@@ -97,7 +97,7 @@ class SendspinHub final : public Component,
/// @brief Connects the underlying client to the given Sendspin server.
///
/// No-op if the hub's client is not ready (e.g. setup() has not completed).
/// No-op if the hub's client is not running (see is_client_running()).
/// Must be called from the main loop thread.
/// @param url WebSocket URL of the Sendspin server, starting with `ws://` (e.g. `ws://host:port/path`).
void connect_to_server(const std::string &url);
@@ -105,7 +105,7 @@ class SendspinHub final : public Component,
/// @brief Disconnects the underlying client from the current server.
///
/// Sends a `client/goodbye` message with the given reason before closing the connection.
/// No-op if the hub's client is not ready. Must be called from the main loop thread.
/// No-op if the hub's client is not running. Must be called from the main loop thread.
/// @param reason Reason reported to the server:
/// - `ANOTHER_SERVER`: client is switching to another server.
/// - `SHUTDOWN`: client is shutting down.
@@ -115,7 +115,7 @@ class SendspinHub final : public Component,
/// @brief Updates the client's reported playback state on the server.
///
/// No-op if the hub's client is not ready. Must be called from the main loop thread.
/// No-op if the hub's client is not running. Must be called from the main loop thread.
/// @param state New client state:
/// - `SYNCHRONIZED`: client is synchronized and playing from the server.
/// - `ERROR`: client encountered a playback error.
@@ -130,6 +130,17 @@ class SendspinHub final : public Component,
void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; }
/// @brief Requests the Sendspin client, including the server, the roles and the mDNS advertisement, to start or
/// stop.
///
/// Applied from the hub's loop(). Stopping blocks until the client is fully stopped; the roles' clear callbacks
/// fire from inside that call. With a sendspin switch configured the client stays stopped until the switch has
/// called this once. Must be called from the main loop thread.
void set_enabled(bool enabled);
/// @brief Returns whether the Sendspin client is running.
bool is_client_running() const { return this->client_ != nullptr && this->client_->is_started(); }
/// @brief Sets the device information reported to the server in the `client/hello` message.
///
/// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the
@@ -212,6 +223,11 @@ class SendspinHub final : public Component,
/// Uses the ethernet MAC if ethernet is configured, otherwise the base MAC (used by wifi).
static const char *get_client_id_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf);
#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE
/// @brief Keeps the `_sendspin` mDNS service advertised while the client is running.
void update_mdns_service_();
#endif
// --- SendspinClientListener overrides ---
void on_group_update(const sendspin::GroupUpdateObject &group) override;
@@ -290,6 +306,9 @@ class SendspinHub final : public Component,
bool task_stack_in_psram_{false};
// Requested client state, applied from loop(). Empty until the switch restores its state.
std::optional<bool> enabled_;
// Device information sent in the `client/hello` message. Defaults apply when neither the
// sendspin configuration nor the project information supplies a value.
const char *manufacturer_{"ESPHome"};
@@ -298,8 +317,7 @@ class SendspinHub final : public Component,
#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE
mdns::MDNSComponent *mdns_{nullptr};
uint32_t mdns_enable_attempt_ms_{0};
bool mdns_advertised_{false};
bool mdns_advertised_{false}; // Last state requested from mdns
#endif
};
@@ -0,0 +1,31 @@
import esphome.codegen as cg
from esphome.components import switch
import esphome.config_validation as cv
from esphome.const import ENTITY_CATEGORY_CONFIG
from esphome.types import ConfigType
from .. import CONF_SENDSPIN_ID, SendspinHub, sendspin_ns
CODEOWNERS = ["@kahrendt"]
DEPENDENCIES = ["sendspin"]
SendspinSwitch = sendspin_ns.class_("SendspinSwitch", switch.Switch, cg.Component)
CONFIG_SCHEMA = cv.All(
switch.switch_schema(
SendspinSwitch,
block_inverted=True,
default_restore_mode="RESTORE_DEFAULT_ON",
entity_category=ENTITY_CATEGORY_CONFIG,
)
.extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)})
.extend(cv.COMPONENT_SCHEMA),
cv.only_on_esp32,
)
async def to_code(config: ConfigType) -> None:
var = await switch.new_switch(config)
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_SENDSPIN_ID])
cg.add_define("USE_SENDSPIN_SWITCH", True)
@@ -0,0 +1,26 @@
#include "sendspin_switch.h"
#ifdef USE_ESP32
#include "esphome/core/log.h"
namespace esphome::sendspin_ {
static const char *const TAG = "sendspin.switch";
void SendspinSwitch::setup() {
// The hub waits for this request, so a restore mode without a state still has to answer.
this->control(this->get_initial_state_with_restore_mode().value_or(true));
}
void SendspinSwitch::dump_config() { LOG_SWITCH("", "Sendspin Switch", this); }
// THREAD CONTEXT: Main loop
void SendspinSwitch::write_state(bool state) {
this->parent_->set_enabled(state);
this->publish_state(state);
}
} // namespace esphome::sendspin_
#endif // USE_ESP32
@@ -0,0 +1,24 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#include "esphome/components/sendspin/sendspin_hub.h"
#include "esphome/components/switch/switch.h"
namespace esphome::sendspin_ {
/// @brief Switch that starts and stops the Sendspin client through the hub (see SendspinHub::set_enabled()).
class SendspinSwitch final : public switch_::Switch, public SendspinChild {
public:
void setup() override;
void dump_config() override;
protected:
void write_state(bool state) override;
};
} // namespace esphome::sendspin_
#endif // USE_ESP32
+1
View File
@@ -400,6 +400,7 @@
#define USE_SENDSPIN_CONTROLLER
#define USE_SENDSPIN_METADATA
#define USE_SENDSPIN_PLAYER
#define USE_SENDSPIN_SWITCH
#define USE_SENDSPIN_VISUALIZER
#define USE_SENDSPIN_PORT 8928 // NOLINT
#define USE_SOCKET_IMPL_BSD_SOCKETS
@@ -0,0 +1,6 @@
packages:
sendspin: !include common.yaml
switch:
- platform: sendspin
name: "Sendspin Enabled"
@@ -0,0 +1,2 @@
packages:
sendspin: !include common-switch.yaml