Compare commits

..
37 changed files with 1129 additions and 261 deletions

No files matched your search

+1 -2
View File
@@ -443,8 +443,7 @@ file does, and it is the authority when they disagree. The most useful starting
- **Hub forwarding entities need no C++ class:** a button, switch, number or select that only calls its hub
uses `button.ApplyButton` (`switch.ApplySwitch`, `number.ApplyNumber`, `select.ApplySelect`) in its schema
and `apply=` on the `sub_*` call. The body is a call on the hub with `entity` and `state`, `value` or
`index`/`option` in scope; on a switch, number or select `publish="before"` or `"after"` publishes the state,
and `publish="on_success"` publishes only when the hub call returns true (a write the device can refuse).
`index`/`option` in scope; on a switch, number or select `publish="before"` or `"after"` publishes the state.
`publish="momentary"` is only for legacy action switches (restart, factory reset): it publishes off and runs
the body only when turned on. Never use it for a new entity; an action is a button.
```python
@@ -0,0 +1,21 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_BQ25186_PG_GPO
#include "bq25186.h"
#include "esphome/components/switch/switch.h"
namespace esphome::bq25186 {
class BQ25186PgGpoSwitch final : public switch_::Switch, public Parented<BQ25186Component> {
protected:
void write_state(bool state) override {
if (this->parent_->write_pg_gpo_level(state))
this->publish_state(state);
}
};
} // namespace esphome::bq25186
#endif // USE_BQ25186_PG_GPO
+5 -8
View File
@@ -3,16 +3,18 @@ from esphome.components import switch
import esphome.config_validation as cv
from esphome.types import ConfigType
from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID
from . import BQ25186_CHILD_SCHEMA, CONF_BQ25186_ID, bq25186_ns
DEPENDENCIES = ["bq25186"]
CONF_PG_GPO = "pg_gpo"
BQ25186PgGpoSwitch = bq25186_ns.class_("BQ25186PgGpoSwitch", switch.Switch)
CONFIG_SCHEMA = BQ25186_CHILD_SCHEMA.extend(
{
cv.Required(CONF_PG_GPO): switch.switch_schema(
switch.ApplySwitch, default_restore_mode="DISABLED"
BQ25186PgGpoSwitch, default_restore_mode="DISABLED"
),
}
)
@@ -22,9 +24,4 @@ async def to_code(config: ConfigType) -> None:
cg.add_define("USE_BQ25186_PG_GPO")
hub = await cg.get_variable(config[CONF_BQ25186_ID])
switches = switch.sub_switches(config, parent=hub)
await switches(
CONF_PG_GPO,
hub.set_pg_gpo_switch,
apply="write_pg_gpo_level(state)",
publish="on_success",
)
await switches(CONF_PG_GPO, hub.set_pg_gpo_switch)
+1 -1
View File
@@ -22,7 +22,7 @@ CONF_CUSTOM_PATTERNS = "custom_patterns"
CONF_SKIP_CRC = "skip_crc"
CONF_DEFAULT_OBIS = "default_obis"
DLMS_PARSER_VERSION = "2.2.0"
DLMS_PARSER_VERSION = "2.2.2"
dlms_meter_component_ns = cg.esphome_ns.namespace("dlms_meter")
DlmsMeterComponent = dlms_meter_component_ns.class_(
+3 -1
View File
@@ -2389,7 +2389,9 @@ FRAMEWORK_SCHEMA = cv.Schema(
cv.Schema(
{
cv.Required(CONF_NAME): cv.string_strict,
cv.Optional(CONF_SOURCE): cv.git_ref,
cv.Optional(CONF_SOURCE): cv.All(
cv.string_strict, cv.Length(min=1)
),
cv.Optional(CONF_REF): cv.string,
cv.Optional(CONF_PATH): cv.string,
}
+26 -6
View File
@@ -138,6 +138,11 @@ static uint8_t calc_checksum(const void *data, size_t size) {
return xor_bytes(static_cast<const uint8_t *>(data), size);
}
// Simple mode needs simple reporting from the module; Normal and Calibrate read energy frames
static uint16_t system_mode_for(uint8_t mode) {
return mode == OP_SIMPLE_MODE ? CMD_SYSTEM_MODE_SIMPLE : CMD_SYSTEM_MODE_ENERGY;
}
static int32_t get_firmware_int(const char *version_string) {
// Convert "v1.5.4" -> 154 by skipping 'v' and '.', accumulating digits
const char *p = (*version_string == 'v') ? version_string + 1 : version_string;
@@ -207,13 +212,10 @@ void LD2420Component::setup() {
memcpy(&this->new_config, &this->current_config, sizeof(this->current_config));
if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) {
this->set_operating_mode(OP_SIMPLE_MODE);
this->set_mode_(CMD_SYSTEM_MODE_SIMPLE);
ESP_LOGW(TAG, "Firmware version %s and older supports Simple Mode only", this->firmware_ver_);
} else {
this->set_operating_mode(OP_NORMAL_MODE);
this->set_mode_(CMD_SYSTEM_MODE_ENERGY);
}
this->set_operating_mode(OP_NORMAL_MODE); // older firmware is held in Simple
this->set_mode_(system_mode_for(this->current_operating_mode));
#ifdef USE_NUMBER
this->init_gate_config_numbers();
#endif
@@ -244,7 +246,9 @@ void LD2420Component::apply_config_action() {
#endif
this->set_system_mode(this->system_mode_);
this->set_config_mode(false); // Disable config mode to save new values in LD2420 nvm
this->set_operating_mode(OP_NORMAL_MODE);
// Applying the calibrated thresholds ends calibration; Normal and Simple stay as they are
if (this->current_operating_mode == OP_CALIBRATE_MODE)
this->set_operating_mode(OP_NORMAL_MODE);
}
void LD2420Component::factory_reset_action() {
@@ -356,6 +360,22 @@ void LD2420Component::set_operating_mode(const char *state) {
}
}
void LD2420Component::change_operating_mode(OpMode mode) {
this->set_operating_mode(mode);
const uint16_t system_mode = system_mode_for(this->current_operating_mode);
if (system_mode == this->get_mode_())
return;
if (this->set_config_mode(true) != LD2420_ERROR_TIMEOUT) {
this->set_system_mode(system_mode);
this->set_config_mode(false);
}
if (this->get_mode_() != system_mode) {
// The module kept its reporting mode; show the mode that matches it
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
this->set_operating_mode(this->get_mode_() == CMD_SYSTEM_MODE_SIMPLE ? OP_SIMPLE_MODE : OP_NORMAL_MODE);
}
}
void LD2420Component::set_operating_mode(OpMode mode) {
// Older firmware only supports simple mode
if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) {
+1
View File
@@ -119,6 +119,7 @@ class LD2420Component final : public Component, public uart::UARTDevice {
void handle_cmd_error(uint16_t error);
void set_operating_mode(const char *state);
void set_operating_mode(OpMode mode);
void change_operating_mode(OpMode mode);
void auto_calibrate_sensitivity();
void update_radar_data(uint16_t const *gate_energy, uint8_t sample_number);
uint8_t set_config_mode(bool enable);
+1 -2
View File
@@ -30,6 +30,5 @@ async def to_code(config: ConfigType) -> None:
CONF_OPERATING_MODE,
hub.set_operating_mode_select,
options=CONF_SELECTS,
apply="set_operating_mode(static_cast<ld2420::OpMode>(index + 1))",
publish="before",
apply="change_operating_mode(static_cast<ld2420::OpMode>(index + 1))",
)
+19 -14
View File
@@ -4,6 +4,7 @@
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <span>
#include <type_traits>
#include <utility>
@@ -53,16 +54,25 @@ using DataCallbackManagerBase = CallbackManager<void(const AudioCapture &)>;
class DataCallbackManager : public DataCallbackManagerBase {
public:
explicit DataCallbackManager(const audio::AudioStreamInfo *stream_info) : stream_info_(stream_info) {}
DataCallbackManager(const audio::AudioStreamInfo *stream_info, const bool *mute_state)
: stream_info_(stream_info), mute_state_(mute_state) {}
using DataCallbackManagerBase::call;
// Remove before 2027.5.0, along with this class (use a plain CallbackManager)
ESPDEPRECATED("Use Microphone::publish_audio_() instead. Removed in 2027.5.0", "2026.11.0")
void call(const std::vector<uint8_t> &data) { this->call(AudioCapture{data, *this->stream_info_, 0}); }
void call(const std::vector<uint8_t> &data) {
if (*this->mute_state_) {
const std::vector<uint8_t> zeros(data.size(), 0);
this->call(AudioCapture{zeros, *this->stream_info_, 0});
} else {
this->call(AudioCapture{data, *this->stream_info_, 0});
}
}
protected:
const audio::AudioStreamInfo *stream_info_;
const bool *mute_state_;
};
class Microphone {
@@ -72,15 +82,7 @@ class Microphone {
/// Callbacks take (const AudioCapture &) and run on the microphone's task.
template<typename F> void add_data_callback(F &&data_callback) {
this->data_callbacks_.add(
[this, callback = as_capture_callback(std::forward<F>(data_callback))](const AudioCapture &capture) {
if (this->mute_state_) {
const std::vector<uint8_t> zeros(capture.data.size(), 0);
callback(AudioCapture{zeros, capture.stream_info, capture.capture_time_us});
} else {
callback(capture);
}
});
this->data_callbacks_.add(as_capture_callback(std::forward<F>(data_callback)));
}
bool is_running() const { return this->state_ == STATE_RUNNING; }
@@ -92,8 +94,11 @@ class Microphone {
audio::AudioStreamInfo get_audio_stream_info() { return this->audio_stream_info_; }
protected:
void publish_audio_(std::span<const uint8_t> data, const audio::AudioStreamInfo &stream_info,
int64_t capture_time_us = 0) {
/// While muted, data is zeroed first, so it must be a buffer the platform owns.
void publish_audio_(std::span<uint8_t> data, const audio::AudioStreamInfo &stream_info, int64_t capture_time_us = 0) {
if (this->mute_state_) {
std::memset(data.data(), 0, data.size());
}
this->data_callbacks_.call(AudioCapture{data, stream_info, capture_time_us});
}
@@ -105,7 +110,7 @@ class Microphone {
audio::AudioStreamInfo audio_stream_info_;
// Make private when the deprecated call(data) is removed in 2027.5.0
DataCallbackManager data_callbacks_{&this->audio_stream_info_};
DataCallbackManager data_callbacks_{&this->audio_stream_info_, &this->mute_state_};
};
} // namespace esphome::microphone
@@ -90,6 +90,7 @@ bool ResamplerMicrophone::init_resampler_(const audio::AudioStreamInfo &input_st
if (input_stream_info.get_sample_rate() == this->target_sample_rate_) {
// The source already delivers the target sample rate, so its audio is passed through unchanged
this->output_buffer_.reserve(input_stream_info.ms_to_bytes(BUFFER_DURATION_MS));
this->input_stream_info_ = input_stream_info;
this->resampler_ready_ = true;
return true;
@@ -137,7 +138,23 @@ void ResamplerMicrophone::process_audio_(const microphone::AudioCapture &capture
}
if (this->resampler_ == nullptr) {
this->publish_audio_(capture.data, input_stream_info, capture.capture_time_us);
if (this->mute_state_) {
// The source's buffer is shared with its other consumers, so publish zeros from our own buffer, in pieces that
// fit its reserved size so muting never allocates
const uint32_t max_frames = input_stream_info.ms_to_frames(BUFFER_DURATION_MS);
const uint32_t total_frames = input_stream_info.bytes_to_frames(capture.data.size());
for (uint32_t done = 0; done < total_frames;) {
const uint32_t frames = std::min(total_frames - done, max_frames);
this->output_buffer_.assign(input_stream_info.frames_to_bytes(frames), 0);
const int64_t time_us =
capture.capture_time_us == 0 ? 0 : capture.capture_time_us + input_stream_info.frames_to_microseconds(done);
this->publish_audio_(this->output_buffer_, input_stream_info, time_us);
done += frames;
}
} else {
// Needs a protected forwarding method once data_callbacks_ is made private in 2027.5.0
this->data_callbacks_.call(microphone::AudioCapture{capture.data, input_stream_info, capture.capture_time_us});
}
return;
}
+162 -3
View File
@@ -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.
+9 -5
View File
@@ -2744,8 +2744,13 @@ def url(value):
raise Invalid("Expected a file scheme or a URL scheme with host")
def git_ref(value):
if re.match(r"[a-zA-Z0-9_./-]+", value) is None:
# Characters git never allows in a branch or tag name (git check-ref-format)
GIT_REF_PATTERN = r"[^\s~^:?*\[\\]+"
def git_ref(value: typing.Any) -> str:
value = string_strict(value)
if re.fullmatch(GIT_REF_PATTERN, value) is None:
raise Invalid("Not a valid git ref")
return value
@@ -3000,9 +3005,8 @@ def validate_source_shorthand(value):
except Invalid:
pass
# Regex for GitHub repo name with optional branch/tag
# Note: git allows other branch/tag names as well, but never seen them used before
m = re.match(
r"github://(?:([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+)(?:@([a-zA-Z0-9\-_.\./]+))?|pr#([0-9]+))",
m = re.fullmatch(
rf"github://(?:([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+)(?:@({GIT_REF_PATTERN}))?|pr#([0-9]+))",
value,
)
if m is None:
+1
View File
@@ -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
+8 -11
View File
@@ -5,7 +5,7 @@ from importlib import import_module
from importlib.util import find_spec
import logging
import re
from typing import Any, Literal, get_args
from typing import Any, Literal
from esphome.automation import apply_static_function, parent_ref
import esphome.codegen as cg
@@ -503,8 +503,7 @@ async def _setup_entity_impl(var: MockObj, config: ConfigType, platform: str) ->
config[_KEY_ICON_IDX] = icon_idx
ApplyPublishMode = Literal["before", "after", "momentary", "on_success"]
ApplyPublish = ApplyPublishMode | None
ApplyPublish = Literal["before", "after", "momentary"] | None
@dataclass(frozen=True, slots=True)
@@ -578,8 +577,7 @@ class SubEntities:
With ``apply`` the entity is the domain's ``Apply*`` class and its control runs
``parent->apply``, or ``apply`` as a namespaced free statement when no parent is bound, with
``publish`` ("before"/"after") placing the domain's publish; ``"on_success"`` publishes only when
``apply`` returns true. ``publish="momentary"`` instead
``publish`` ("before"/"after") placing the domain's publish. ``publish="momentary"`` instead
acknowledges first (a switch publishes off) and runs ``apply`` only when turned on. It is only for
legacy action switches; a new action entity is a button.
"""
@@ -602,8 +600,10 @@ class SubEntities:
)
if publish is not None and (apply is None or self.apply_entity.publish is None):
raise ValueError(f"{entity_id}: publish= needs apply= on a stateful domain")
if publish is not None and publish not in (modes := get_args(ApplyPublishMode)):
raise ValueError(f"{entity_id}: publish= must be one of {', '.join(modes)}")
if publish not in (None, "before", "after", "momentary"):
raise ValueError(
f'{entity_id}: publish= must be "before", "after" or "momentary"'
)
if publish == "momentary" and self.apply_entity.momentary is None:
raise ValueError(f'{entity_id}: publish="momentary" is not supported here')
@@ -626,14 +626,11 @@ class SubEntities:
if isinstance(parent, ID):
parent = await get_variable(parent)
apply_entity = self.apply_entity
expr = apply if parent is None else f"{parent_ref(parent)}->{apply}"
call = f"{expr};"
call = f"{apply};" if parent is None else f"{parent_ref(parent)}->{apply};"
if publish == "before":
body = [apply_entity.publish, call]
elif publish == "after":
body = [call, apply_entity.publish]
elif publish == "on_success":
body = [f"if ({expr}) {{ {apply_entity.publish} }}"]
elif publish == "momentary":
acknowledge, condition = apply_entity.momentary
body = [acknowledge, f"if ({condition}) {{ {call} }}"]
+2 -2
View File
@@ -1051,8 +1051,8 @@ class GitFile:
"""Parse a git shorthand URL into its components."""
if not isinstance(shorthand, str):
raise ValueError("Git shorthand must be a string")
m = re.match(
r"(?P<domain>[a-zA-Z0-9\-]+)://(?P<owner>[a-zA-Z0-9\-]+)/(?P<repo>[a-zA-Z0-9\-\._]+)/(?P<filename>[a-zA-Z0-9\-_.\./]+)(?:@(?P<ref>[a-zA-Z0-9\-_.\./]+))?(?:\?(?P<query>[a-zA-Z0-9\-_.\./]+))?",
m = re.fullmatch(
rf"(?P<domain>[a-zA-Z0-9\-]+)://(?P<owner>[a-zA-Z0-9\-]+)/(?P<repo>[a-zA-Z0-9\-\._]+)/(?P<filename>[a-zA-Z0-9\-_.\./]+)(?:@(?P<ref>{cv.GIT_REF_PATTERN}))?(?:\?(?P<query>[a-zA-Z0-9\-_.\./]+))?",
shorthand,
)
if m is None:
+1 -1
View File
@@ -2,7 +2,7 @@ dependencies:
bblanchon/arduinojson:
version: "7.4.3"
esphome/dlms_parser:
version: 2.2.0
version: 2.2.2
esphome/esp-audio-libs:
version: 4.1.0
esphome/esp-micro-speech-features:
+7 -2
View File
@@ -284,12 +284,17 @@ def _lookup_module(domain: str, exception: bool) -> ComponentManifest | None:
_COMPONENT_CACHE[domain] = manif
return manif
module_name = f"esphome.components.{domain}"
try:
module = importlib.import_module(f"esphome.components.{domain}")
module = importlib.import_module(module_name)
except ImportError as e:
if exception:
raise
if "No module named" in str(e):
if (
isinstance(e, ModuleNotFoundError)
and e.name is not None
and f"{module_name}.".startswith(f"{e.name}.")
):
_LOGGER.info(
"Unable to import component %s: %s", domain, str(e), exc_info=False
)
+1 -1
View File
@@ -106,7 +106,7 @@ build_unflags =
; esp32 envs (which would double-include them).
[common:idf-component-libs]
lib_deps =
esphome/dlms_parser@2.2.0 ; dlms_meter
esphome/dlms_parser@2.2.2 ; dlms_meter
bblanchon/ArduinoJson@7.4.3 ; json
lvgl/lvgl@9.6.0 ; lvgl
@@ -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
-14
View File
@@ -1,14 +0,0 @@
import esphome.codegen as cg
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# One empty config entry so to_code runs and compiles the PG/GPO switch path.
manifest.multi_conf = False
manifest.dependencies = manifest.dependencies + ["i2c", "switch"]
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_BQ25186_PG_GPO")
manifest.to_code = to_code
@@ -1,143 +0,0 @@
#include <gtest/gtest.h>
#include <array>
#include "esphome/components/bq25186/bq25186.h"
#include "esphome/components/switch/switch.h"
namespace esphome::bq25186::testing {
static constexpr uint8_t ADDRESS = 0x6A;
static constexpr uint8_t SYS_REG_OTHER_BITS = 0x0F;
// The chip's registers behind an address pointer that moves on after every byte; writes can be made to fail.
class RegisterBus final : public i2c::I2CBus {
public:
i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out,
size_t out_size) override {
EXPECT_EQ(address, ADDRESS);
if (size > 1 && this->fail_writes)
return i2c::ERROR_NOT_ACKNOWLEDGED;
if (size != 0) {
this->pointer_ = data[0];
for (size_t i = 1; i < size; i++)
this->registers[this->next_()] = data[i];
}
for (size_t i = 0; i < out_size; i++)
out[i] = this->registers[this->next_()];
return i2c::ERROR_OK;
}
std::array<uint8_t, BQ25186_STATUS_REGISTER_COUNT> registers{};
bool fail_writes{false};
protected:
size_t next_() { return this->pointer_++ % this->registers.size(); }
uint8_t pointer_{0};
};
// The body code generation emits for apply= with publish="on_success"
static BQ25186Component *hub_under_test = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static void apply_pg_gpo(switch_::Switch *entity, bool state) {
if (hub_under_test->write_pg_gpo_level(state))
entity->publish_state(state);
}
class BQ25186PgGpoTest : public ::testing::Test {
protected:
void SetUp() override {
this->bus_.registers[BQ25186_REG_MASK_ID] = 0x01; // the BQ25186 device id
this->bus_.registers[BQ25186_REG_SYS_REG] = SYS_REG_OTHER_BITS;
this->hub_.set_i2c_bus(&this->bus_);
this->hub_.set_i2c_address(ADDRESS);
hub_under_test = &this->hub_;
this->switch_.set_restore_mode(switch_::SWITCH_RESTORE_DISABLED);
this->hub_.set_pg_gpo_switch(&this->switch_);
}
bool level_() const { return (this->bus_.registers[BQ25186_REG_SYS_REG] & BQ25186_SYS_REG_PG_GPO_LEVEL) != 0; }
uint8_t other_bits_() const { return this->bus_.registers[BQ25186_REG_SYS_REG] & ~BQ25186_SYS_REG_PG_GPO_LEVEL; }
RegisterBus bus_;
BQ25186Component hub_;
switch_::ApplySwitch<apply_pg_gpo> switch_;
};
TEST_F(BQ25186PgGpoTest, TurnOnSetsTheLevelAndPublishes) {
this->switch_.turn_on();
EXPECT_TRUE(this->level_());
EXPECT_EQ(this->other_bits_(), SYS_REG_OTHER_BITS);
EXPECT_TRUE(this->switch_.has_state());
EXPECT_TRUE(this->switch_.state);
}
TEST_F(BQ25186PgGpoTest, TurnOffClearsTheLevelAndPublishes) {
this->bus_.registers[BQ25186_REG_SYS_REG] |= BQ25186_SYS_REG_PG_GPO_LEVEL;
this->switch_.publish_state(true);
this->switch_.turn_off();
EXPECT_FALSE(this->level_());
EXPECT_EQ(this->other_bits_(), SYS_REG_OTHER_BITS);
EXPECT_FALSE(this->switch_.state);
}
TEST_F(BQ25186PgGpoTest, FailedWriteLeavesTheStateUnpublished) {
this->bus_.fail_writes = true;
this->switch_.turn_on();
EXPECT_FALSE(this->level_());
EXPECT_FALSE(this->switch_.has_state());
}
TEST_F(BQ25186PgGpoTest, FailedWriteKeepsThePreviousState) {
// Start from on, so a reset to the default off state would show
this->bus_.registers[BQ25186_REG_SYS_REG] |= BQ25186_SYS_REG_PG_GPO_LEVEL;
this->switch_.publish_state(true);
this->bus_.fail_writes = true;
this->switch_.turn_off();
EXPECT_TRUE(this->level_());
EXPECT_TRUE(this->switch_.state);
}
TEST_F(BQ25186PgGpoTest, InvertedSwitchDrivesTheOppositeLevel) {
this->switch_.set_inverted(true);
this->switch_.turn_on();
EXPECT_FALSE(this->level_());
EXPECT_TRUE(this->switch_.state);
this->switch_.turn_off();
EXPECT_TRUE(this->level_());
EXPECT_FALSE(this->switch_.state);
}
TEST_F(BQ25186PgGpoTest, SetupWritesTheRestoredLevel) {
this->switch_.set_restore_mode(switch_::SWITCH_ALWAYS_ON);
this->hub_.setup();
EXPECT_TRUE(this->level_());
EXPECT_NE(this->bus_.registers[BQ25186_REG_VBAT_CTRL] & BQ25186_VBAT_CTRL_PG_MODE, 0);
EXPECT_TRUE(this->switch_.state);
}
TEST_F(BQ25186PgGpoTest, SetupWritesTheRestoredLevelInverted) {
this->bus_.registers[BQ25186_REG_SYS_REG] |= BQ25186_SYS_REG_PG_GPO_LEVEL;
this->switch_.set_restore_mode(switch_::SWITCH_ALWAYS_ON);
this->switch_.set_inverted(true);
this->hub_.setup();
EXPECT_FALSE(this->level_());
EXPECT_TRUE(this->switch_.state);
}
TEST_F(BQ25186PgGpoTest, SetupWithoutRestoreTakesTheChipLevel) {
this->bus_.registers[BQ25186_REG_SYS_REG] |= BQ25186_SYS_REG_PG_GPO_LEVEL;
this->hub_.setup();
EXPECT_TRUE(this->level_());
EXPECT_TRUE(this->switch_.state);
}
TEST_F(BQ25186PgGpoTest, SetupWithoutRestoreTakesTheChipLevelInverted) {
this->bus_.registers[BQ25186_REG_SYS_REG] |= BQ25186_SYS_REG_PG_GPO_LEVEL;
this->switch_.set_inverted(true);
this->hub_.setup();
EXPECT_TRUE(this->level_());
EXPECT_FALSE(this->switch_.state);
}
} // namespace esphome::bq25186::testing
@@ -17,7 +17,7 @@ class TestMicrophone : public Microphone {
void start() override {}
void stop() override {}
void publish(std::span<const uint8_t> data, const audio::AudioStreamInfo &stream_info, int64_t capture_time_us) {
void publish(std::span<uint8_t> data, const audio::AudioStreamInfo &stream_info, int64_t capture_time_us) {
this->publish_audio_(data, stream_info, capture_time_us);
}
bool has_callbacks() const { return this->has_data_callbacks_(); }
@@ -56,20 +56,24 @@ TEST(MicrophoneDataCallbacks, CallbackGetsDataFormatAndCaptureTime) {
});
EXPECT_TRUE(mic.has_callbacks());
mic.publish(SAMPLES, STEREO_48K, 123456);
std::vector<uint8_t> buffer = SAMPLES;
mic.publish(buffer, STEREO_48K, 123456);
EXPECT_EQ(data, SAMPLES);
EXPECT_EQ(stream_info, STEREO_48K);
EXPECT_EQ(capture_time_us, 123456);
}
TEST(MicrophoneDataCallbacks, MutedCallbackGetsZeros) {
TEST(MicrophoneDataCallbacks, MuteZeroesTheBufferInPlace) {
TestMicrophone mic;
std::vector<uint8_t> data;
mic.add_data_callback([&](const AudioCapture &capture) { data.assign(capture.data.begin(), capture.data.end()); });
mic.set_mute_state(true);
mic.publish(SAMPLES, STEREO_48K, 0);
EXPECT_EQ(data, std::vector<uint8_t>(SAMPLES.size(), 0));
std::vector<uint8_t> buffer = SAMPLES;
mic.publish(buffer, STEREO_48K, 0);
const std::vector<uint8_t> zeros(SAMPLES.size(), 0);
EXPECT_EQ(data, zeros);
EXPECT_EQ(buffer, zeros);
}
TEST(MicrophoneDataCallbacks, DataTriggerPassesACopy) {
@@ -115,4 +119,17 @@ TEST(MicrophoneDataCallbacks, DeprecatedCallUsesMicrophoneFormat) {
EXPECT_EQ(capture_time_us, 0);
}
// Remove in 2027.5.0 with the deprecated DataCallbackManager::call(data)
TEST(MicrophoneDataCallbacks, DeprecatedCallDeliversZerosWhileMuted) {
TestMicrophone mic;
std::vector<uint8_t> data;
mic.add_data_callback([&](const AudioCapture &capture) { data.assign(capture.data.begin(), capture.data.end()); });
mic.set_mute_state(true);
const std::vector<uint8_t> buffer = SAMPLES;
mic.publish_deprecated(buffer);
EXPECT_EQ(data, std::vector<uint8_t>(SAMPLES.size(), 0));
EXPECT_EQ(buffer, SAMPLES);
}
} // namespace esphome::microphone::testing
@@ -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
@@ -0,0 +1,96 @@
esphome:
name: uart-mock-ld2420-select-mode
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2420's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
id: mock_uart
baud_rate: 115200
auto_start: false
responses:
# Firmware v2.0.0 (1.5.4 or newer), so setup picks energy reporting
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x0C, 0x00,
0x00, 0x01,
0x00, 0x00,
0x06, 0x00,
0x76, 0x32, 0x2E, 0x30, 0x2E, 0x30,
0x04, 0x03, 0x02, 0x01,
]
# Generic ACK for every other command
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFF, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
injections:
# Sent after the select switched the module to Simple: "ON Range 0035\r\n" is only
# parsed while the component expects simple frames.
- delay: 100ms
inject_rx:
[
0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20,
0x30, 0x30, 0x33, 0x35,
0x0D, 0x0A,
]
button:
- platform: template
name: "Start Scenario"
on_press:
- lambda: 'id(mock_uart).start_scenario();'
# A changed timeout gets apply_config_action() past its no-change check
- platform: template
name: "Apply Changed Config"
on_press:
- lambda: |-
id(ld2420_dev).new_config.timeout++;
id(ld2420_dev).apply_config_action();
- platform: template
name: "Inject Simple Frame"
on_press:
- lambda: |-
// "ON Range 0040\r\n"
id(mock_uart).inject_to_rx_buffer(std::vector<uint8_t>{
0x4F, 0x4E, 0x20, 0x52, 0x61, 0x6E, 0x67, 0x65, 0x20, 0x30, 0x30, 0x34, 0x30, 0x0D, 0x0A});
ld2420:
id: ld2420_dev
uart_id: mock_uart
sensor:
- platform: ld2420
ld2420_id: ld2420_dev
moving_distance:
name: "Moving Distance"
select:
- platform: ld2420
ld2420_id: ld2420_dev
operating_mode:
name: "Operating Mode"
@@ -0,0 +1,73 @@
esphome:
name: uart-mock-ld2420-mode-nack
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy ld2420's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
id: mock_uart
baud_rate: 115200
responses:
# Firmware v2.0.0 (1.5.4 or newer), so setup picks energy reporting
- expect_tx:
[0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x0C, 0x00,
0x00, 0x01,
0x00, 0x00,
0x06, 0x00,
0x76, 0x32, 0x2E, 0x30, 0x2E, 0x30,
0x04, 0x03, 0x02, 0x01,
]
# The simple system mode write is refused (error word 1)
- expect_tx:
[
0xFD, 0xFC, 0xFB, 0xFA, 0x08, 0x00, 0x12, 0x00,
0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01,
]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0x12, 0x01,
0x01, 0x00,
0x04, 0x03, 0x02, 0x01,
]
# Generic ACK for every other command
- expect_tx: [0x04, 0x03, 0x02, 0x01]
inject_rx:
[
0xFD, 0xFC, 0xFB, 0xFA,
0x04, 0x00,
0xFF, 0x01,
0x00, 0x00,
0x04, 0x03, 0x02, 0x01,
]
ld2420:
id: ld2420_dev
uart_id: mock_uart
select:
- platform: ld2420
ld2420_id: ld2420_dev
operating_mode:
name: "Operating Mode"
@@ -0,0 +1,128 @@
"""Integration test for the LD2420 operating mode select with a mock UART.
The module runs firmware v2.0.0, so it starts in energy reporting. Choosing Simple
must write the simple system mode to the module and parse simple frames afterwards;
choosing Normal again must write the energy system mode back.
"""
from __future__ import annotations
import asyncio
from aioesphomeapi import ButtonInfo, EntityState, SelectInfo, SelectState
import pytest
from .log_utils import LineWaiter
from .state_utils import (
SensorStateCollector,
require_entity,
subscribe_after_initial_states,
)
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
# CMD_WRITE_SYS_PARAM (0x0012), parameter 0x0000 (system mode), then the mode and 0x0000
WRITE_SIMPLE = "FD:FC:FB:FA:08:00:12:00:00:00:64:00:00:00:04:03:02:01"
WRITE_ENERGY = "FD:FC:FB:FA:08:00:12:00:00:00:04:00:00:00:04:03:02:01"
@pytest.mark.asyncio
async def test_uart_mock_ld2420_select_mode(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""The select switches the module between energy and simple reporting."""
waiter = LineWaiter()
collector = SensorStateCollector(sensor_names=["moving_distance"])
simple_frame_parsed = collector.add_waiter(
lambda: pytest.approx(35.0) in collector.sensor_states["moving_distance"]
)
second_frame_parsed = collector.add_waiter(
lambda: pytest.approx(40.0) in collector.sensor_states["moving_distance"]
)
modes: list[str] = []
back_to_normal = asyncio.Event()
def on_state(state: EntityState) -> None:
collector.on_state(state)
if isinstance(state, SelectState):
modes.append(state.state)
if modes[-2:] == ["Calibrate", "Normal"]:
back_to_normal.set()
async with (
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
collector.build_key_mapping(entities)
await subscribe_after_initial_states(client, entities, on_state=on_state)
mode = require_entity(entities, "operating_mode", SelectInfo)
apply_changed = require_entity(entities, "apply_changed_config", ButtonInfo)
# Setup wrote energy mode for this firmware
await waiter.wait_for("TX", WRITE_ENERGY)
writes_before = waiter.count(WRITE_ENERGY)
client.select_command(mode.key, "Simple")
await waiter.wait_for("TX", WRITE_SIMPLE)
client.button_command(
require_entity(entities, "start_scenario", ButtonInfo).key
)
await wait_or_fail(simple_frame_parsed, 3.0, "Simple frame was not parsed")
# Applying a changed config in Simple keeps Simple
client.button_command(apply_changed.key)
await waiter.wait_for("Reconfiguring")
inject = require_entity(entities, "inject_simple_frame", ButtonInfo)
async def inject_until_parsed() -> None:
# Simple reports are rate limited, so keep sending until one is published
while not second_frame_parsed.done():
client.button_command(inject.key)
await asyncio.sleep(0.2)
await wait_or_fail(inject_until_parsed(), 5.0, "Simple stopped after apply")
assert modes[-1] == "Simple"
client.select_command(mode.key, "Normal")
await waiter.wait_count(
WRITE_ENERGY, writes_before + 1, 3.0, "Normal did not write energy mode"
)
assert waiter.count(WRITE_ENERGY) == writes_before + 1
# Applying a changed config ends calibration
client.select_command(mode.key, "Calibrate")
client.button_command(apply_changed.key)
await wait_or_fail(back_to_normal, 5.0, "Apply did not end calibration")
@pytest.mark.asyncio
async def test_uart_mock_ld2420_select_mode_nack(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A refused Simple write puts the select back on the mode the module reports."""
waiter = LineWaiter()
states: list[str] = []
back_to_normal = asyncio.Event()
def on_state(state: EntityState) -> None:
if isinstance(state, SelectState):
states.append(state.state)
if states[-2:] == ["Simple", "Normal"]:
back_to_normal.set()
async with (
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
await subscribe_after_initial_states(client, entities, on_state=on_state)
mode = require_entity(entities, "operating_mode", SelectInfo)
client.select_command(mode.key, "Simple")
# Three tries of about a second each before the write is given up
await wait_or_fail(back_to_normal, 6.0, "Select did not go back to Normal")
assert waiter.count(WRITE_SIMPLE) == 3
@@ -0,0 +1,28 @@
"""Tests for the esp32 framework ``components:`` schema."""
import pytest
from esphome.components import esp32
import esphome.config_validation as cv
@pytest.mark.parametrize(
"source",
[
"https://github.com/owner/component.git",
"git@github.com:owner/component.git",
],
)
def test_component_source_accepts_repository_url(source: str) -> None:
config = esp32.FRAMEWORK_SCHEMA(
{"components": [{"name": "owner/component", "source": source}]}
)
assert config["components"][0]["source"] == source
@pytest.mark.parametrize("source", [1.0, ""])
def test_component_source_rejects_invalid(source: object) -> None:
with pytest.raises(cv.Invalid):
esp32.FRAMEWORK_SCHEMA(
{"components": [{"name": "owner/component", "source": source}]}
)
@@ -1711,14 +1711,6 @@ _SELECT_APPLY = entity_helpers.ApplyEntity(
"after",
"::hub->set_mode(index, options_);\nentity->publish_state(index);",
),
(
"set_mode(option)",
"on_success",
(
"const char *option = entity->option_at(index);\n"
"if (::hub->set_mode(option)) { entity->publish_state(index); }"
),
),
],
)
async def test_sub_entities_apply_locals(
@@ -1891,35 +1883,6 @@ async def test_sub_entities_apply_momentary(
assert apply_functions[0][2] == body
@pytest.mark.asyncio
@pytest.mark.parametrize(
("parent", "apply", "body"),
[
(
_HUB,
"write_level(state)",
"if (::hub->write_level(state)) { entity->publish_state(state); }",
),
(
None,
"chip::write_level(state)",
"if (chip::write_level(state)) { entity->publish_state(state); }",
),
],
)
async def test_sub_entities_apply_on_success(
setup_test_environment: list[str],
apply_functions: list[tuple[str, str, str]],
parent: MockObj | None,
apply: str,
body: str,
) -> None:
"""publish="on_success" publishes only when the hub call returns true."""
sub = _apply_sub([], {"k": _apply_conf("k_sw")}, parent=parent)
await sub("k", None, apply=apply, publish="on_success")
assert apply_functions[0][2] == body
@pytest.mark.asyncio
async def test_sub_entities_apply_without_parent_needs_namespaced_call(
setup_test_environment: list[str],
+42 -2
View File
@@ -3121,9 +3121,21 @@ def test_git_ref_valid() -> None:
assert cv.git_ref("v1.2.3") == "v1.2.3"
def test_git_ref_invalid() -> None:
@pytest.mark.parametrize("ref", ["main", "release/v6.0", "feat+x", "a1b2c3d4"])
def test_git_ref_valid_chars(ref: str) -> None:
assert cv.git_ref(ref) == ref
@pytest.mark.parametrize("ref", ["main hi there", "a:b", "x~1", "", "main\n"])
def test_git_ref_invalid(ref: str) -> None:
with pytest.raises(Invalid, match="Not a valid git ref"):
cv.git_ref("!!!")
cv.git_ref(ref)
@pytest.mark.parametrize("ref", [1.0, 2025, None])
def test_git_ref_not_string(ref: object) -> None:
with pytest.raises(Invalid, match="Must be string"):
cv.git_ref(ref)
def test_source_refresh_always() -> None:
@@ -3291,6 +3303,34 @@ def test_validate_source_shorthand_github_pr(setup_core: Path) -> None:
assert result[CONF_REF] == "pull/1234/head"
def test_validate_source_shorthand_github_ref_with_slash(setup_core: Path) -> None:
result = cv.validate_source_shorthand("github://user/repo@release/v1")
assert result[CONF_REF] == "release/v1"
def test_validate_source_shorthand_github_ref_keeps_all_chars(
setup_core: Path,
) -> None:
result = cv.validate_source_shorthand("github://user/repo@feat+x")
assert result[CONF_REF] == "feat+x"
@pytest.mark.parametrize(
"value",
[
"github://esphome/esphome/esphome/components@dev",
"github://user/repo/",
"github://user/repo@main hi",
"github://pr#1234abc",
],
)
def test_validate_source_shorthand_rejects_trailing_text(
setup_core: Path, value: str
) -> None:
with pytest.raises(Invalid, match="not a file system path"):
cv.validate_source_shorthand(value)
def test_validate_source_shorthand_invalid(setup_core: Path) -> None:
with pytest.raises(Invalid, match="not a file system path"):
cv.validate_source_shorthand("notvalid")
+20
View File
@@ -3332,6 +3332,26 @@ def test_git_file_from_shorthand_without_ref_or_query() -> None:
assert gf == git.GitFile("codeberg.org", "owner", "repo", "file.yaml")
def test_git_file_from_shorthand_keeps_whole_ref() -> None:
gf = git.GitFile.from_shorthand("github://owner/repo/file.yaml@feat+x?full_config")
assert gf.ref == "feat+x"
assert gf.query == "full_config"
@pytest.mark.parametrize(
"shorthand",
[
"github://owner/repo/file.yaml@main hi",
"github://owner/repo/my file.yaml",
"github://owner/repo/file.yaml?full config",
],
)
def test_git_file_from_shorthand_rejects_trailing_text(shorthand: str) -> None:
with pytest.raises(ValueError, match="not in expected"):
git.GitFile.from_shorthand(shorthand)
def test_git_file_from_shorthand_requires_string() -> None:
with pytest.raises(ValueError, match="must be a string"):
git.GitFile.from_shorthand(123)
+39
View File
@@ -26,6 +26,7 @@ from esphome.loader import (
clear_component_meta_finders,
get_alias_metadata,
get_component,
get_platform,
install_meta_finder,
)
from tests.testing_helpers import ComponentManifestOverride
@@ -809,6 +810,44 @@ def test_lookup_module_import_error_missing_module_returns_none(
assert "definitely_missing_component_xyz" not in _COMPONENT_CACHE
def test_lookup_module_missing_platform_returns_none(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A platform file that does not exist is "not found", not an error."""
with caplog.at_level(logging.INFO, logger="esphome.loader"):
assert get_platform("definitely_missing_platform_xyz", "template") is None
assert "Unable to import component template.definitely_missing_platform_xyz" in (
caplog.text
)
assert not [r for r in caplog.records if r.levelno >= logging.ERROR]
def test_lookup_module_missing_dependency_is_logged_as_error(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A component that exists but imports a missing package reports that
package as an error, instead of looking like a missing component."""
comp = tmp_path / "depfailcomp"
comp.mkdir()
(comp / "__init__.py").write_text("import definitely_missing_package_xyz\n")
saved = list(sys.meta_path)
try:
install_meta_finder(tmp_path)
with caplog.at_level(logging.INFO, logger="esphome.loader"):
assert get_component("depfailcomp") is None
finally:
sys.meta_path[:] = saved
for name in [
m for m in sys.modules if m.startswith("esphome.components.depfailcomp")
]:
del sys.modules[name]
errors = [r for r in caplog.records if r.levelno >= logging.ERROR]
assert len(errors) == 1
assert errors[0].getMessage() == "Unable to import component depfailcomp:"
assert errors[0].exc_info is not None
assert errors[0].exc_info[1].name == "definitely_missing_package_xyz"
def test_lookup_module_missing_module_raises_when_requested() -> None:
"""With ``exception=True`` the ImportError propagates."""
with pytest.raises(ImportError):