mirror of
https://github.com/esphome/esphome.git
synced 2026-10-27 08:22:25 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63ef2b9b81 |
No files matched your search
@@ -4,16 +4,28 @@ from typing import Any
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32, mdns, network, psram, socket, wifi
|
||||
from esphome.components import (
|
||||
audio,
|
||||
esp32,
|
||||
mdns,
|
||||
microphone,
|
||||
network,
|
||||
psram,
|
||||
socket,
|
||||
wifi,
|
||||
)
|
||||
from esphome.components.const import CONF_ENABLED, CONF_MANUFACTURER
|
||||
from esphome.components.mdns import DOMAIN as MDNS_DOMAIN
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BITS_PER_SAMPLE,
|
||||
CONF_BUFFER_SIZE,
|
||||
CONF_CHANNELS,
|
||||
CONF_ESPHOME,
|
||||
CONF_FORMAT,
|
||||
CONF_HEIGHT,
|
||||
CONF_ID,
|
||||
CONF_MICROPHONE,
|
||||
CONF_MODEL,
|
||||
CONF_NAME,
|
||||
CONF_PROJECT,
|
||||
@@ -25,6 +37,7 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.cpp_generator import MockObj
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -59,6 +72,10 @@ CONF_ON_CLEAR_PAIRING_CODE = "on_clear_pairing_code"
|
||||
CONF_ON_PAIRING_SUCCEEDED = "on_pairing_succeeded"
|
||||
CONF_ON_PAIRING_FAILED = "on_pairing_failed"
|
||||
|
||||
CONF_ON_STREAMING_STARTED = "on_streaming_started"
|
||||
CONF_ON_STREAMING_STOPPED = "on_streaming_stopped"
|
||||
CONF_PRESENT = "present"
|
||||
|
||||
# A static pairing code is exactly 8 decimal digits.
|
||||
STATIC_PAIRING_CODE_DIGITS = 8
|
||||
|
||||
@@ -104,6 +121,9 @@ CODECS = {
|
||||
DEFAULT_CODECS = [CODEC_FLAC, CODEC_OPUS, CODEC_PCM]
|
||||
OPUS_SAMPLE_RATE = 48000
|
||||
|
||||
# The source role's limits, matching SourceRoleConfig in sendspin-cpp.
|
||||
SOURCE_BITS_PER_SAMPLE = (16, 24, 32)
|
||||
|
||||
SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True)
|
||||
IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG")
|
||||
IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG")
|
||||
@@ -117,6 +137,7 @@ AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObj
|
||||
PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig")
|
||||
ArtworkRoleConfig = sendspin_library_ns.struct("ArtworkRoleConfig")
|
||||
ImageSlotPreference = sendspin_library_ns.struct("ImageSlotPreference")
|
||||
SourceRoleConfig = sendspin_library_ns.struct("SourceRoleConfig")
|
||||
|
||||
# MemoryLocation enum (from sendspin/types.h) controls SPIRAM-vs-internal-RAM placement
|
||||
# preference for the player role's transfer buffers.
|
||||
@@ -176,6 +197,11 @@ class SendspinConfiguration:
|
||||
visualizer_support: bool = False
|
||||
pairing_code_display_support: bool = False
|
||||
switch_types: set[str] = field(default_factory=set)
|
||||
# Set by the sendspin.source actions and conditions, which need a source block.
|
||||
source_referenced: bool = False
|
||||
source_line_sense: bool = False
|
||||
# The microphone's sample rate, read during final validation.
|
||||
source_sample_rate: int | None = None
|
||||
|
||||
artwork_preferences: list[ConfigType] = field(default_factory=list)
|
||||
player_config: ConfigType | None = None
|
||||
@@ -270,6 +296,44 @@ def _request_high_performance_networking(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
_SOURCE_CALLBACK_AUTOMATIONS = (
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_STREAMING_STARTED, "add_on_source_streaming_started_callback"
|
||||
),
|
||||
automation.CallbackAutomation(
|
||||
CONF_ON_STREAMING_STOPPED, "add_on_source_streaming_stopped_callback"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _validate_source_format(config: ConfigType) -> ConfigType:
|
||||
if config[CONF_MICROPHONE][CONF_BITS_PER_SAMPLE] not in SOURCE_BITS_PER_SAMPLE:
|
||||
raise cv.Invalid(
|
||||
f"{CONF_BITS_PER_SAMPLE} must be one of "
|
||||
f"{', '.join(str(bits) for bits in SOURCE_BITS_PER_SAMPLE)}",
|
||||
path=[CONF_MICROPHONE, CONF_BITS_PER_SAMPLE],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
SOURCE_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_MICROPHONE): microphone.microphone_source_schema(
|
||||
min_bits_per_sample=16,
|
||||
max_bits_per_sample=32,
|
||||
min_channels=1,
|
||||
max_channels=2,
|
||||
),
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
|
||||
cv.Optional(CONF_ON_STREAMING_STARTED): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_STREAMING_STOPPED): automation.validate_automation({}),
|
||||
}
|
||||
),
|
||||
_validate_source_format,
|
||||
)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
@@ -295,6 +359,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_ON_CLEAR_PAIRING_CODE): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_PAIRING_SUCCEEDED): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_PAIRING_FAILED): automation.validate_automation({}),
|
||||
cv.Optional(CONF_SOURCE): SOURCE_SCHEMA,
|
||||
}
|
||||
),
|
||||
cv.only_on_esp32,
|
||||
@@ -335,9 +400,38 @@ def _final_validate(config: ConfigType) -> ConfigType:
|
||||
CONF_UNPAIRED_ACCESS,
|
||||
CONF_STATIC_PAIRING_CODE,
|
||||
)
|
||||
_final_validate_source(config)
|
||||
return config
|
||||
|
||||
|
||||
def _final_validate_source(config: ConfigType) -> None:
|
||||
data = _get_data()
|
||||
if (source := config.get(CONF_SOURCE)) is None:
|
||||
if data.source_referenced:
|
||||
raise cv.Invalid(
|
||||
f"The sendspin.source actions and conditions require a '{CONF_SOURCE}' "
|
||||
"block on the sendspin component"
|
||||
)
|
||||
return
|
||||
|
||||
mic_source = source[CONF_MICROPHONE]
|
||||
try:
|
||||
microphone.final_validate_microphone_source_schema(DOMAIN)(mic_source)
|
||||
except cv.Invalid as err:
|
||||
err.prepend([CONF_SOURCE, CONF_MICROPHONE])
|
||||
raise
|
||||
|
||||
# MicrophoneSource cannot resample, so the stream takes the microphone's own rate.
|
||||
mic_config = fv.get_declaring_config(mic_source[CONF_MICROPHONE])
|
||||
sample_rate = mic_config.get(audio.CONF_MAX_SAMPLE_RATE)
|
||||
if sample_rate is None or mic_config.get(audio.CONF_MIN_SAMPLE_RATE) != sample_rate:
|
||||
raise cv.Invalid(
|
||||
"The microphone used by the sendspin source must have a fixed sample rate",
|
||||
path=[CONF_SOURCE, CONF_MICROPHONE],
|
||||
)
|
||||
data.source_sample_rate = sample_rate
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
@@ -348,6 +442,7 @@ def _request_controller_role(config: ConfigType) -> ConfigType:
|
||||
|
||||
|
||||
# Selects the hub. sendspin.switch adds the controller role it needs; the pairing window actions need no role.
|
||||
# sendspin.source.is_streaming also uses it.
|
||||
SENDSPIN_HUB_ACTION_SCHEMA = automation.maybe_simple_id(
|
||||
cv.Schema(
|
||||
{
|
||||
@@ -381,6 +476,40 @@ automation.register_apply_action(
|
||||
)
|
||||
|
||||
|
||||
def _request_source(config: ConfigType) -> ConfigType:
|
||||
"""Mark that a source action or condition is used, so a missing source block is reported."""
|
||||
_get_data().source_referenced = True
|
||||
return config
|
||||
|
||||
|
||||
def _request_source_line_sense(config: ConfigType) -> ConfigType:
|
||||
"""Advertise line sense for the sendspin.source.set_signal action."""
|
||||
_get_data().source_line_sense = True
|
||||
return _request_source(config)
|
||||
|
||||
|
||||
automation.register_apply_condition(
|
||||
"sendspin.source.is_streaming",
|
||||
cv.All(SENDSPIN_HUB_ACTION_SCHEMA, _request_source),
|
||||
"is_source_streaming()",
|
||||
)
|
||||
|
||||
|
||||
automation.register_apply_action(
|
||||
"sendspin.source.set_signal",
|
||||
cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(SendspinHub),
|
||||
cv.Required(CONF_PRESENT): cv.templatable(cv.boolean),
|
||||
}
|
||||
),
|
||||
_request_source_line_sense,
|
||||
),
|
||||
automation.ApplyField(CONF_PRESENT, "set_source_signal", cg.bool_),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
@@ -433,9 +562,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
data = _get_data()
|
||||
|
||||
# The color and source roles are not yet wired up in ESPHome; disable them in the library for now.
|
||||
# The color role is not yet wired up in ESPHome; disable it in the library for now.
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_COLOR", False)
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE", False)
|
||||
|
||||
# Configure Sendspin roles based on requested features (ESPHome internally via USE_SENDSPIN_*)
|
||||
# and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*).
|
||||
@@ -525,6 +653,37 @@ async def to_code(config: ConfigType) -> None:
|
||||
else:
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_PLAYER", False)
|
||||
|
||||
if (source := config.get(CONF_SOURCE)) is not None:
|
||||
cg.add_define("USE_SENDSPIN_SOURCE", True)
|
||||
|
||||
mic_source = await microphone.microphone_source_to_code(source[CONF_MICROPHONE])
|
||||
cg.add(var.set_microphone_source(mic_source))
|
||||
|
||||
source_psram_stack = source.get(CONF_TASK_STACK_IN_PSRAM, False)
|
||||
if source_psram_stack:
|
||||
psram.request_external_task_stack()
|
||||
|
||||
# Designated initializers must follow SourceRoleConfig's declaration order. The fields left out keep
|
||||
# sendspin-cpp's defaults.
|
||||
cg.add(
|
||||
var.set_source_config(
|
||||
cg.StructInitializer(
|
||||
SourceRoleConfig,
|
||||
("sample_rate", data.source_sample_rate),
|
||||
("channels", len(source[CONF_MICROPHONE][CONF_CHANNELS])),
|
||||
("bit_depth", source[CONF_MICROPHONE][CONF_BITS_PER_SAMPLE]),
|
||||
("line_sense", data.source_line_sense),
|
||||
("psram_stack", source_psram_stack),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
await automation.build_callback_automations(
|
||||
var, source, _SOURCE_CALLBACK_AUTOMATIONS
|
||||
)
|
||||
else:
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE", False)
|
||||
|
||||
if not data.player_support or CODEC_OPUS not in data.player_config[CONF_CODECS]:
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_OPUS", False)
|
||||
|
||||
|
||||
@@ -158,6 +158,23 @@ void SendspinHub::setup() {
|
||||
this->client_->add_player(this->player_config_).set_listener(this->player_listener_);
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_SOURCE
|
||||
this->source_role_ = &this->client_->add_source(this->source_config_);
|
||||
this->source_role_->set_listener(this);
|
||||
// THREAD CONTEXT: Microphone task, the role's single producer. A capture time of 0 (unknown) makes the library
|
||||
// stamp the buffer as ending now.
|
||||
this->microphone_source_->add_data_callback([this](const microphone::AudioCapture &capture) {
|
||||
// A failed microphone read can publish an empty buffer, which the library rejects with a warning
|
||||
if (!capture.data.empty()) {
|
||||
this->source_role_->write_audio(capture.data.data(), capture.data.size(), capture.capture_time_us);
|
||||
}
|
||||
});
|
||||
// A sensor that reports its state at boot can run the set_signal action before this setup()
|
||||
if (this->source_signal_.has_value()) {
|
||||
this->set_source_signal(*this->source_signal_);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Set before setup() by codegen; an unpaired access switch sets it later, from its own setup().
|
||||
if (this->unpaired_access_.has_value()) {
|
||||
this->client_->set_unpaired_access_enabled(*this->unpaired_access_);
|
||||
@@ -223,6 +240,18 @@ void SendspinHub::dump_config() {
|
||||
preference.display_offset_ms);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_SOURCE
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Source:\n"
|
||||
" Sample rate: %" PRIu32 " Hz\n"
|
||||
" Channels: %u\n"
|
||||
" Bits per sample: %u\n"
|
||||
" Task stack in PSRAM: %s\n"
|
||||
" Line sense: %s",
|
||||
this->source_config_.sample_rate, this->source_config_.channels, this->source_config_.bit_depth,
|
||||
YESNO(this->source_config_.psram_stack), YESNO(this->source_config_.line_sense));
|
||||
#endif
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components)
|
||||
@@ -564,6 +593,29 @@ sendspin::PlayerRole *SendspinHub::get_player_role() {
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_SOURCE
|
||||
// THREAD CONTEXT: Main loop (invoked from the sendspin.source.set_signal action)
|
||||
void SendspinHub::set_source_signal(bool present) {
|
||||
if (this->source_role_ == nullptr) {
|
||||
this->source_signal_ = present; // Applied in setup()
|
||||
return;
|
||||
}
|
||||
this->source_role_->set_signal(present ? sendspin::SourceSignal::PRESENT : sendspin::SourceSignal::ABSENT);
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (SourceRoleListener override, fired from client_->loop())
|
||||
void SendspinHub::on_streaming_started() {
|
||||
this->microphone_source_->start();
|
||||
this->source_streaming_started_callbacks_.call();
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (SourceRoleListener override, fired from client_->loop())
|
||||
void SendspinHub::on_streaming_stopped() {
|
||||
this->microphone_source_->stop();
|
||||
this->source_streaming_stopped_callbacks_.call();
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif // USE_ESP_IDF
|
||||
@@ -31,6 +31,11 @@
|
||||
#ifdef USE_SENDSPIN_PLAYER
|
||||
#include <sendspin/player_role.h>
|
||||
#endif
|
||||
#ifdef USE_SENDSPIN_SOURCE
|
||||
#include "esphome/components/microphone/microphone_source.h"
|
||||
|
||||
#include <sendspin/source_role.h>
|
||||
#endif
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
@@ -75,6 +80,7 @@ enum SendspinFeature : uint32_t {
|
||||
/// - Handles Sendspin roles that apply to multiple child components (artwork, controller, metadata) so their events
|
||||
/// can be fanned out. Roles specific to a single component (player) are configured by the hub but owned by the
|
||||
/// child thereafter, since no fan-out is needed.
|
||||
/// - The source role streams a microphone. Its data callback runs on the microphone's task.
|
||||
///
|
||||
/// The sendspin-cpp library follows this design:
|
||||
/// - Core and role configuration are passed at client/role construction time as structs. Built in our `setup()`.
|
||||
@@ -91,6 +97,9 @@ class SendspinHub final : public Component,
|
||||
#endif
|
||||
#ifdef USE_SENDSPIN_METADATA
|
||||
public sendspin::MetadataRoleListener,
|
||||
#endif
|
||||
#ifdef USE_SENDSPIN_SOURCE
|
||||
public sendspin::SourceRoleListener,
|
||||
#endif
|
||||
public sendspin::SendspinClientListener,
|
||||
public sendspin::SendspinNetworkProvider,
|
||||
@@ -285,6 +294,31 @@ class SendspinHub final : public Component,
|
||||
sendspin::PlayerRole *get_player_role();
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_SOURCE
|
||||
void set_source_config(const sendspin::SourceRoleConfig &config) { this->source_config_ = config; }
|
||||
void set_microphone_source(microphone::MicrophoneSource *microphone_source) {
|
||||
this->microphone_source_ = microphone_source;
|
||||
}
|
||||
|
||||
/// @brief Reports whether the capture input has a signal; exposed as the sendspin.source.set_signal action.
|
||||
///
|
||||
/// Using the action enables line sense in the source config. A call before setup() is applied there. Must be called
|
||||
/// from the main loop thread.
|
||||
void set_source_signal(bool present);
|
||||
|
||||
/// @brief Returns whether the source is streaming microphone audio to the server.
|
||||
bool is_source_streaming() const { return this->source_role_ != nullptr && this->source_role_->is_streaming(); }
|
||||
|
||||
/// @brief Registers a callback that fires when the server opens the source stream.
|
||||
template<typename F> void add_on_source_streaming_started_callback(F &&callback) {
|
||||
this->source_streaming_started_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
/// @brief Registers a callback that fires when the source stream closes.
|
||||
template<typename F> void add_on_source_streaming_stopped_callback(F &&callback) {
|
||||
this->source_streaming_stopped_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
#endif
|
||||
|
||||
protected:
|
||||
/// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info.
|
||||
sendspin::SendspinClientConfig build_client_config_();
|
||||
@@ -383,6 +417,22 @@ class SendspinHub final : public Component,
|
||||
sendspin::PlayerRoleConfig player_config_{};
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_SOURCE
|
||||
// The server opens and closes the stream; the microphone runs only while it is open.
|
||||
void on_streaming_started() override;
|
||||
void on_streaming_stopped() override;
|
||||
|
||||
sendspin::SourceRoleConfig source_config_{};
|
||||
sendspin::SourceRole *source_role_{nullptr};
|
||||
microphone::MicrophoneSource *microphone_source_{nullptr};
|
||||
// Signal state set before setup(), applied once the role exists
|
||||
std::optional<bool> source_signal_;
|
||||
|
||||
// Lazy: each is fed by an optional YAML automation.
|
||||
LazyCallbackManager<void()> source_streaming_started_callbacks_{};
|
||||
LazyCallbackManager<void()> source_streaming_stopped_callbacks_{};
|
||||
#endif
|
||||
|
||||
// --- Core member variables ---
|
||||
|
||||
// Built once in setup(): make_preference() allocates a backend that is never freed.
|
||||
|
||||
@@ -527,6 +527,7 @@
|
||||
#define USE_SENDSPIN_CONTROLLER
|
||||
#define USE_SENDSPIN_METADATA
|
||||
#define USE_SENDSPIN_PLAYER
|
||||
#define USE_SENDSPIN_SOURCE
|
||||
#define USE_SENDSPIN_SWITCH
|
||||
#define USE_SENDSPIN_VISUALIZER
|
||||
#define USE_SENDSPIN_PORT 8928 // NOLINT
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ap:
|
||||
|
||||
i2s_audio:
|
||||
- id: i2s_bus
|
||||
i2s_lrclk_pin: GPIO4
|
||||
i2s_bclk_pin: GPIO5
|
||||
|
||||
microphone:
|
||||
- platform: i2s_audio
|
||||
id: mic
|
||||
i2s_din_pin: GPIO39
|
||||
adc_type: external
|
||||
sample_rate: 16000
|
||||
|
||||
sendspin:
|
||||
source:
|
||||
microphone: mic
|
||||
|
||||
# The condition alone does not report signals, so line sense stays off
|
||||
interval:
|
||||
- interval: 10s
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
sendspin.source.is_streaming:
|
||||
then:
|
||||
- delay: 1ms
|
||||
@@ -0,0 +1,29 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
wifi:
|
||||
ap:
|
||||
|
||||
i2s_audio:
|
||||
- id: i2s_bus
|
||||
i2s_lrclk_pin: GPIO4
|
||||
i2s_bclk_pin: GPIO5
|
||||
|
||||
microphone:
|
||||
- platform: i2s_audio
|
||||
id: mic
|
||||
i2s_din_pin: GPIO39
|
||||
adc_type: external
|
||||
sample_rate: 48000
|
||||
|
||||
sendspin:
|
||||
source:
|
||||
microphone: mic
|
||||
on_streaming_started:
|
||||
- sendspin.source.set_signal:
|
||||
present: true
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Validation and code generation tests for the sendspin source role.
|
||||
|
||||
These cover the rejection branches of the source block and the generated role
|
||||
config, which a compile test cannot check: a `test*.yaml` can only assert that a
|
||||
configuration is accepted.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.automation import ACTION_REGISTRY, CONDITION_REGISTRY
|
||||
from esphome.components.audio import CONF_MAX_SAMPLE_RATE, CONF_MIN_SAMPLE_RATE
|
||||
from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS
|
||||
from esphome.components.sendspin import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA, _get_data
|
||||
from esphome.config import Config
|
||||
from esphome.const import CONF_ID, CONF_SOURCE, PlatformFramework
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.types import ConfigType
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
HUB_ID = "sendspin_hub"
|
||||
MIC_ID = "mic"
|
||||
|
||||
|
||||
def _full_config(min_sample_rate: int = 16000, max_sample_rate: int = 16000) -> Config:
|
||||
"""A full config carrying one microphone, as the ID pass leaves it."""
|
||||
mic: ConfigType = {
|
||||
CONF_ID: ID(MIC_ID, is_declaration=True, type="microphone"),
|
||||
CONF_MIN_SAMPLE_RATE: min_sample_rate,
|
||||
CONF_MAX_SAMPLE_RATE: max_sample_rate,
|
||||
"min_channels": 1,
|
||||
"max_channels": 2,
|
||||
"min_bits_per_sample": 16,
|
||||
"max_bits_per_sample": 32,
|
||||
}
|
||||
full = Config()
|
||||
full["microphone"] = [mic]
|
||||
full.declare_ids.append((mic[CONF_ID], ["microphone", 0, CONF_ID]))
|
||||
return full
|
||||
|
||||
|
||||
def _hub_config(**source: Any) -> ConfigType:
|
||||
"""Validate a hub config with a source block, allowing field overrides."""
|
||||
source_config: ConfigType = {
|
||||
"microphone": {"microphone": ID(MIC_ID, is_declaration=False)}
|
||||
}
|
||||
source_config.update(source)
|
||||
return CONFIG_SCHEMA({CONF_ID: HUB_ID, CONF_SOURCE: source_config})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def microphone(set_core_config: SetCoreConfigCallable) -> None:
|
||||
set_core_config(PlatformFramework.ESP32_IDF, full_config=_full_config())
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("microphone")
|
||||
def test_final_validate_records_sample_rate() -> None:
|
||||
"""The stream takes the microphone's own rate, since nothing can resample it."""
|
||||
FINAL_VALIDATE_SCHEMA(_hub_config())
|
||||
|
||||
assert _get_data().source_sample_rate == 16000
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("microphone")
|
||||
def test_unsupported_bits_per_sample_rejected() -> None:
|
||||
with pytest.raises(cv.Invalid, match="bits_per_sample must be one of"):
|
||||
_hub_config(microphone={"microphone": MIC_ID, "bits_per_sample": 20})
|
||||
|
||||
|
||||
def test_variable_sample_rate_rejected(set_core_config: SetCoreConfigCallable) -> None:
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
full_config=_full_config(min_sample_rate=16000, max_sample_rate=48000),
|
||||
)
|
||||
|
||||
with pytest.raises(cv.Invalid, match="must have a fixed sample rate"):
|
||||
FINAL_VALIDATE_SCHEMA(_hub_config())
|
||||
|
||||
|
||||
def test_microphone_source_errors_point_at_the_source(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""A channel the microphone does not have is reported at source: microphone:."""
|
||||
full_config = _full_config()
|
||||
full_config["microphone"][0]["max_channels"] = 1
|
||||
set_core_config(PlatformFramework.ESP32_IDF, full_config=full_config)
|
||||
|
||||
with pytest.raises(cv.Invalid) as err:
|
||||
FINAL_VALIDATE_SCHEMA(
|
||||
_hub_config(microphone={"microphone": MIC_ID, "channels": [0, 1]})
|
||||
)
|
||||
assert err.value.path[:2] == [CONF_SOURCE, "microphone"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("registry", "name", "action_config"),
|
||||
[
|
||||
(ACTION_REGISTRY, "sendspin.source.set_signal", {"present": True}),
|
||||
(CONDITION_REGISTRY, "sendspin.source.is_streaming", {}),
|
||||
],
|
||||
)
|
||||
def test_source_automation_without_source_rejected(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
registry: dict[str, Any],
|
||||
name: str,
|
||||
action_config: ConfigType,
|
||||
) -> None:
|
||||
"""The source action and condition have nothing to act on without a source block."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
registry[name].schema({CONF_ID: ID(HUB_ID, is_declaration=False), **action_config})
|
||||
|
||||
with pytest.raises(cv.Invalid, match="require a 'source' block"):
|
||||
FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA({CONF_ID: HUB_ID}))
|
||||
|
||||
|
||||
def _sdkconfig_option(name: str) -> Any:
|
||||
return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get(name)
|
||||
|
||||
|
||||
def test_line_sense_source_config_generated(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""The set_signal action turns on line sense."""
|
||||
main_cpp = generate_main(component_config_path("source_line_sense.yaml"))
|
||||
|
||||
assert "set_source_config(" in main_cpp
|
||||
assert ".sample_rate = 48000" in main_cpp
|
||||
assert ".line_sense = true" in main_cpp
|
||||
assert _sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE") is None
|
||||
assert _sdkconfig_option("CONFIG_SENDSPIN_ENABLE_OPUS") is False
|
||||
|
||||
|
||||
def test_default_source_config_generated(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Fields with no option keep sendspin-cpp's defaults, and is_streaming alone leaves line sense off."""
|
||||
main_cpp = generate_main(component_config_path("source_defaults.yaml"))
|
||||
|
||||
assert ".sample_rate = 16000" in main_cpp
|
||||
assert ".chunk_duration_ms" not in main_cpp
|
||||
assert ".capture_buffer_ms" not in main_cpp
|
||||
assert ".line_sense = false" in main_cpp
|
||||
assert ".buffer_location" not in main_cpp
|
||||
assert _sdkconfig_option("CONFIG_SENDSPIN_ENABLE_OPUS") is False
|
||||
|
||||
|
||||
def test_no_source_disables_the_role(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
main_cpp = generate_main(component_config_path("device_info_default.yaml"))
|
||||
|
||||
assert "set_source_config(" not in main_cpp
|
||||
assert _sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE") is False
|
||||
@@ -0,0 +1,40 @@
|
||||
packages:
|
||||
sendspin: !include common.yaml
|
||||
|
||||
microphone:
|
||||
- platform: i2s_audio
|
||||
id: sendspin_mic_id
|
||||
i2s_audio_id: i2s_audio_bus
|
||||
i2s_din_pin: ${i2s_din_pin}
|
||||
adc_type: external
|
||||
channel: stereo
|
||||
sample_rate: 48000
|
||||
bits_per_sample: 16bit
|
||||
|
||||
sendspin:
|
||||
source:
|
||||
microphone:
|
||||
microphone: sendspin_mic_id
|
||||
channels: [0, 1]
|
||||
bits_per_sample: 16
|
||||
gain_factor: 2
|
||||
task_stack_in_psram: true
|
||||
on_streaming_started:
|
||||
then:
|
||||
- logger.log: "Source streaming started"
|
||||
- sendspin.source.set_signal:
|
||||
present: true
|
||||
on_streaming_stopped:
|
||||
then:
|
||||
- logger.log: "Source streaming stopped"
|
||||
- sendspin.source.set_signal:
|
||||
present: !lambda return false;
|
||||
|
||||
interval:
|
||||
- interval: 10s
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
sendspin.source.is_streaming:
|
||||
then:
|
||||
- logger.log: "Source is streaming"
|
||||
@@ -0,0 +1,6 @@
|
||||
substitutions:
|
||||
i2s_din_pin: GPIO39
|
||||
|
||||
packages:
|
||||
i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml
|
||||
sendspin: !include common-source.yaml
|
||||
@@ -0,0 +1,21 @@
|
||||
# The defaults, with a 24-bit mono source at 44.1 kHz
|
||||
substitutions:
|
||||
i2s_din_pin: GPIO39
|
||||
|
||||
packages:
|
||||
i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml
|
||||
sendspin: !include common.yaml
|
||||
|
||||
microphone:
|
||||
- platform: i2s_audio
|
||||
id: sendspin_mic_id
|
||||
i2s_audio_id: i2s_audio_bus
|
||||
i2s_din_pin: ${i2s_din_pin}
|
||||
adc_type: external
|
||||
sample_rate: 44100
|
||||
|
||||
sendspin:
|
||||
source:
|
||||
microphone:
|
||||
microphone: sendspin_mic_id
|
||||
bits_per_sample: 24
|
||||
Reference in new issue
Block a user