mirror of
https://github.com/esphome/esphome.git
synced 2026-06-02 03:02:19 +08:00
[sendspin] Add a group media player controller (PR3) (#15948)
Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
@@ -441,6 +441,7 @@ esphome/components/sen21231/* @shreyaskarnik
|
|||||||
esphome/components/sen5x/* @martgras
|
esphome/components/sen5x/* @martgras
|
||||||
esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct
|
esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct
|
||||||
esphome/components/sendspin/* @kahrendt
|
esphome/components/sendspin/* @kahrendt
|
||||||
|
esphome/components/sendspin/media_player/* @kahrendt
|
||||||
esphome/components/sensirion_common/* @martgras
|
esphome/components/sensirion_common/* @martgras
|
||||||
esphome/components/sensor/* @esphome/core
|
esphome/components/sensor/* @esphome/core
|
||||||
esphome/components/serial_proxy/* @kbx81
|
esphome/components/serial_proxy/* @kbx81
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ CODEOWNERS = ["@kahrendt"]
|
|||||||
DEPENDENCIES = ["network"]
|
DEPENDENCIES = ["network"]
|
||||||
DOMAIN = "sendspin"
|
DOMAIN = "sendspin"
|
||||||
|
|
||||||
|
CONF_SENDSPIN_ID = "sendspin_id"
|
||||||
|
|
||||||
# Trailing underscore avoids clashing with sendspin-cpp's global `sendspin` namespace.
|
# Trailing underscore avoids clashing with sendspin-cpp's global `sendspin` namespace.
|
||||||
# Analysis tools strip the trailing underscore (same pattern as `template_`).
|
# Analysis tools strip the trailing underscore (same pattern as `template_`).
|
||||||
sendspin_ns = cg.esphome_ns.namespace("sendspin_")
|
sendspin_ns = cg.esphome_ns.namespace("sendspin_")
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import esphome.codegen as cg
|
||||||
|
from esphome.components import media_player
|
||||||
|
from esphome.components.const import CONF_VOLUME_INCREMENT
|
||||||
|
import esphome.config_validation as cv
|
||||||
|
from esphome.const import CONF_ID
|
||||||
|
from esphome.types import ConfigType
|
||||||
|
|
||||||
|
from .. import CONF_SENDSPIN_ID, SendspinHub, request_controller_support, sendspin_ns
|
||||||
|
|
||||||
|
CODEOWNERS = ["@kahrendt"]
|
||||||
|
DEPENDENCIES = ["sendspin"]
|
||||||
|
|
||||||
|
SendspinMediaPlayer = sendspin_ns.class_(
|
||||||
|
"SendspinMediaPlayer",
|
||||||
|
media_player.MediaPlayer,
|
||||||
|
cg.Component,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _request_roles(config: ConfigType) -> ConfigType:
|
||||||
|
"""Request the necessary Sendspin roles for the media player."""
|
||||||
|
request_controller_support()
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_SCHEMA = cv.All(
|
||||||
|
media_player.media_player_schema(SendspinMediaPlayer).extend(
|
||||||
|
{
|
||||||
|
cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub),
|
||||||
|
cv.Optional(CONF_VOLUME_INCREMENT, default=0.05): cv.percentage,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
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 media_player.register_media_player(var, config)
|
||||||
|
|
||||||
|
cg.add(var.set_volume_increment(config[CONF_VOLUME_INCREMENT]))
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
#include "sendspin_media_player.h"
|
||||||
|
|
||||||
|
#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER)
|
||||||
|
|
||||||
|
#include "esphome/core/application.h"
|
||||||
|
#include "esphome/core/log.h"
|
||||||
|
|
||||||
|
#include <sendspin/types.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
#include <esp_timer.h>
|
||||||
|
|
||||||
|
namespace esphome::sendspin_ {
|
||||||
|
|
||||||
|
static const char *const TAG = "sendspin.media_player";
|
||||||
|
|
||||||
|
// THREAD CONTEXT: Main loop. The callbacks registered here also fire on the main loop,
|
||||||
|
// since SendspinHub dispatches group updates and controller state from client_->loop().
|
||||||
|
void SendspinMediaPlayer::setup() {
|
||||||
|
// Register for group updates to sync playback state
|
||||||
|
this->parent_->add_group_update_callback([this](const sendspin::GroupUpdateObject &group_obj) {
|
||||||
|
if (group_obj.playback_state.has_value()) {
|
||||||
|
media_player::MediaPlayerState new_state;
|
||||||
|
switch (group_obj.playback_state.value()) {
|
||||||
|
case sendspin::SendspinPlaybackState::PLAYING:
|
||||||
|
new_state = media_player::MEDIA_PLAYER_STATE_PLAYING;
|
||||||
|
break;
|
||||||
|
case sendspin::SendspinPlaybackState::STOPPED:
|
||||||
|
default:
|
||||||
|
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->parent_->add_controller_state_callback([this](const sendspin::ServerStateControllerObject &state) {
|
||||||
|
float new_volume = static_cast<float>(state.volume) / 100.0f;
|
||||||
|
bool new_muted = state.muted;
|
||||||
|
if ((new_volume != this->volume) || (new_muted != this->muted_)) {
|
||||||
|
this->volume = new_volume;
|
||||||
|
this->muted_ = new_muted;
|
||||||
|
this->publish_state();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publish an initial state
|
||||||
|
this->state = media_player::MEDIA_PLAYER_STATE_IDLE;
|
||||||
|
this->publish_state();
|
||||||
|
}
|
||||||
|
|
||||||
|
// THREAD CONTEXT: Main loop (invoked by the media_player framework)
|
||||||
|
media_player::MediaPlayerTraits SendspinMediaPlayer::get_traits() {
|
||||||
|
auto traits = media_player::MediaPlayerTraits();
|
||||||
|
|
||||||
|
// By default, the base media player always enables these traits, but they are not actually supported by this media
|
||||||
|
// player
|
||||||
|
traits.clear_feature_flags(media_player::MediaPlayerEntityFeature::PLAY_MEDIA |
|
||||||
|
media_player::MediaPlayerEntityFeature::BROWSE_MEDIA |
|
||||||
|
media_player::MediaPlayerEntityFeature::MEDIA_ANNOUNCE);
|
||||||
|
|
||||||
|
traits.add_feature_flags(
|
||||||
|
media_player::MediaPlayerEntityFeature::PLAY | media_player::MediaPlayerEntityFeature::PAUSE |
|
||||||
|
media_player::MediaPlayerEntityFeature::STOP | media_player::MediaPlayerEntityFeature::VOLUME_STEP |
|
||||||
|
media_player::MediaPlayerEntityFeature::VOLUME_SET | media_player::MediaPlayerEntityFeature::VOLUME_MUTE);
|
||||||
|
|
||||||
|
// NEXT_TRACK, PREVIOUS_TRACK, SHUFFLE_SET, and REPEAT_SET are intentionally not advertised: the ESPHome native API
|
||||||
|
// does not implement the corresponding media player commands, so Home Assistant cannot actually send them even if
|
||||||
|
// we expose the capability. They remain accessible via ESPHome YAML automations.
|
||||||
|
|
||||||
|
return traits;
|
||||||
|
}
|
||||||
|
|
||||||
|
// THREAD CONTEXT: Main loop (invoked by the media_player framework)
|
||||||
|
void SendspinMediaPlayer::control(const media_player::MediaPlayerCall &call) {
|
||||||
|
if (!this->is_ready()) {
|
||||||
|
// Ignore any commands sent before the media player is setup
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto volume = call.get_volume();
|
||||||
|
if (volume.has_value()) {
|
||||||
|
uint8_t new_volume = static_cast<uint8_t>(std::roundf(volume.value() * 100.0f));
|
||||||
|
this->parent_->send_client_command(sendspin::SendspinControllerCommand::VOLUME, new_volume, std::nullopt);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto command = call.get_command();
|
||||||
|
if (!command.has_value()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (command.value()) {
|
||||||
|
case media_player::MEDIA_PLAYER_COMMAND_TOGGLE:
|
||||||
|
if (this->state == media_player::MediaPlayerState::MEDIA_PLAYER_STATE_PLAYING) {
|
||||||
|
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PAUSE);
|
||||||
|
} else {
|
||||||
|
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PLAY);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case media_player::MEDIA_PLAYER_COMMAND_PLAY:
|
||||||
|
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PLAY);
|
||||||
|
break;
|
||||||
|
case media_player::MEDIA_PLAYER_COMMAND_PAUSE:
|
||||||
|
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PAUSE);
|
||||||
|
break;
|
||||||
|
case media_player::MEDIA_PLAYER_COMMAND_STOP:
|
||||||
|
this->parent_->send_client_command(sendspin::SendspinControllerCommand::STOP);
|
||||||
|
break;
|
||||||
|
case media_player::MEDIA_PLAYER_COMMAND_REPEAT_OFF:
|
||||||
|
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_OFF);
|
||||||
|
break;
|
||||||
|
case media_player::MEDIA_PLAYER_COMMAND_REPEAT_ONE:
|
||||||
|
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ONE);
|
||||||
|
break;
|
||||||
|
case media_player::MEDIA_PLAYER_COMMAND_REPEAT_ALL:
|
||||||
|
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ALL);
|
||||||
|
break;
|
||||||
|
case media_player::MEDIA_PLAYER_COMMAND_SHUFFLE:
|
||||||
|
this->parent_->send_client_command(sendspin::SendspinControllerCommand::SHUFFLE);
|
||||||
|
break;
|
||||||
|
case media_player::MEDIA_PLAYER_COMMAND_UNSHUFFLE:
|
||||||
|
this->parent_->send_client_command(sendspin::SendspinControllerCommand::UNSHUFFLE);
|
||||||
|
break;
|
||||||
|
case media_player::MEDIA_PLAYER_COMMAND_NEXT:
|
||||||
|
this->parent_->send_client_command(sendspin::SendspinControllerCommand::NEXT);
|
||||||
|
break;
|
||||||
|
case media_player::MEDIA_PLAYER_COMMAND_PREVIOUS:
|
||||||
|
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PREVIOUS);
|
||||||
|
break;
|
||||||
|
case media_player::MEDIA_PLAYER_COMMAND_VOLUME_UP:
|
||||||
|
this->parent_->send_client_command(
|
||||||
|
sendspin::SendspinControllerCommand::VOLUME,
|
||||||
|
static_cast<uint8_t>(std::roundf(std::min(1.0f, this->volume + this->volume_increment_) * 100.0f)),
|
||||||
|
std::nullopt);
|
||||||
|
break;
|
||||||
|
case media_player::MEDIA_PLAYER_COMMAND_VOLUME_DOWN:
|
||||||
|
this->parent_->send_client_command(
|
||||||
|
sendspin::SendspinControllerCommand::VOLUME,
|
||||||
|
static_cast<uint8_t>(std::roundf(std::max(0.0f, this->volume - this->volume_increment_) * 100.0f)),
|
||||||
|
std::nullopt);
|
||||||
|
break;
|
||||||
|
case media_player::MEDIA_PLAYER_COMMAND_MUTE:
|
||||||
|
this->parent_->send_client_command(sendspin::SendspinControllerCommand::MUTE, std::nullopt, true);
|
||||||
|
break;
|
||||||
|
case media_player::MEDIA_PLAYER_COMMAND_UNMUTE:
|
||||||
|
this->parent_->send_client_command(sendspin::SendspinControllerCommand::MUTE, std::nullopt, false);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SendspinMediaPlayer::dump_config() {
|
||||||
|
ESP_LOGCONFIG(TAG, "Sendspin Media Player: volume_increment=%.2f", this->volume_increment_);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace esphome::sendspin_
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "esphome/core/defines.h"
|
||||||
|
|
||||||
|
#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER)
|
||||||
|
|
||||||
|
#include "esphome/components/media_player/media_player.h"
|
||||||
|
#include "esphome/components/sendspin/sendspin_hub.h"
|
||||||
|
|
||||||
|
namespace esphome::sendspin_ {
|
||||||
|
|
||||||
|
class SendspinMediaPlayer : public SendspinChild, public media_player::MediaPlayer {
|
||||||
|
public:
|
||||||
|
void setup() override;
|
||||||
|
void dump_config() override;
|
||||||
|
|
||||||
|
// MediaPlayer implementations
|
||||||
|
media_player::MediaPlayerTraits get_traits() override;
|
||||||
|
|
||||||
|
void set_volume_increment(float volume_increment) { this->volume_increment_ = volume_increment; }
|
||||||
|
|
||||||
|
bool is_muted() const override { return this->muted_; }
|
||||||
|
|
||||||
|
protected:
|
||||||
|
// Receives commands from HA
|
||||||
|
void control(const media_player::MediaPlayerCall &call) override;
|
||||||
|
|
||||||
|
float volume_increment_{0.05f};
|
||||||
|
bool muted_{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace esphome::sendspin_
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<<: !include common.yaml
|
||||||
|
|
||||||
|
media_player:
|
||||||
|
- platform: sendspin
|
||||||
|
id: media_player_id
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<<: !include common-media_player.yaml
|
||||||
Reference in New Issue
Block a user