mirror of
https://github.com/esphome/esphome.git
synced 2026-10-11 13:54:12 +00:00
542 lines
20 KiB
C++
542 lines
20 KiB
C++
#include "sendspin_hub.h"
|
|
|
|
#ifdef USE_ESP_IDF
|
|
|
|
#include "esphome/components/network/util.h"
|
|
#ifdef USE_ETHERNET
|
|
#include "esphome/components/ethernet/ethernet_component.h"
|
|
#endif
|
|
#ifdef USE_WIFI
|
|
#include "esphome/components/wifi/wifi_component.h"
|
|
#endif
|
|
|
|
#include "esphome/core/application.h"
|
|
#include "esphome/core/helpers.h"
|
|
#include "esphome/core/log.h"
|
|
#include "esphome/core/version.h"
|
|
|
|
#include <esp_log.h>
|
|
|
|
#include <cstring>
|
|
#include <memory>
|
|
|
|
namespace esphome::sendspin_ {
|
|
|
|
ESPHOME_LOG_TAG(TAG, "sendspin.hub");
|
|
|
|
namespace {
|
|
|
|
// The reason passed to on_pairing_failed automations, in the protocol's own spelling.
|
|
StringRef pair_abort_reason_to_string(sendspin::SendspinPairAbortReason reason) {
|
|
using sendspin::SendspinPairAbortReason;
|
|
switch (reason) {
|
|
case SendspinPairAbortReason::ATTEMPT_TIMEOUT:
|
|
return StringRef::from_lit("attempt_timeout");
|
|
case SendspinPairAbortReason::CONCURRENT_ATTEMPT:
|
|
return StringRef::from_lit("concurrent_attempt");
|
|
case SendspinPairAbortReason::METHOD_NOT_SUPPORTED:
|
|
return StringRef::from_lit("method_not_supported");
|
|
case SendspinPairAbortReason::PAIRING_CODE_MISMATCH:
|
|
return StringRef::from_lit("pairing_code_mismatch");
|
|
case SendspinPairAbortReason::USER_CANCELLED:
|
|
return StringRef::from_lit("user_cancelled");
|
|
case SendspinPairAbortReason::UNKNOWN:
|
|
break;
|
|
}
|
|
return StringRef::from_lit("unknown");
|
|
}
|
|
|
|
// Zeroes through a volatile pointer so dead-store elimination cannot drop it.
|
|
void secure_wipe(void *data, size_t len) {
|
|
volatile auto *p = static_cast<volatile uint8_t *>(data);
|
|
for (size_t i = 0; i < len; ++i) {
|
|
p[i] = 0;
|
|
}
|
|
}
|
|
|
|
// Call once per key, from setup().
|
|
ESPPreferenceObject make_blob_pref(const std::string &name, size_t size) {
|
|
return global_preferences->make_preference(size, fnv1a_hash("sendspin_" + name));
|
|
}
|
|
|
|
// Loads straight into the returned buffer, which the library wipes after use. A failed load can
|
|
// leave part of a stored private key or PSK behind, so that path wipes it here. This only covers
|
|
// this buffer: the preference backend keeps its own copy of a pending write until it syncs.
|
|
std::optional<std::vector<uint8_t>> read_blob(ESPPreferenceObject &pref, size_t size) {
|
|
std::vector<uint8_t> out(size);
|
|
if (!pref.load(out.data(), out.size())) {
|
|
secure_wipe(out.data(), out.size());
|
|
return std::nullopt;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// The library always writes a key's fixed size; any other length is misuse and is not stored.
|
|
bool write_blob(ESPPreferenceObject &pref, const char *key, size_t size, const uint8_t *data, size_t len) {
|
|
if (len != size) {
|
|
ESP_LOGW(TAG, "\"%s\" blob of %zu bytes does not match its size of %zu; rejecting write", key, len, size);
|
|
return false;
|
|
}
|
|
if (!pref.save(data, len)) {
|
|
ESP_LOGW(TAG, "Failed to persist \"%s\" blob (%zu bytes)", key, len);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// "rec_<n>" to n; nullopt for any other key or a slot with no storage here.
|
|
std::optional<size_t> parse_record_slot(const std::string &key) {
|
|
const char *const prefix = sendspin::persistence_keys::RECORD_SLOT_PREFIX;
|
|
const size_t prefix_len = std::strlen(prefix);
|
|
if (key.size() <= prefix_len || key.compare(0, prefix_len, prefix) != 0) {
|
|
return std::nullopt;
|
|
}
|
|
size_t slot = 0;
|
|
for (size_t i = prefix_len; i < key.size(); i++) {
|
|
const char c = key[i];
|
|
if (c < '0' || c > '9') {
|
|
return std::nullopt;
|
|
}
|
|
slot = slot * 10 + static_cast<size_t>(c - '0');
|
|
if (slot >= SENDSPIN_RECORD_SLOTS) {
|
|
return std::nullopt;
|
|
}
|
|
}
|
|
return slot;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
#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"};
|
|
static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG"};
|
|
#endif
|
|
|
|
void SendspinHub::setup() {
|
|
auto config = this->build_client_config_();
|
|
this->client_ = std::make_unique<sendspin::SendspinClient>(std::move(config));
|
|
|
|
// Set up persistence (preferences must be initialized before providers are added to the client). These key names
|
|
// are frozen; see PREFERENCE KEYS in sendspin_hub.h.
|
|
namespace keys = sendspin::persistence_keys;
|
|
this->keypair_pref_ = make_blob_pref("keypair", keys::KEYPAIR_SIZE);
|
|
this->pairing_psk_pref_ = make_blob_pref("pair_psk", keys::PAIRING_PSK_SIZE);
|
|
this->last_played_pref_ = make_blob_pref("last_played", keys::LAST_PLAYED_SIZE);
|
|
// Released firmware's name, so a calibrated delay survives.
|
|
this->output_delay_pref_ = make_blob_pref("static_delay", keys::OUTPUT_DELAY_SIZE);
|
|
for (size_t slot = 0; slot < SENDSPIN_RECORD_SLOTS; slot++) {
|
|
this->record_slot_prefs_[slot] = make_blob_pref(keys::record_slot_key(slot), keys::RECORD_SLOT_SIZE);
|
|
}
|
|
this->record_order_pref_ = make_blob_pref(keys::RECORD_ORDER, SENDSPIN_RECORD_SLOTS);
|
|
|
|
// Wire providers and client listener
|
|
this->client_->set_listener(this);
|
|
this->client_->set_network_provider(this);
|
|
this->client_->set_persistence_provider(this);
|
|
|
|
#ifdef USE_SENDSPIN_ARTWORK
|
|
this->artwork_role_ = &this->client_->add_artwork(this->artwork_config_);
|
|
this->artwork_role_->set_listener(this);
|
|
#endif
|
|
|
|
#ifdef USE_SENDSPIN_CONTROLLER
|
|
this->controller_role_ = &this->client_->add_controller();
|
|
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
|
|
|
|
this->client_->set_unpaired_access_enabled(this->default_unpaired_access_);
|
|
|
|
#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
|
|
this->update_mdns_service_();
|
|
#endif
|
|
}
|
|
|
|
// Sends each server a goodbye and writes what the client still owed its provider. Wi-Fi sets up
|
|
// before this hub, so it shuts down after it and the goodbyes can still go out.
|
|
void SendspinHub::on_shutdown() {
|
|
// Keeps loop() from starting the client again.
|
|
this->enabled_ = false;
|
|
if (this->is_client_running()) {
|
|
this->client_->stop();
|
|
}
|
|
}
|
|
|
|
void SendspinHub::dump_config() {
|
|
// client_id exists only once start() has run.
|
|
const char *client_id = "(unavailable)";
|
|
if (this->client_ != nullptr && !this->client_->client_id().empty()) {
|
|
client_id = this->client_->client_id().c_str();
|
|
}
|
|
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
|
ESP_LOGCONFIG(TAG,
|
|
"Sendspin Hub:\n"
|
|
" Client ID: %s\n"
|
|
" MAC address: %s\n"
|
|
" Manufacturer: %s\n"
|
|
" Model: %s\n"
|
|
" Firmware version: %s\n"
|
|
" Task stack in PSRAM: %s\n"
|
|
" Unpaired access: %s\n"
|
|
" Pairing code method: %s",
|
|
client_id, get_mac_address_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(),
|
|
this->firmware_version_, YESNO(this->task_stack_in_psram_),
|
|
YESNO(this->client_ != nullptr && this->client_->is_unpaired_access_enabled()),
|
|
this->pairing_code_method_());
|
|
|
|
#ifdef USE_SENDSPIN_ARTWORK
|
|
// Slot indices come from the order the image platform entries were declared, so the log is the
|
|
// only place the mapping from a slot to the artwork it asked for can be read back.
|
|
uint8_t slot = 0;
|
|
for (const auto &preference : this->artwork_config_.preferred_formats) {
|
|
ESP_LOGCONFIG(TAG, " Artwork slot %u: %s as %s, %ux%u, display offset %" PRId32 " ms", slot++,
|
|
IMAGE_SOURCE_NAMES[static_cast<uint8_t>(preference.source)],
|
|
IMAGE_FORMAT_NAMES[static_cast<uint8_t>(preference.format)], preference.width, preference.height,
|
|
preference.display_offset_ms);
|
|
}
|
|
#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_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_client_running()) {
|
|
this->client_->disconnect(reason);
|
|
}
|
|
}
|
|
|
|
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
|
|
void SendspinHub::leave_group() {
|
|
if (this->is_client_running() &&
|
|
this->client_->get_group_state().playback_state == sendspin::SendspinPlaybackState::PLAYING) {
|
|
this->client_->leave();
|
|
}
|
|
}
|
|
|
|
// THREAD CONTEXT: Main loop (invoked from the sendspin.confirm_pairing_window action)
|
|
void SendspinHub::confirm_pairing_window() {
|
|
if (this->is_client_running()) {
|
|
this->client_->confirm_pairing_window();
|
|
}
|
|
}
|
|
|
|
// THREAD CONTEXT: Main loop (invoked from the sendspin.cancel_pairing_window action)
|
|
void SendspinHub::cancel_pairing_window() {
|
|
if (this->is_client_running()) {
|
|
this->client_->cancel_pairing_window();
|
|
}
|
|
}
|
|
|
|
const char *SendspinHub::get_mac_address_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) {
|
|
// The server matches this MAC against the L2 source MAC of the device's multicast traffic.
|
|
// ESP-IDF derives the ethernet MAC as base+3 by default on ESP32-S3, so we cannot use the
|
|
// eFuse base MAC when ethernet is the active interface.
|
|
#ifdef USE_ETHERNET
|
|
if (ethernet::global_eth_component != nullptr) {
|
|
ethernet::global_eth_component->get_eth_mac_address_pretty_into_buffer(buf);
|
|
} else {
|
|
get_mac_address_pretty_into_buffer(buf);
|
|
}
|
|
#else
|
|
get_mac_address_pretty_into_buffer(buf);
|
|
#endif
|
|
// The pretty format is uppercase, but SendspinClientConfig::mac_address must be lowercase.
|
|
for (char &c : buf) {
|
|
if (c >= 'A' && c <= 'F') {
|
|
c += 'a' - 'A';
|
|
}
|
|
}
|
|
return buf.data();
|
|
}
|
|
|
|
// Validation rejects a static code together with the dynamic code, so at most one is set.
|
|
const char *SendspinHub::pairing_code_method_() const {
|
|
if (this->pairing_code_display_supported_) {
|
|
return LOG_STR_LITERAL("dynamic");
|
|
}
|
|
if (this->static_pairing_code_ != nullptr) {
|
|
return LOG_STR_LITERAL("static");
|
|
}
|
|
return LOG_STR_LITERAL("none");
|
|
}
|
|
|
|
const char *SendspinHub::get_product_name_() const {
|
|
return this->model_ != nullptr ? this->model_ : App.get_name().c_str();
|
|
}
|
|
|
|
sendspin::SendspinClientConfig SendspinHub::build_client_config_() {
|
|
sendspin::SendspinClientConfig config;
|
|
|
|
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
|
config.mac_address = SendspinHub::get_mac_address_into_buffer(mac_buf);
|
|
config.name = App.get_friendly_name();
|
|
config.product_name = this->get_product_name_();
|
|
config.manufacturer = this->manufacturer_;
|
|
config.software_version = this->firmware_version_;
|
|
config.httpd_psram_stack = this->task_stack_in_psram_;
|
|
config.protocol_task_psram_stack = this->task_stack_in_psram_;
|
|
config.max_pairing_records = SENDSPIN_RECORD_SLOTS;
|
|
|
|
// The dynamic code needs a channel and a format. Only digits, since automations get the bare string and could not
|
|
// tell a QR code token apart.
|
|
if (this->pairing_code_display_supported_) {
|
|
config.pairing_code_out_channels = {sendspin::SendspinPairingCodeChannel::DISPLAY};
|
|
config.pairing_code_formats = {sendspin::SendspinPairingCodeFormat::DIGITS};
|
|
}
|
|
|
|
if (this->static_pairing_code_ != nullptr) {
|
|
config.static_pairing_code_locations = {"operator"};
|
|
config.static_pairing_code = this->static_pairing_code_;
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
// --- SendspinClientListener overrides ---
|
|
// THREAD CONTEXT: Main loop (fired from client_->loop())
|
|
|
|
void SendspinHub::on_group_update(const sendspin::GroupUpdateObject &group) {
|
|
this->group_update_callbacks_.call(group);
|
|
}
|
|
|
|
void SendspinHub::on_request_high_performance() {
|
|
#ifdef USE_WIFI
|
|
if (wifi::global_wifi_component != nullptr) {
|
|
wifi::global_wifi_component->request_high_performance();
|
|
wifi::global_wifi_component->request_roaming_suppression();
|
|
}
|
|
#endif
|
|
}
|
|
|
|
void SendspinHub::on_release_high_performance() {
|
|
#ifdef USE_WIFI
|
|
if (wifi::global_wifi_component != nullptr) {
|
|
wifi::global_wifi_component->release_high_performance();
|
|
wifi::global_wifi_component->release_roaming_suppression();
|
|
}
|
|
#endif
|
|
}
|
|
|
|
void SendspinHub::on_open_pairing_window() { this->open_pairing_window_callbacks_.call(); }
|
|
|
|
void SendspinHub::on_close_pairing_window() { this->close_pairing_window_callbacks_.call(); }
|
|
|
|
// Only digits are offered, and the library refuses an activation in any other format.
|
|
void SendspinHub::on_display_pairing_code(const std::string &code, sendspin::SendspinPairingCodeFormat /*format*/) {
|
|
this->display_pairing_code_callbacks_.call(code);
|
|
}
|
|
|
|
void SendspinHub::on_clear_pairing_code() { this->clear_pairing_code_callbacks_.call(); }
|
|
|
|
void SendspinHub::on_pairing_succeeded(const std::string &server_id) {
|
|
this->pairing_succeeded_callbacks_.call(server_id);
|
|
}
|
|
|
|
void SendspinHub::on_pairing_failed(const std::string &server_id, sendspin::SendspinPairAbortReason reason) {
|
|
this->pairing_failed_callbacks_.call(server_id, pair_abort_reason_to_string(reason));
|
|
}
|
|
|
|
// --- SendspinNetworkProvider override ---
|
|
|
|
// THREAD CONTEXT: Main loop (polled by start() and client_->loop())
|
|
bool SendspinHub::is_network_ready() { return network::is_connected(); }
|
|
|
|
// --- SendspinPersistenceProvider overrides ---
|
|
// THREAD CONTEXT: Main loop (the library makes every provider call there, including from start() and stop())
|
|
|
|
std::pair<ESPPreferenceObject *, size_t> SendspinHub::pref_for_key_(const std::string &key) {
|
|
namespace keys = sendspin::persistence_keys;
|
|
if (auto slot = parse_record_slot(key); slot.has_value()) {
|
|
return {&this->record_slot_prefs_[*slot], keys::RECORD_SLOT_SIZE};
|
|
}
|
|
if (key == keys::RECORD_ORDER) {
|
|
return {&this->record_order_pref_, SENDSPIN_RECORD_SLOTS};
|
|
}
|
|
if (key == keys::KEYPAIR) {
|
|
return {&this->keypair_pref_, keys::KEYPAIR_SIZE};
|
|
}
|
|
if (key == keys::PAIRING_PSK) {
|
|
return {&this->pairing_psk_pref_, keys::PAIRING_PSK_SIZE};
|
|
}
|
|
if (key == keys::LAST_PLAYED) {
|
|
return {&this->last_played_pref_, keys::LAST_PLAYED_SIZE};
|
|
}
|
|
if (key == keys::OUTPUT_DELAY) {
|
|
return {&this->output_delay_pref_, keys::OUTPUT_DELAY_SIZE};
|
|
}
|
|
return {nullptr, 0};
|
|
}
|
|
|
|
std::optional<std::vector<uint8_t>> SendspinHub::load_blob(const std::string &key) {
|
|
auto [pref, size] = this->pref_for_key_(key);
|
|
if (pref == nullptr) {
|
|
ESP_LOGW(TAG, "load_blob: unknown key \"%s\"", key.c_str());
|
|
return std::nullopt;
|
|
}
|
|
return read_blob(*pref, size);
|
|
}
|
|
|
|
bool SendspinHub::save_blob(const std::string &key, const uint8_t *data, size_t len) {
|
|
auto [pref, size] = this->pref_for_key_(key);
|
|
if (pref == nullptr) {
|
|
ESP_LOGW(TAG, "save_blob: unknown key \"%s\"", key.c_str());
|
|
return false;
|
|
}
|
|
return write_blob(*pref, key.c_str(), size, data, len);
|
|
}
|
|
|
|
// Writes every component's queued preferences, so a failure may belong to another component.
|
|
bool SendspinHub::commit() { return global_preferences->sync(); }
|
|
|
|
// --- Sendspin role specific methods/overrides ---
|
|
|
|
#ifdef USE_SENDSPIN_ARTWORK
|
|
// THREAD CONTEXT: Dedicated artwork decode thread; downstream callbacks run here too
|
|
void SendspinHub::on_image_decode(uint8_t slot, const uint8_t *data, size_t length,
|
|
sendspin::SendspinImageFormat format) {
|
|
this->artwork_image_decode_callbacks_.call(slot, data, length, format);
|
|
}
|
|
|
|
// THREAD CONTEXT: Main loop (fired from client_->loop() once the slot's offset-shifted display
|
|
// deadline is reached; lateness_ms reports how far past the deadline the display slipped)
|
|
void SendspinHub::on_image_display(uint8_t slot, uint32_t lateness_ms) {
|
|
this->artwork_image_display_callbacks_.call(slot, lateness_ms);
|
|
}
|
|
|
|
// THREAD CONTEXT: Main loop (fired from client_->loop())
|
|
void SendspinHub::on_image_clear(uint8_t slot) { this->artwork_image_clear_callbacks_.call(slot); }
|
|
|
|
// THREAD CONTEXT: Main loop (invoked from SendspinImageSlot once a delivery is fully presented)
|
|
void SendspinHub::artwork_frame_done(uint8_t slot) {
|
|
if (this->artwork_role_ != nullptr) {
|
|
this->artwork_role_->frame_done(slot);
|
|
}
|
|
}
|
|
#endif
|
|
|
|
#ifdef USE_SENDSPIN_CONTROLLER
|
|
// 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_client_running()) {
|
|
sendspin::ClientCommandControllerObject obj = {
|
|
.command = command,
|
|
.volume = volume,
|
|
.muted = mute,
|
|
};
|
|
this->controller_role_->send_command(obj);
|
|
}
|
|
}
|
|
|
|
// THREAD CONTEXT: Main loop (invoked from the sendspin.switch action)
|
|
void SendspinHub::switch_client() { this->send_client_command(sendspin::SendspinControllerCommand::SWITCH); }
|
|
|
|
// THREAD CONTEXT: Main loop (ControllerRoleListener override, fired from client_->loop())
|
|
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
|
|
// 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 (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()) {
|
|
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() {
|
|
if (this->is_ready()) {
|
|
return this->client_->player();
|
|
}
|
|
return nullptr;
|
|
}
|
|
#endif
|
|
|
|
} // namespace esphome::sendspin_
|
|
|
|
#endif // USE_ESP_IDF
|