Compare commits

..
26 changed files with 428 additions and 879 deletions
@@ -58,9 +58,6 @@ esp_err_t AudioReader::add_sink(const std::weak_ptr<ring_buffer::RingBuffer> &ou
if (current_audio_file_ != nullptr) { if (current_audio_file_ != nullptr) {
// A transfer buffer isn't ncessary for a local file // A transfer buffer isn't ncessary for a local file
this->file_ring_buffer_ = output_ring_buffer.lock(); this->file_ring_buffer_ = output_ring_buffer.lock();
if (this->file_ring_buffer_ == nullptr) {
return ESP_ERR_INVALID_STATE;
}
return ESP_OK; return ESP_OK;
} }
@@ -51,14 +51,14 @@ void AudioTransferBuffer::increase_buffer_length(size_t bytes) { this->buffer_le
void AudioTransferBuffer::clear_buffered_data() { void AudioTransferBuffer::clear_buffered_data() {
this->buffer_length_ = 0; this->buffer_length_ = 0;
if (this->ring_buffer_ != nullptr) { if (this->ring_buffer_.use_count() > 0) {
this->ring_buffer_->reset(); this->ring_buffer_->reset();
} }
} }
void AudioSinkTransferBuffer::clear_buffered_data() { void AudioSinkTransferBuffer::clear_buffered_data() {
this->buffer_length_ = 0; this->buffer_length_ = 0;
if (this->ring_buffer_ != nullptr) { if (this->ring_buffer_.use_count() > 0) {
this->ring_buffer_->reset(); this->ring_buffer_->reset();
} }
#ifdef USE_SPEAKER #ifdef USE_SPEAKER
@@ -69,7 +69,7 @@ void AudioSinkTransferBuffer::clear_buffered_data() {
} }
bool AudioTransferBuffer::has_buffered_data() const { bool AudioTransferBuffer::has_buffered_data() const {
if (this->ring_buffer_ != nullptr) { if (this->ring_buffer_.use_count() > 0) {
return ((this->ring_buffer_->available() > 0) || (this->available() > 0)); return ((this->ring_buffer_->available() > 0) || (this->available() > 0));
} }
return (this->available() > 0); return (this->available() > 0);
@@ -144,7 +144,7 @@ size_t AudioSourceTransferBuffer::transfer_data_from_source(TickType_t ticks_to_
size_t bytes_to_read = AudioTransferBuffer::free(); size_t bytes_to_read = AudioTransferBuffer::free();
size_t bytes_read = 0; size_t bytes_read = 0;
if (bytes_to_read > 0) { if (bytes_to_read > 0) {
if (this->ring_buffer_ != nullptr) { if (this->ring_buffer_.use_count() > 0) {
bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait); bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait);
} }
@@ -161,7 +161,7 @@ size_t AudioSinkTransferBuffer::transfer_data_to_sink(TickType_t ticks_to_wait,
bytes_written = this->speaker_->play(this->data_start_, this->available(), ticks_to_wait); bytes_written = this->speaker_->play(this->data_start_, this->available(), ticks_to_wait);
} else } else
#endif #endif
if (this->ring_buffer_ != nullptr) { if (this->ring_buffer_.use_count() > 0) {
bytes_written = bytes_written =
this->ring_buffer_->write_without_replacement((void *) this->data_start_, this->available(), ticks_to_wait); this->ring_buffer_->write_without_replacement((void *) this->data_start_, this->available(), ticks_to_wait);
} else if (this->sink_callback_ != nullptr) { } else if (this->sink_callback_ != nullptr) {
@@ -186,7 +186,7 @@ bool AudioSinkTransferBuffer::has_buffered_data() const {
return (this->speaker_->has_buffered_data() || (this->available() > 0)); return (this->speaker_->has_buffered_data() || (this->available() > 0));
} }
#endif #endif
if (this->ring_buffer_ != nullptr) { if (this->ring_buffer_.use_count() > 0) {
return ((this->ring_buffer_->available() > 0) || (this->available() > 0)); return ((this->ring_buffer_->available() > 0) || (this->available() > 0));
} }
return (this->available() > 0); return (this->available() > 0);
@@ -41,10 +41,7 @@ const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const {
#endif #endif
static constexpr uint16_t OTA_BLOCK_SIZE = 8192; static constexpr uint16_t OTA_BLOCK_SIZE = 8192;
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake
// Milliseconds for data transfer. Covers the lwIP retransmit run seen in static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer
// practice for a lost chunk ack (1.5 + 3 + 6 + 12 + 24 + 48 s); the CLI waits
// longer (espota2.DATA_PHASE_TIMEOUT) so the device is free before it retries
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 105000;
// Single-instance pointer — multi-port configs are rejected in final_validate. // Single-instance pointer — multi-port configs are rejected in final_validate.
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
@@ -118,24 +118,21 @@ void I2SAudioSpeakerBase::loop() {
break; break;
} }
// Still starting up or winding down from a previous run
if ((this->tx_handle_ != nullptr) || (this->speaker_task_handle_ != nullptr)) {
break;
}
if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) { if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) {
ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second"); ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second");
this->status_momentary_error("driver-failure", 1000); this->status_momentary_error("driver-failure", 1000);
break; break;
} }
xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
&this->speaker_task_handle_);
if (this->speaker_task_handle_ == nullptr) { if (this->speaker_task_handle_ == nullptr) {
ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
this->status_momentary_error("task-failure", 1000); &this->speaker_task_handle_);
this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt
if (this->speaker_task_handle_ == nullptr) {
ESP_LOGE(TAG, "Task failed to start, retrying in 1 second");
this->status_momentary_error("task-failure", 1000);
this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt
}
} }
break; break;
case speaker::STATE_RUNNING: // Intentional fallthrough case speaker::STATE_RUNNING: // Intentional fallthrough
@@ -221,8 +218,8 @@ size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t
} }
bool I2SAudioSpeakerBase::has_buffered_data() const { bool I2SAudioSpeakerBase::has_buffered_data() const {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->audio_ring_buffer_.lock(); if (this->audio_ring_buffer_.use_count() > 0) {
if (temp_ring_buffer != nullptr) { std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->audio_ring_buffer_.lock();
return temp_ring_buffer->available() > 0; return temp_ring_buffer->available() > 0;
} }
return false; return false;
@@ -129,7 +129,7 @@ void MicroWakeWord::setup() {
return; return;
} }
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock(); std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (temp_ring_buffer != nullptr) { if (this->ring_buffer_.use_count() > 1) {
// Producer-only write: never touches consumer state. If the buffer is full, ask the inference task // Producer-only write: never touches consumer state. If the buffer is full, ask the inference task
// to drain it - reset() is a consumer operation and must run on the inference task's thread. // to drain it - reset() is a consumer operation and must run on the inference task's thread.
// Disable partial writes so audio chunks are either fully accepted or rejected and handled below. // Disable partial writes so audio chunks are either fully accepted or rejected and handled below.
@@ -446,9 +446,9 @@ void MicroWakeWord::loop() {
xEventGroupClearBits(this->event_group_, EventGroupBits::TASK_STOPPING); xEventGroupClearBits(this->event_group_, EventGroupBits::TASK_STOPPING);
} }
// Retries on a subsequent loop if the task is still running on the other core if ((event_group_bits & EventGroupBits::TASK_STOPPED)) {
if ((event_group_bits & EventGroupBits::TASK_STOPPED) && this->inference_task_.deallocate()) {
ESP_LOGD(TAG, "Inference task is finished, freeing task resources"); ESP_LOGD(TAG, "Inference task is finished, freeing task resources");
this->inference_task_.deallocate();
xEventGroupClearBits(this->event_group_, ALL_BITS); xEventGroupClearBits(this->event_group_, ALL_BITS);
xQueueReset(this->detection_queue_); xQueueReset(this->detection_queue_);
this->set_state_(State::STOPPED); this->set_state_(State::STOPPED);
@@ -48,7 +48,7 @@ class MicrophoneSource final {
template<typename F> void add_data_callback(F &&data_callback) { template<typename F> void add_data_callback(F &&data_callback) {
this->mic_->add_data_callback([this, data_callback](const std::vector<uint8_t> &data) { this->mic_->add_data_callback([this, data_callback](const std::vector<uint8_t> &data) {
if (this->enabled_ || this->passive_) { if (this->enabled_ || this->passive_) {
if (this->processed_samples_ == nullptr) { if (this->processed_samples_.use_count() == 0) {
// Create vector if its unused // Create vector if its unused
this->processed_samples_ = std::make_shared<std::vector<uint8_t>>(); this->processed_samples_ = std::make_shared<std::vector<uint8_t>>();
} }
@@ -218,7 +218,7 @@ size_t SourceSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_
} }
size_t bytes_written = 0; size_t bytes_written = 0;
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock(); std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (temp_ring_buffer != nullptr) { if (temp_ring_buffer.use_count() > 0) {
// Only write to the ring buffer if the reference is valid // Only write to the ring buffer if the reference is valid
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait); bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
if (bytes_written > 0) { if (bytes_written > 0) {
@@ -250,14 +250,14 @@ esp_err_t SourceSpeaker::start_() {
// avoids unnecessary single-frame splices. // avoids unnecessary single-frame splices.
const size_t ring_buffer_size = const size_t ring_buffer_size =
(this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame; (this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame;
if (this->audio_source_ == nullptr) { if (this->audio_source_.use_count() == 0) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock(); std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (temp_ring_buffer == nullptr) { if (!temp_ring_buffer) {
temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size); temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
this->ring_buffer_ = temp_ring_buffer; this->ring_buffer_ = temp_ring_buffer;
} }
if (temp_ring_buffer == nullptr) { if (!temp_ring_buffer) {
return ESP_ERR_NO_MEM; return ESP_ERR_NO_MEM;
} }
@@ -278,7 +278,7 @@ void SourceSpeaker::stop() { this->send_command_(SOURCE_SPEAKER_COMMAND_STOP); }
void SourceSpeaker::finish() { this->send_command_(SOURCE_SPEAKER_COMMAND_FINISH); } void SourceSpeaker::finish() { this->send_command_(SOURCE_SPEAKER_COMMAND_FINISH); }
bool SourceSpeaker::has_buffered_data() const { bool SourceSpeaker::has_buffered_data() const {
return ((this->audio_source_ != nullptr) && this->audio_source_->has_buffered_data()); return ((this->audio_source_.use_count() > 0) && this->audio_source_->has_buffered_data());
} }
void SourceSpeaker::set_mute_state(bool mute_state) { void SourceSpeaker::set_mute_state(bool mute_state) {
@@ -382,8 +382,8 @@ void MixerSpeaker::loop() {
ESP_LOGV(TAG, "Stopping"); ESP_LOGV(TAG, "Stopping");
xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_STOPPING); xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_STOPPING);
} }
// Retries on a subsequent loop if the task is still running on the other core if (event_group_bits & MIXER_TASK_STATE_STOPPED) {
if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) { this->task_.deallocate();
ESP_LOGD(TAG, "Stopped"); ESP_LOGD(TAG, "Stopped");
xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS); xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS);
this->all_stopped_since_ms_ = 0; this->all_stopped_since_ms_ = 0;
@@ -496,7 +496,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
if (speaker->is_running() && !speaker->get_pause_state()) { if (speaker->is_running() && !speaker->get_pause_state()) {
// Speaker is running and not paused, so it possibly can provide audio data // Speaker is running and not paused, so it possibly can provide audio data
std::shared_ptr<audio::RingBufferAudioSource> audio_source = speaker->get_audio_source().lock(); std::shared_ptr<audio::RingBufferAudioSource> audio_source = speaker->get_audio_source().lock();
if (audio_source == nullptr) { if (audio_source.use_count() == 0) {
// No audio source allocated, so skip processing this speaker // No audio source allocated, so skip processing this speaker
continue; continue;
} }
+2 -2
View File
@@ -88,12 +88,12 @@ def encryption_schema(config: ConfigType | None) -> ConfigType:
async def to_code(config: ConfigType) -> None: async def to_code(config: ConfigType) -> None:
cg.add_define("USE_NOISE") cg.add_define("USE_NOISE")
cg.add_library("esphome/noise-c", "0.1.26") cg.add_library("esphome/noise-c", "0.1.24")
# noise-c depends on libsodium, but declaring it here too lets the # noise-c depends on libsodium, but declaring it here too lets the
# library manager see the full set up front instead of discovering # library manager see the full set up front instead of discovering
# libsodium only after noise-c has downloaded, so the two can download # libsodium only after noise-c has downloaded, so the two can download
# in parallel. The version must match noise-c's library.json. # in parallel. The version must match noise-c's library.json.
cg.add_library("esphome/libsodium", "1.10021.8") cg.add_library("esphome/libsodium", "1.10021.6")
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
cg.add_build_flag("-DHAVE_INLINE_ASM=1") cg.add_build_flag("-DHAVE_INLINE_ASM=1")
@@ -153,8 +153,8 @@ void ResamplerSpeaker::loop() {
ESP_LOGV(TAG, "Stopping"); ESP_LOGV(TAG, "Stopping");
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::STATE_STOPPING); xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::STATE_STOPPING);
} }
// Retries on a subsequent loop if the task is still running on the other core if (event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) {
if ((event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) && this->task_.deallocate()) { this->task_.deallocate();
ESP_LOGD(TAG, "Stopped"); ESP_LOGD(TAG, "Stopped");
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ALL_BITS); xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ALL_BITS);
} }
@@ -235,7 +235,7 @@ size_t ResamplerSpeaker::play(const uint8_t *data, size_t length, TickType_t tic
bytes_written = this->output_speaker_->play(data, length, ticks_to_wait); bytes_written = this->output_speaker_->play(data, length, ticks_to_wait);
} else { } else {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock(); std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (temp_ring_buffer != nullptr) { if (temp_ring_buffer) {
// Only write to the ring buffer if the reference is valid // Only write to the ring buffer if the reference is valid
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait); bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
} else { } else {
@@ -299,7 +299,7 @@ bool ResamplerSpeaker::has_buffered_data() const {
bool has_ring_buffer_data = false; bool has_ring_buffer_data = false;
if (this->requires_resampling_()) { if (this->requires_resampling_()) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock(); std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (temp_ring_buffer != nullptr) { if (temp_ring_buffer) {
has_ring_buffer_data = (temp_ring_buffer->available() > 0); has_ring_buffer_data = (temp_ring_buffer->available() > 0);
} }
} }
@@ -342,7 +342,7 @@ void ResamplerSpeaker::resample_task(void *params) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create( std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(
this_resampler->audio_stream_info_.ms_to_bytes(this_resampler->buffer_duration_ms_)); this_resampler->audio_stream_info_.ms_to_bytes(this_resampler->buffer_duration_ms_));
if (temp_ring_buffer == nullptr) { if (!temp_ring_buffer) {
err = ESP_ERR_NO_MEM; err = ESP_ERR_NO_MEM;
} else { } else {
this_resampler->ring_buffer_ = temp_ring_buffer; this_resampler->ring_buffer_ = temp_ring_buffer;
+7 -19
View File
@@ -30,7 +30,6 @@ CONF_SENDSPIN_ID = "sendspin_id"
CONF_INITIAL_STATIC_DELAY = "initial_static_delay" CONF_INITIAL_STATIC_DELAY = "initial_static_delay"
CONF_FIXED_DELAY = "fixed_delay" CONF_FIXED_DELAY = "fixed_delay"
CONF_DECODE_MEMORY = "decode_memory" CONF_DECODE_MEMORY = "decode_memory"
CONF_CODECS = "codecs"
# Matches ARTWORK_MAX_SLOTS in sendspin-cpp. # Matches ARTWORK_MAX_SLOTS in sendspin-cpp.
MAX_ARTWORK_SLOTS = 4 MAX_ARTWORK_SLOTS = 4
@@ -45,20 +44,6 @@ CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS")
CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM") CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM")
CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED") CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED")
CODEC_FLAC = "flac"
CODEC_OPUS = "opus"
CODEC_PCM = "pcm"
CODECS = {
CODEC_FLAC: CODEC_FORMAT_FLAC,
CODEC_OPUS: CODEC_FORMAT_OPUS,
CODEC_PCM: CODEC_FORMAT_PCM,
}
# Opus only supports 48 kHz audio, so it is left out of the default list at other rates.
DEFAULT_CODECS = [CODEC_FLAC, CODEC_OPUS, CODEC_PCM]
OPUS_SAMPLE_RATE = 48000
SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True) SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True)
IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG") IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG")
IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG") IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG")
@@ -301,13 +286,16 @@ async def to_code(config: ConfigType) -> None:
if data.player_support: if data.player_support:
cg.add_define("USE_SENDSPIN_PLAYER", True) cg.add_define("USE_SENDSPIN_PLAYER", True)
# Configures the player role. Each configured codec is advertised for 16 bits per sample # Configures the player role. We always assume support for 16 bits per sample mono and stereo FLAC, Opus, and PCM at the configured sample rate
# mono and stereo at the configured sample rate. The order is a preference order, both for # (with Opus only supported at 48 kHz since that's the only sample rate it supports). Users can configure the specific formats via the Sendspin server
# the codecs themselves and for stereo over mono.
player_cfg = data.player_config player_cfg = data.player_config
sample_rate = player_cfg[CONF_SAMPLE_RATE] sample_rate = player_cfg[CONF_SAMPLE_RATE]
codecs = player_cfg[CONF_CODECS] # OPUS only supports 48 kHz audio
codecs = [CODEC_FORMAT_FLAC]
if sample_rate == 48000:
codecs.append(CODEC_FORMAT_OPUS)
codecs.append(CODEC_FORMAT_PCM)
def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer: def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer:
return cg.StructInitializer( return cg.StructInitializer(
@@ -13,16 +13,11 @@ from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType from esphome.types import ConfigType
from .. import ( from .. import (
CODEC_OPUS,
CODECS,
CONF_CODECS,
CONF_DECODE_MEMORY, CONF_DECODE_MEMORY,
CONF_FIXED_DELAY, CONF_FIXED_DELAY,
CONF_INITIAL_STATIC_DELAY, CONF_INITIAL_STATIC_DELAY,
CONF_SENDSPIN_ID, CONF_SENDSPIN_ID,
DEFAULT_CODECS,
MEMORY_LOCATIONS, MEMORY_LOCATIONS,
OPUS_SAMPLE_RATE,
SendspinHub, SendspinHub,
register_player_config, register_player_config,
request_controller_support, request_controller_support,
@@ -54,32 +49,10 @@ DisableStaticDelayAdjustmentAction = sendspin_ns.class_(
) )
def _resolve_codecs(config: ConfigType) -> ConfigType:
"""Validate the codec preference list, filling in the default when it is not set."""
sample_rate = config[CONF_SAMPLE_RATE]
if (codecs := config.get(CONF_CODECS)) is None:
config[CONF_CODECS] = [
codec
for codec in DEFAULT_CODECS
if codec != CODEC_OPUS or sample_rate == OPUS_SAMPLE_RATE
]
return config
if len(set(codecs)) != len(codecs):
raise cv.Invalid("Each codec may only be listed once", path=[CONF_CODECS])
if CODEC_OPUS in codecs and sample_rate != OPUS_SAMPLE_RATE:
raise cv.Invalid(
f"Codec '{CODEC_OPUS}' requires a {CONF_SAMPLE_RATE} of {OPUS_SAMPLE_RATE}",
path=[CONF_CODECS],
)
return config
def _register(config: ConfigType) -> ConfigType: def _register(config: ConfigType) -> ConfigType:
request_controller_support() request_controller_support()
register_player_config( register_player_config(
{ {
CONF_CODECS: config[CONF_CODECS],
CONF_SAMPLE_RATE: config[CONF_SAMPLE_RATE], CONF_SAMPLE_RATE: config[CONF_SAMPLE_RATE],
CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE], CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE],
CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY], CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY],
@@ -112,13 +85,9 @@ CONFIG_SCHEMA = cv.All(
min=16000, max=96000 min=16000, max=96000
), ),
cv.Optional(CONF_DECODE_MEMORY): cv.one_of(*MEMORY_LOCATIONS, lower=True), cv.Optional(CONF_DECODE_MEMORY): cv.one_of(*MEMORY_LOCATIONS, lower=True),
cv.Optional(CONF_CODECS): cv.All(
cv.ensure_list(cv.enum(CODECS, lower=True)), cv.Length(min=1)
),
} }
), ),
cv.only_on_esp32, cv.only_on_esp32,
_resolve_codecs,
_register, _register,
) )
@@ -202,15 +202,8 @@ AudioPipelineState AudioPipeline::process_state() {
if (!this->is_playing_) { if (!this->is_playing_) {
// The tasks have been stopped for two ``process_state`` calls in a row, so delete the tasks // The tasks have been stopped for two ``process_state`` calls in a row, so delete the tasks
if (this->read_task_.is_created() || this->decode_task_.is_created()) { if (this->read_task_.is_created() || this->decode_task_.is_created()) {
// Both are attempted every time; a task that is still running on the other core is freed by a this->read_task_.deallocate();
// subsequent call, and freeing an already freed task succeeds without doing anything this->decode_task_.deallocate();
bool read_task_freed = this->read_task_.deallocate();
bool decode_task_freed = this->decode_task_.deallocate();
if (!read_task_freed || !decode_task_freed) {
// A task is still running on the other core, so keep the pipeline in its current state and try
// again on the next call
return AudioPipelineState::PLAYING;
}
if (this->hard_stop_) { if (this->hard_stop_) {
// Stop command was sent, so immediately end the playback // Stop command was sent, so immediately end the playback
this->speaker_->stop(); this->speaker_->stop();
@@ -322,17 +315,17 @@ void AudioPipeline::read_task(void *params) {
if (err == ESP_OK) { if (err == ESP_OK) {
size_t file_ring_buffer_size = this_pipeline->buffer_size_; size_t file_ring_buffer_size = this_pipeline->buffer_size_;
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this_pipeline->raw_file_ring_buffer_.lock(); std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer;
if (temp_ring_buffer == nullptr) { if (!this_pipeline->raw_file_ring_buffer_.use_count()) {
temp_ring_buffer = ring_buffer::RingBuffer::create(file_ring_buffer_size); temp_ring_buffer = ring_buffer::RingBuffer::create(file_ring_buffer_size);
this_pipeline->raw_file_ring_buffer_ = temp_ring_buffer; this_pipeline->raw_file_ring_buffer_ = temp_ring_buffer;
} }
if (temp_ring_buffer == nullptr) { if (!this_pipeline->raw_file_ring_buffer_.use_count()) {
err = ESP_ERR_NO_MEM; err = ESP_ERR_NO_MEM;
} else { } else {
err = reader->add_sink(temp_ring_buffer); reader->add_sink(this_pipeline->raw_file_ring_buffer_);
} }
} }
@@ -403,9 +396,7 @@ void AudioPipeline::decode_task(void *params) {
make_unique<audio::AudioDecoder>(this_pipeline->transfer_buffer_size_, this_pipeline->transfer_buffer_size_); make_unique<audio::AudioDecoder>(this_pipeline->transfer_buffer_size_, this_pipeline->transfer_buffer_size_);
esp_err_t err = decoder->start(this_pipeline->current_audio_file_type_); esp_err_t err = decoder->start(this_pipeline->current_audio_file_type_);
if (err == ESP_OK) { decoder->add_source(this_pipeline->raw_file_ring_buffer_);
err = decoder->add_source(this_pipeline->raw_file_ring_buffer_);
}
if (err != ESP_OK) { if (err != ESP_OK) {
// Send specific error message // Send specific error message
+7 -23
View File
@@ -40,31 +40,16 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size
return true; return true;
} }
bool StaticTask::destroy() { void StaticTask::destroy() {
if (this->handle_ == nullptr) { if (this->handle_ != nullptr) {
return true; TaskHandle_t handle = this->handle_;
this->handle_ = nullptr;
vTaskDelete(handle);
} }
// Suspending takes the task off the ready and event lists, so nothing can schedule it again. It only asks
// the other core to yield though, so the task may still be running on it for a moment.
vTaskSuspend(this->handle_);
if (eTaskGetState(this->handle_) != eSuspended) {
// The task is still running on the other core and using its stack. Deleting it now would only put it on
// the termination list and return, so the caller has to try again once it has been swapped out.
return false;
}
// The task cannot run again, so the delete completes right away instead of being left to the idle task.
TaskHandle_t handle = this->handle_;
this->handle_ = nullptr;
vTaskDelete(handle);
return true;
} }
bool StaticTask::deallocate() { void StaticTask::deallocate() {
if (!this->destroy()) { this->destroy();
return false;
}
if (this->stack_buffer_ != nullptr) { if (this->stack_buffer_ != nullptr) {
RAMAllocator<StackType_t> allocator(this->use_psram_ ? RAMAllocator<StackType_t>::ALLOC_EXTERNAL RAMAllocator<StackType_t> allocator(this->use_psram_ ? RAMAllocator<StackType_t>::ALLOC_EXTERNAL
: RAMAllocator<StackType_t>::ALLOC_INTERNAL); : RAMAllocator<StackType_t>::ALLOC_INTERNAL);
@@ -72,7 +57,6 @@ bool StaticTask::deallocate() {
this->stack_buffer_ = nullptr; this->stack_buffer_ = nullptr;
this->stack_size_ = 0; this->stack_size_ = 0;
} }
return true;
} }
} // namespace esphome } // namespace esphome
+5 -12
View File
@@ -11,7 +11,6 @@ namespace esphome {
/** Helper for FreeRTOS static task management. /** Helper for FreeRTOS static task management.
* Bundles TaskHandle_t, StaticTask_t, and the stack buffer into one object with create/destroy methods. * Bundles TaskHandle_t, StaticTask_t, and the stack buffer into one object with create/destroy methods.
* Call destroy() and deallocate() from another task: a task cannot free the stack it is still running on.
*/ */
class StaticTask { class StaticTask {
public: public:
@@ -24,7 +23,7 @@ class StaticTask {
/// @brief Allocate stack and create task. /// @brief Allocate stack and create task.
/// @param fn Task function /// @param fn Task function
/// @param name Task name (for debug) /// @param name Task name (for debug)
/// @param stack_size Stack size in bytes (StackType_t is a byte on ESP-IDF) /// @param stack_size Stack size in StackType_t words
/// @param param Parameter passed to task function /// @param param Parameter passed to task function
/// @param priority FreeRTOS task priority /// @param priority FreeRTOS task priority
/// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM /// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM
@@ -32,17 +31,11 @@ class StaticTask {
bool create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority, bool create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority,
bool use_psram); bool use_psram);
/// @brief Delete the task, keeping the stack buffer allocated for reuse by a subsequent create() call. /// @brief Delete the task but keep the stack buffer allocated for reuse by a subsequent create() call.
/// The task must have finished its work and parked itself, either suspended or blocked indefinitely: it is void destroy();
/// suspended here so that it cannot be scheduled again, and it is given no chance to clean up.
/// @return true if the task was deleted; false if it is still running on another core, in which case the
/// caller should try again later.
bool destroy();
/// @brief Delete the task (if created) and free the stack buffer. /// @brief Delete the task (if running) and free the stack buffer.
/// @return true if the stack buffer was freed; false if the task is still running on another core, in void deallocate();
/// which case the caller should try again later.
bool deallocate();
protected: protected:
TaskHandle_t handle_{nullptr}; TaskHandle_t handle_{nullptr};
+3 -6
View File
@@ -96,10 +96,6 @@ UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8
# across the addresses on top of that. # across the addresses on top of that.
EXTRA_UPLOAD_ATTEMPTS = 2 EXTRA_UPLOAD_ATTEMPTS = 2
UPLOAD_RETRY_DELAY = 5.0 UPLOAD_RETRY_DELAY = 5.0
# Data phase timeout; must stay longer than the device's OTA_SOCKET_TIMEOUT_DATA
# (105 s) so a stalled session is gone before a retry, and long enough for lwIP
# to get a lost chunk ack through after the retransmit run seen in practice
DATA_PHASE_TIMEOUT = 160.0
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -698,7 +694,8 @@ def perform_ota(
_LOGGER.info("Handshake complete") _LOGGER.info("Handshake complete")
sock.settimeout(DATA_PHASE_TIMEOUT) # Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures
sock.settimeout(90.0)
if extended_proto: if extended_proto:
send_check(sock, ota_type, "ota type") send_check(sock, ota_type, "ota type")
@@ -857,7 +854,7 @@ def run_ota_impl_(
# clean up a half-open connection (its handshake watchdog runs at 20s); # clean up a half-open connection (its handshake watchdog runs at 20s);
# moving on to the next address family stays immediate. Known limitation: # moving on to the next address family stays immediate. Known limitation:
# a silent mid-transfer drop with no reset can wedge the device until its # a silent mid-transfer drop with no reset can wedge the device until its
# 105s data timeout, which outlasts this budget; the retries target the # 90s data timeout, which outlasts this budget; the retries target the
# common failures where the device resets or closes the link promptly. # common failures where the device resets or closes the link promptly.
total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS
last_error = "" last_error = ""
+3 -3
View File
@@ -45,7 +45,7 @@ lib_deps_base =
lib_deps = lib_deps =
${common.lib_deps_base} ${common.lib_deps_base}
https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea
esphome/noise-c@0.1.26 ; noise (api, ota) esphome/noise-c@0.1.24 ; noise (api, ota)
improv/Improv@1.2.7 ; improv_serial / esp32_improv improv/Improv@1.2.7 ; improv_serial / esp32_improv
kikuchan98/pngle@1.1.0 ; online_image kikuchan98/pngle@1.1.0 ; online_image
; Using the repository directly, otherwise ESP-IDF can't use the library ; Using the repository directly, otherwise ESP-IDF can't use the library
@@ -244,7 +244,7 @@ lib_deps =
${common:idf-component-libs.lib_deps} ${common:idf-component-libs.lib_deps}
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
droscy/esp_wireguard@0.4.5 ; wireguard droscy/esp_wireguard@0.4.5 ; wireguard
esphome/noise-c@0.1.26 ; noise (api, ota) esphome/noise-c@0.1.24 ; noise (api, ota)
ESP32Async/AsyncTCP@3.4.5 ; async_tcp ESP32Async/AsyncTCP@3.4.5 ; async_tcp
DNSServer ; captive_portal DNSServer ; captive_portal
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
@@ -641,7 +641,7 @@ build_unflags =
extends = common extends = common
platform = platformio/native platform = platformio/native
lib_deps = lib_deps =
esphome/noise-c@0.1.26 ; used by noise (api, ota) esphome/noise-c@0.1.24 ; used by noise (api, ota)
lvgl/lvgl@9.5.0 ; lvgl lvgl/lvgl@9.5.0 ; lvgl
build_flags = build_flags =
${common.build_flags} ${common.build_flags}
+1 -1
View File
@@ -10,7 +10,7 @@ tzlocal==5.4.4 # from time
tzdata>=2026.3 # from time tzdata>=2026.3 # from time
pyserial==3.5 pyserial==3.5
platformio==6.1.19 platformio==6.1.19
esptool==5.4.0 esptool==5.3.1
click==8.3.3 click==8.3.3
aioesphomeapi==46.3.0 aioesphomeapi==46.3.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
@@ -1,90 +0,0 @@
"""Validation tests for the sendspin media_source platform.
These cover the codec preference list, whose rejection branches a compile test
cannot reach: a `test*.yaml` can only assert that a configuration is accepted.
"""
from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.sendspin import CONF_CODECS, _get_data
from esphome.components.sendspin.media_source import CONFIG_SCHEMA
from esphome.const import PlatformFramework
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
def _media_source_config(**overrides: Any) -> ConfigType:
"""Build a minimal valid media source config, allowing field overrides."""
config: ConfigType = {
"id": "sendspin_media_source",
"sendspin_id": "sendspin_hub",
}
config.update(overrides)
return config
def test_default_codecs_at_48_khz(set_core_config: SetCoreConfigCallable) -> None:
"""Every codec is advertised when the sample rate suits all of them."""
set_core_config(PlatformFramework.ESP32_IDF)
config = CONFIG_SCHEMA(_media_source_config())
assert config[CONF_CODECS] == ["flac", "opus", "pcm"]
def test_default_codecs_drop_opus_at_other_rates(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Opus only supports 48 kHz, so it leaves the default list at other rates."""
set_core_config(PlatformFramework.ESP32_IDF)
config = CONFIG_SCHEMA(_media_source_config(sample_rate=44100))
assert config[CONF_CODECS] == ["flac", "pcm"]
def test_configured_order_is_preserved(set_core_config: SetCoreConfigCallable) -> None:
"""The list is a preference order, so it reaches the player role as written."""
set_core_config(PlatformFramework.ESP32_IDF)
CONFIG_SCHEMA(_media_source_config(codecs=["pcm", "flac"]))
assert _get_data().player_config[CONF_CODECS] == ["pcm", "flac"]
def test_empty_codec_list_rejected(set_core_config: SetCoreConfigCallable) -> None:
"""A player with no codecs at all could never be given a stream."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match="length of value must be at least 1"):
CONFIG_SCHEMA(_media_source_config(codecs=[]))
def test_duplicate_codec_rejected(set_core_config: SetCoreConfigCallable) -> None:
"""A repeated codec has no meaning in a preference order."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match="may only be listed once"):
CONFIG_SCHEMA(_media_source_config(codecs=["flac", "flac"]))
def test_unknown_codec_rejected(set_core_config: SetCoreConfigCallable) -> None:
"""Only codecs the player role can decode are accepted."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match="Unknown value"):
CONFIG_SCHEMA(_media_source_config(codecs=["mp3"]))
def test_opus_at_wrong_sample_rate_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Asking for Opus at a rate it cannot handle fails rather than silently
dropping the stated preference."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match="requires a sample_rate of 48000"):
CONFIG_SCHEMA(_media_source_config(codecs=["opus"], sample_rate=44100))
@@ -9,4 +9,3 @@ media_source:
static_delay_adjustable: true static_delay_adjustable: true
fixed_delay: 480us fixed_delay: 480us
decode_memory: internal decode_memory: internal
codecs: [pcm, opus, flac]
@@ -17,10 +17,10 @@ uart:
baud_rate: 115200 baud_rate: 115200
port: /dev/null port: /dev/null
# Shared 3-bus mesh (see the shared_yaml markers): addr 1 = typed read-only # Shared 3-bus mesh (see the shared_yaml markers): addr 1 = typed registers
# registers, addr 5 = the read/write 0x17 target, addr 2/3 on the second # backed by writable globals, addr 5 = the read/write 0x17 target, addr 2/3/6
# server hub. auto_start everywhere: the controller polls at boot, so the # on the second server hub. auto_start everywhere: the controller polls at
# forwarding must already be live or early requests generate warnings. # boot, so the forwarding must already be live or early requests generate warnings.
# Every test presses Start Scenario, so all merged actions fire in every test. # Every test presses Start Scenario, so all merged actions fire in every test.
uart_mock: uart_mock:
- id: virtual_uart_server - id: virtual_uart_server
@@ -64,6 +64,54 @@ globals:
- id: stored_1 - id: stored_1
type: uint16_t type: uint16_t
initial_value: "0" initial_value: "0"
- id: stored_u_word
type: uint16_t
initial_value: "99"
- id: stored_u_word_s
type: uint16_t
initial_value: "4660"
- id: stored_s_word
type: int16_t
initial_value: "-99"
- id: stored_s_word_s
type: int16_t
initial_value: "-2"
- id: stored_u_dword
type: uint32_t
initial_value: "16909060"
- id: stored_s_dword
type: int32_t
initial_value: "-16909060"
- id: stored_u_dword_r
type: uint32_t
initial_value: "67305985"
- id: stored_s_dword_r
type: int32_t
initial_value: "-67305985"
- id: stored_u_qword
type: uint64_t
initial_value: "72623859790382856"
- id: stored_s_qword
type: int64_t
initial_value: "-72623859790382856"
- id: stored_u_qword_r
type: uint64_t
initial_value: "578437695752307201"
- id: stored_s_qword_r
type: int64_t
initial_value: "-578437695752307201"
- id: stored_fp32
type: float
initial_value: "3.14"
- id: stored_fp32_r
type: float
initial_value: "2.5"
- id: stored_bit_2
type: bool
initial_value: "false"
- id: stored_bit_3
type: bool
initial_value: "true"
modbus: modbus:
- uart_id: virtual_uart_server - uart_id: virtual_uart_server
@@ -90,6 +138,10 @@ modbus_controller:
modbus_id: virtual_modbus_client modbus_id: virtual_modbus_client
id: modbus_controller_3 id: modbus_controller_3
update_interval: 1s update_interval: 1s
- address: 6
modbus_id: virtual_modbus_client
id: modbus_controller_6
update_interval: 1s
modbus_server: modbus_server:
- address: 1 - address: 1
@@ -97,46 +149,60 @@ modbus_server:
registers: registers:
- address: 0x01 - address: 0x01
value_type: U_WORD value_type: U_WORD
read_lambda: return 99; read_lambda: return id(stored_u_word);
write_lambda: id(stored_u_word) = x; return true;
- address: 0x02 - address: 0x02
value_type: U_WORD_S value_type: U_WORD_S
read_lambda: return 4660; read_lambda: return id(stored_u_word_s);
write_lambda: id(stored_u_word_s) = x; return true;
- address: 0x03 - address: 0x03
value_type: S_WORD value_type: S_WORD
read_lambda: return -99; read_lambda: return id(stored_s_word);
write_lambda: id(stored_s_word) = x; return true;
- address: 0x04 - address: 0x04
value_type: S_WORD_S value_type: S_WORD_S
read_lambda: return -2; read_lambda: return id(stored_s_word_s);
write_lambda: id(stored_s_word_s) = x; return true;
- address: 0x05 - address: 0x05
value_type: U_DWORD value_type: U_DWORD
read_lambda: return 16909060; read_lambda: return id(stored_u_dword);
write_lambda: id(stored_u_dword) = x; return true;
- address: 0x08 - address: 0x08
value_type: S_DWORD value_type: S_DWORD
read_lambda: return -16909060; read_lambda: return id(stored_s_dword);
write_lambda: id(stored_s_dword) = x; return true;
- address: 0x0B - address: 0x0B
value_type: U_DWORD_R value_type: U_DWORD_R
read_lambda: return 67305985; read_lambda: return id(stored_u_dword_r);
write_lambda: id(stored_u_dword_r) = x; return true;
- address: 0x0E - address: 0x0E
value_type: S_DWORD_R value_type: S_DWORD_R
read_lambda: return -67305985; read_lambda: return id(stored_s_dword_r);
write_lambda: id(stored_s_dword_r) = x; return true;
- address: 0x11 - address: 0x11
value_type: U_QWORD value_type: U_QWORD
read_lambda: return 72623859790382856; read_lambda: return id(stored_u_qword);
write_lambda: id(stored_u_qword) = x; return true;
- address: 0x16 - address: 0x16
value_type: S_QWORD value_type: S_QWORD
read_lambda: return -72623859790382856; read_lambda: return id(stored_s_qword);
write_lambda: id(stored_s_qword) = x; return true;
- address: 0x1B - address: 0x1B
value_type: U_QWORD_R value_type: U_QWORD_R
read_lambda: return 578437695752307201; read_lambda: return id(stored_u_qword_r);
write_lambda: id(stored_u_qword_r) = x; return true;
- address: 0x20 - address: 0x20
value_type: S_QWORD_R value_type: S_QWORD_R
read_lambda: return -578437695752307201; read_lambda: return id(stored_s_qword_r);
write_lambda: id(stored_s_qword_r) = x; return true;
- address: 0x25 - address: 0x25
value_type: FP32 value_type: FP32
read_lambda: return 3.14; read_lambda: return id(stored_fp32);
write_lambda: id(stored_fp32) = x; return true;
- address: 0x28 - address: 0x28
value_type: FP32_R value_type: FP32_R
read_lambda: return 3.14; read_lambda: return id(stored_fp32_r);
write_lambda: id(stored_fp32_r) = x; return true;
- address: 5 - address: 5
modbus_id: virtual_modbus_server modbus_id: virtual_modbus_server
registers: registers:
@@ -165,6 +231,19 @@ modbus_server:
- address: 0x01 - address: 0x01
value_type: U_WORD value_type: U_WORD
read_lambda: return 929; read_lambda: return 929;
- address: 6
modbus_id: virtual_modbus_server_2
bits:
- address: 0x00
read_lambda: return true;
- address: 0x01
read_lambda: return false;
- address: 0x02
read_lambda: return id(stored_bit_2);
write_lambda: id(stored_bit_2) = x; return true;
- address: 0x03
read_lambda: return id(stored_bit_3);
write_lambda: id(stored_bit_3) = x; return true;
sensor: sensor:
- platform: modbus_controller - platform: modbus_controller
@@ -280,6 +359,183 @@ sensor:
name: "client_read_1" name: "client_read_1"
id: client_read_1 id: client_read_1
# The number schema caps min/max at 16777215 (float32 integer precision), so
# the large dword/qword baselines cannot be written back through these numbers.
number:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_u_word"
address: 0x01
register_type: holding
value_type: U_WORD
min_value: 0
max_value: 65535
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_u_word_s"
address: 0x02
register_type: holding
value_type: U_WORD_S
min_value: 0
max_value: 65535
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_s_word"
address: 0x03
register_type: holding
value_type: S_WORD
min_value: -16777215
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_s_word_s"
address: 0x04
register_type: holding
value_type: S_WORD_S
min_value: -16777215
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_u_dword"
address: 0x05
register_type: holding
value_type: U_DWORD
min_value: 0
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_s_dword"
address: 0x08
register_type: holding
value_type: S_DWORD
min_value: -16777215
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_u_dword_r"
address: 0x0B
register_type: holding
value_type: U_DWORD_R
min_value: 0
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_s_dword_r"
address: 0x0E
register_type: holding
value_type: S_DWORD_R
min_value: -16777215
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_u_qword"
address: 0x11
register_type: holding
value_type: U_QWORD
min_value: 0
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_s_qword"
address: 0x16
register_type: holding
value_type: S_QWORD
min_value: -16777215
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_u_qword_r"
address: 0x1B
register_type: holding
value_type: U_QWORD_R
min_value: 0
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_s_qword_r"
address: 0x20
register_type: holding
value_type: S_QWORD_R
min_value: -16777215
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_fp32"
address: 0x25
register_type: holding
value_type: FP32
min_value: -16777215
max_value: 16777215
step: 0.01
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_fp32_r"
address: 0x28
register_type: holding
value_type: FP32_R
min_value: -16777215
max_value: 16777215
step: 0.01
# The four bits are read both as coils (FC 0x01) and discrete inputs (FC 0x02);
# the server serves both from one shared table, so the two views must agree.
binary_sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_6
name: "bit_coil_0"
address: 0x00
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_6
name: "bit_coil_1"
address: 0x01
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_6
name: "bit_coil_2"
address: 0x02
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_6
name: "bit_coil_3"
address: 0x03
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_6
name: "bit_di_0"
address: 0x00
register_type: discrete_input
- platform: modbus_controller
modbus_controller_id: modbus_controller_6
name: "bit_di_1"
address: 0x01
register_type: discrete_input
- platform: modbus_controller
modbus_controller_id: modbus_controller_6
name: "bit_di_2"
address: 0x02
register_type: discrete_input
- platform: modbus_controller
modbus_controller_id: modbus_controller_6
name: "bit_di_3"
address: 0x03
register_type: discrete_input
# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the
# multiple-coils write (FC 0x0F) so both server write paths are exercised.
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_6
name: "write_bit_2"
address: 0x02
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_6
name: "write_bit_3"
address: 0x03
register_type: coil
use_write_multiple: true
button: button:
- platform: template - platform: template
name: "Start Scenario" name: "Start Scenario"
@@ -1,147 +0,0 @@
esphome:
name: uart-mock-modbus-srv-bits
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
# auto_start must be true for loopback fixtures: the modbus controller
# polls on its update_interval immediately at boot, so the uart_mock
# forwarding must already be active or early requests are lost and
# generate modbus warnings.
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true # See comment on virtual_uart_server above
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: stored_bit_2
type: bool
initial_value: "false"
- id: stored_bit_3
type: bool
initial_value: "true"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
update_interval: 1s
id: modbus_controller_1
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
bits:
- address: 0x00
read_lambda: return true;
- address: 0x01
read_lambda: return false;
- address: 0x02
read_lambda: return id(stored_bit_2);
write_lambda: id(stored_bit_2) = x; return true;
- address: 0x03
read_lambda: return id(stored_bit_3);
write_lambda: id(stored_bit_3) = x; return true;
# The same four bits are read both as coils (FC 0x01) and as discrete inputs
# (FC 0x02): the server serves both from one shared bit table, so the two
# views must always agree.
binary_sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_coil_0"
address: 0x00
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_coil_1"
address: 0x01
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_coil_2"
address: 0x02
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_coil_3"
address: 0x03
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_di_0"
address: 0x00
register_type: discrete_input
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_di_1"
address: 0x01
register_type: discrete_input
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_di_2"
address: 0x02
register_type: discrete_input
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "bit_di_3"
address: 0x03
register_type: discrete_input
# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the
# multiple-coils write (FC 0x0F) so both server write paths are exercised.
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_bit_2"
address: 0x02
register_type: coil
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_bit_3"
address: 0x03
register_type: coil
use_write_multiple: true
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
@@ -1,371 +0,0 @@
esphome:
name: uart-mock-modbus-srv-write
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
# auto_start must be true for loopback fixtures: the modbus controller
# polls on its update_interval immediately at boot, so the uart_mock
# forwarding must already be active or early requests are lost and
# generate modbus warnings.
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true # See comment on virtual_uart_server above
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: stored_u_word
type: uint16_t
initial_value: "11"
- id: stored_u_word_s
type: uint16_t
initial_value: "4660"
- id: stored_s_word
type: int16_t
initial_value: "-11"
- id: stored_s_word_s
type: int16_t
initial_value: "-2"
- id: stored_u_dword
type: uint32_t
initial_value: "1001"
- id: stored_s_dword
type: int32_t
initial_value: "-1001"
- id: stored_u_dword_r
type: uint32_t
initial_value: "3003"
- id: stored_s_dword_r
type: int32_t
initial_value: "-3003"
- id: stored_u_qword
type: uint64_t
initial_value: "5005"
- id: stored_s_qword
type: int64_t
initial_value: "-5005"
- id: stored_u_qword_r
type: uint64_t
initial_value: "7007"
- id: stored_s_qword_r
type: int64_t
initial_value: "-7007"
- id: stored_fp32
type: float
initial_value: "1.5"
- id: stored_fp32_r
type: float
initial_value: "2.5"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
update_interval: 2s
id: modbus_controller_1
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x01
value_type: U_WORD
read_lambda: return id(stored_u_word);
write_lambda: id(stored_u_word) = x; return true;
- address: 0x02
value_type: U_WORD_S
read_lambda: return id(stored_u_word_s);
write_lambda: id(stored_u_word_s) = x; return true;
- address: 0x03
value_type: S_WORD
read_lambda: return id(stored_s_word);
write_lambda: id(stored_s_word) = x; return true;
- address: 0x04
value_type: S_WORD_S
read_lambda: return id(stored_s_word_s);
write_lambda: id(stored_s_word_s) = x; return true;
- address: 0x05
value_type: U_DWORD
read_lambda: return id(stored_u_dword);
write_lambda: id(stored_u_dword) = x; return true;
- address: 0x08
value_type: S_DWORD
read_lambda: return id(stored_s_dword);
write_lambda: id(stored_s_dword) = x; return true;
- address: 0x0B
value_type: U_DWORD_R
read_lambda: return id(stored_u_dword_r);
write_lambda: id(stored_u_dword_r) = x; return true;
- address: 0x0E
value_type: S_DWORD_R
read_lambda: return id(stored_s_dword_r);
write_lambda: id(stored_s_dword_r) = x; return true;
- address: 0x11
value_type: U_QWORD
read_lambda: return id(stored_u_qword);
write_lambda: id(stored_u_qword) = x; return true;
- address: 0x16
value_type: S_QWORD
read_lambda: return id(stored_s_qword);
write_lambda: id(stored_s_qword) = x; return true;
- address: 0x1B
value_type: U_QWORD_R
read_lambda: return id(stored_u_qword_r);
write_lambda: id(stored_u_qword_r) = x; return true;
- address: 0x20
value_type: S_QWORD_R
read_lambda: return id(stored_s_qword_r);
write_lambda: id(stored_s_qword_r) = x; return true;
- address: 0x25
value_type: FP32
read_lambda: return id(stored_fp32);
write_lambda: id(stored_fp32) = x; return true;
- address: 0x28
value_type: FP32_R
read_lambda: return id(stored_fp32_r);
write_lambda: id(stored_fp32_r) = x; return true;
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_word"
address: 0x01
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_word_s"
address: 0x02
register_type: holding
value_type: U_WORD_S
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_word"
address: 0x03
register_type: holding
value_type: S_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_word_s"
address: 0x04
register_type: holding
value_type: S_WORD_S
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_dword"
address: 0x05
register_type: holding
value_type: U_DWORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_dword"
address: 0x08
register_type: holding
value_type: S_DWORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_dword_r"
address: 0x0B
register_type: holding
value_type: U_DWORD_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_dword_r"
address: 0x0E
register_type: holding
value_type: S_DWORD_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_qword"
address: 0x11
register_type: holding
value_type: U_QWORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_qword"
address: 0x16
register_type: holding
value_type: S_QWORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_u_qword_r"
address: 0x1B
register_type: holding
value_type: U_QWORD_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_s_qword_r"
address: 0x20
register_type: holding
value_type: S_QWORD_R
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_fp32"
address: 0x25
register_type: holding
value_type: FP32
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_fp32_r"
address: 0x28
register_type: holding
value_type: FP32_R
number:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_u_word"
address: 0x01
register_type: holding
value_type: U_WORD
min_value: 0
max_value: 65535
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_u_word_s"
address: 0x02
register_type: holding
value_type: U_WORD_S
min_value: 0
max_value: 65535
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_s_word"
address: 0x03
register_type: holding
value_type: S_WORD
min_value: -16777215
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_s_word_s"
address: 0x04
register_type: holding
value_type: S_WORD_S
min_value: -16777215
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_u_dword"
address: 0x05
register_type: holding
value_type: U_DWORD
min_value: 0
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_s_dword"
address: 0x08
register_type: holding
value_type: S_DWORD
min_value: -16777215
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_u_dword_r"
address: 0x0B
register_type: holding
value_type: U_DWORD_R
min_value: 0
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_s_dword_r"
address: 0x0E
register_type: holding
value_type: S_DWORD_R
min_value: -16777215
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_u_qword"
address: 0x11
register_type: holding
value_type: U_QWORD
min_value: 0
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_s_qword"
address: 0x16
register_type: holding
value_type: S_QWORD
min_value: -16777215
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_u_qword_r"
address: 0x1B
register_type: holding
value_type: U_QWORD_R
min_value: 0
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_s_qword_r"
address: 0x20
register_type: holding
value_type: S_QWORD_R
min_value: -16777215
max_value: 16777215
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_fp32"
address: 0x25
register_type: holding
value_type: FP32
min_value: -16777215
max_value: 16777215
step: 0.01
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "write_fp32_r"
address: 0x28
register_type: holding
value_type: FP32_R
min_value: -16777215
max_value: 16777215
step: 0.01
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
+66 -74
View File
@@ -19,23 +19,40 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass
from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo, TextSensorState from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo, TextSensorState
import pytest import pytest
from .state_utils import SensorTracker, find_entity, wait_for_state from .state_utils import SensorTracker, find_entity, require_entity, wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction from .types import APIClientConnectedFactory, RunCompiledFunction
@dataclass def _swap16(value: int) -> int:
class RegisterTestCase: """Byte-swapped view of a 16-bit register as the raw U_WORD wire value."""
"""Test parameters for a single modbus register write/read round-trip.""" return ((value & 0xFF) << 8) | (value >> 8)
initial_value: object
write_number_name: str # Raw U_WORD view of reg_u_word_s's initial 0x1234
write_value: float MESH_RAW_U_WORD_S = _swap16(4660)
post_write_value: object
# Initial values of the mesh fixture's address 1 registers; the
# server_controller test reads them and the write test uses them as baseline.
MESH_INITIAL_VALUES: dict[str, object] = {
"reg_u_word": 99,
"reg_u_word_s": 4660,
"reg_s_word": -99,
"reg_s_word_s": -2,
"reg_u_dword": 16909060,
"reg_s_dword": -16909060,
"reg_u_dword_r": pytest.approx(67305985),
"reg_s_dword_r": pytest.approx(-67305985),
"reg_u_qword": pytest.approx(72623859790382856),
"reg_s_qword": pytest.approx(-72623859790382856),
"reg_u_qword_r": pytest.approx(578437695752307201),
"reg_s_qword_r": pytest.approx(-578437695752307201),
"reg_fp32": pytest.approx(3.14),
"reg_fp32_r": pytest.approx(2.5),
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -310,23 +327,7 @@ async def test_uart_mock_modbus_server_controller(
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
expected_values = { expected_values = MESH_INITIAL_VALUES | {"reg_u_word_s_raw": MESH_RAW_U_WORD_S}
"reg_u_word": 99,
"reg_u_word_s": 4660,
"reg_u_word_s_raw": 13330,
"reg_s_word": -99,
"reg_s_word_s": -2,
"reg_u_dword": 16909060,
"reg_s_dword": -16909060,
"reg_u_dword_r": pytest.approx(67305985),
"reg_s_dword_r": pytest.approx(-67305985),
"reg_u_qword": pytest.approx(72623859790382856),
"reg_s_qword": pytest.approx(-72623859790382856),
"reg_u_qword_r": pytest.approx(578437695752307201),
"reg_s_qword_r": pytest.approx(-578437695752307201),
"reg_fp32": pytest.approx(3.14),
"reg_fp32_r": pytest.approx(3.14),
}
tracker = SensorTracker(list(expected_values.keys())) tracker = SensorTracker(list(expected_values.keys()))
futures = tracker.expect_all(expected_values) futures = tracker.expect_all(expected_values)
@@ -334,14 +335,12 @@ async def test_uart_mock_modbus_server_controller(
run_compiled(yaml_config, line_callback=line_callback), run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client, api_client_connected() as client,
): ):
# The controller polls from boot, so the first values can already be in
# the states the device sends on connect; matching them there saves
# waiting for the next poll
await tracker.setup_and_start_scenario(client, match_initial_states=True) await tracker.setup_and_start_scenario(client, match_initial_states=True)
await tracker.await_all(futures) await tracker.await_all(futures)
_assert_no_modbus_errors(error_log_lines, warning_log_lines) _assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uart_mock_modbus_server_controller_write( async def test_uart_mock_modbus_server_controller_write(
yaml_config: str, yaml_config: str,
@@ -357,51 +356,47 @@ async def test_uart_mock_modbus_server_controller_write(
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
register_test_cases: dict[str, RegisterTestCase] = { # Per read-back sensor: the number entity to write through and the value;
"reg_u_word": RegisterTestCase(11, "write_u_word", 42, 42), # floats read back within tolerance, everything else exactly
"reg_u_word_s": RegisterTestCase(4660, "write_u_word_s", 17185, 17185), register_writes: dict[str, tuple[str, int | float]] = {
"reg_s_word": RegisterTestCase(-11, "write_s_word", -42, -42), "reg_u_word": ("write_u_word", 42),
"reg_s_word_s": RegisterTestCase(-2, "write_s_word_s", -257, -257), "reg_u_word_s": ("write_u_word_s", 17185),
"reg_u_dword": RegisterTestCase(1001, "write_u_dword", 2002, 2002), "reg_s_word": ("write_s_word", -42),
"reg_s_dword": RegisterTestCase(-1001, "write_s_dword", -2002, -2002), "reg_s_word_s": ("write_s_word_s", -257),
"reg_u_dword_r": RegisterTestCase(3003, "write_u_dword_r", 4004, 4004), "reg_u_dword": ("write_u_dword", 2002),
"reg_s_dword_r": RegisterTestCase(-3003, "write_s_dword_r", -4004, -4004), "reg_s_dword": ("write_s_dword", -2002),
"reg_u_qword": RegisterTestCase(5005, "write_u_qword", 6006, 6006), "reg_u_dword_r": ("write_u_dword_r", 4004),
"reg_s_qword": RegisterTestCase(-5005, "write_s_qword", -6006, -6006), "reg_s_dword_r": ("write_s_dword_r", -4004),
"reg_u_qword_r": RegisterTestCase(7007, "write_u_qword_r", 8008, 8008), "reg_u_qword": ("write_u_qword", 6006),
"reg_s_qword_r": RegisterTestCase(-7007, "write_s_qword_r", -8008, -8008), "reg_s_qword": ("write_s_qword", -6006),
"reg_fp32": RegisterTestCase( "reg_u_qword_r": ("write_u_qword_r", 8008),
pytest.approx(1.5, abs=0.01), "reg_s_qword_r": ("write_s_qword_r", -8008),
"write_fp32", "reg_fp32": ("write_fp32", 6.28),
3.14, "reg_fp32_r": ("write_fp32_r", 9.42),
pytest.approx(3.14, abs=0.01),
),
"reg_fp32_r": RegisterTestCase(
pytest.approx(2.5, abs=0.01),
"write_fp32_r",
6.28,
pytest.approx(6.28, abs=0.01),
),
} }
tracker = SensorTracker(list(register_test_cases.keys())) tracker = SensorTracker([*register_writes, "reg_u_word_s_raw"])
# The raw U_WORD view of 0x02 pins the byte swap on the write path: the
# round trip through write_u_word_s applies the swap an even number of
# times, so only the raw sensor can catch a symmetrically dropped swap.
# Phase 1: expect initial baseline values # Phase 1: expect initial baseline values
initial_futures = tracker.expect_all( initial_futures = tracker.expect_all(
{name: case.initial_value for name, case in register_test_cases.items()} MESH_INITIAL_VALUES | {"reg_u_word_s_raw": MESH_RAW_U_WORD_S}
) )
# Phase 2: expect post-write values (registered now so on_state can match them) # Phase 2: expect post-write values (registered now so on_state can match them)
written_futures = tracker.expect_all( written_futures = tracker.expect_all(
{name: case.post_write_value for name, case in register_test_cases.items()} {
name: pytest.approx(value, abs=0.01) if isinstance(value, float) else value
for name, (_, value) in register_writes.items()
}
| {"reg_u_word_s_raw": _swap16(register_writes["reg_u_word_s"][1])}
) )
async with ( async with (
run_compiled(yaml_config, line_callback=line_callback), run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client, api_client_connected() as client,
): ):
# The controller polls from boot, so the baseline can already be in the
# states the device sends on connect; matching it there saves waiting for
# the next poll
entities = await tracker.setup_and_start_scenario( entities = await tracker.setup_and_start_scenario(
client, match_initial_states=True client, match_initial_states=True
) )
@@ -410,19 +405,22 @@ async def test_uart_mock_modbus_server_controller_write(
# connection is working before issuing writes # connection is working before issuing writes
await tracker.await_all(initial_futures, timeout=4.0) await tracker.await_all(initial_futures, timeout=4.0)
# Issue write commands for all register types # Issue write commands for all register types; exact object_id match,
for case in register_test_cases.values(): # since several write_* names are prefixes of a sibling
entity = find_entity(entities, case.write_number_name, NumberInfo) numbers = {
assert entity is not None, ( e.object_id.lower(): e for e in entities if isinstance(e, NumberInfo)
f"{case.write_number_name} number entity not found" }
) for number_name, value in register_writes.values():
client.number_command(entity.key, case.write_value) entity = numbers.get(number_name)
assert entity is not None, f"{number_name} number entity not found"
client.number_command(entity.key, value)
# Wait for sensors to reflect the written values (round-trip write+read) # Wait for sensors to reflect the written values (round-trip write+read)
await tracker.await_all(written_futures, timeout=4.0) await tracker.await_all(written_futures, timeout=4.0)
_assert_no_modbus_errors(error_log_lines, warning_log_lines) _assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.shared_yaml("uart_mock_modbus_mesh")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_uart_mock_modbus_server_controller_bits( async def test_uart_mock_modbus_server_controller_bits(
yaml_config: str, yaml_config: str,
@@ -468,8 +466,6 @@ async def test_uart_mock_modbus_server_controller_bits(
run_compiled(yaml_config, line_callback=line_callback), run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client, api_client_connected() as client,
): ):
# The controller polls from boot and binary sensors drop repeats, so the
# baseline can arrive only in the states the device sends on connect
entities = await tracker.setup_and_start_scenario( entities = await tracker.setup_and_start_scenario(
client, match_initial_states=True client, match_initial_states=True
) )
@@ -480,8 +476,7 @@ async def test_uart_mock_modbus_server_controller_bits(
# Flip both writable bits: 0x02 false -> true, 0x03 true -> false # Flip both writable bits: 0x02 false -> true, 0x03 true -> false
for switch_name, value in (("write_bit_2", True), ("write_bit_3", False)): for switch_name, value in (("write_bit_2", True), ("write_bit_3", False)):
entity = find_entity(entities, switch_name, SwitchInfo) entity = require_entity(entities, switch_name, SwitchInfo)
assert entity is not None, f"{switch_name} switch entity not found"
client.switch_command(entity.key, value) client.switch_command(entity.key, value)
# Wait for both read views to reflect the written values # Wait for both read views to reflect the written values
@@ -508,9 +503,6 @@ async def test_uart_mock_modbus_server_controller_multiple(
run_compiled(yaml_config, line_callback=line_callback), run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client, api_client_connected() as client,
): ):
# The controller polls from boot, so the first values can already be in
# the states the device sends on connect; matching them there saves
# waiting for the next poll
await tracker.setup_and_start_scenario(client, match_initial_states=True) await tracker.setup_and_start_scenario(client, match_initial_states=True)
await tracker.await_all(futures) await tracker.await_all(futures)
_assert_no_modbus_errors(error_log_lines, warning_log_lines) _assert_no_modbus_errors(error_log_lines, warning_log_lines)
+17 -17
View File
@@ -35,8 +35,8 @@ def _load_script():
def test_spec_key_collapses_destinations() -> None: def test_spec_key_collapses_destinations() -> None:
"""Two specs delivering one package share a directory and one key.""" """Two specs delivering one package share a directory and one key."""
mod = _load_script() mod = _load_script()
assert mod.spec_key("esphome/noise-c @ 0.1.26") == "noise-c" assert mod.spec_key("esphome/noise-c @ 0.1.24") == "noise-c"
assert mod.spec_key("esphome/noise-c@0.1.26") == "noise-c" assert mod.spec_key("esphome/noise-c@0.1.24") == "noise-c"
assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key( assert mod.spec_key("ESP32Async/AsyncTCP @ ^3.4.10") == mod.spec_key(
"esp32async/asynctcp @ 3.5.0" "esp32async/asynctcp @ 3.5.0"
) )
@@ -54,23 +54,23 @@ def test_parse_specs_and_cli_args(tmp_path: Path) -> None:
"[env:a]\n" "[env:a]\n"
"platform = fake/platform@1\n" "platform = fake/platform@1\n"
"lib_deps =\n" "lib_deps =\n"
" esphome/noise-c @ 0.1.26\n" " esphome/noise-c @ 0.1.24\n"
" ${common.lib_deps}\n" " ${common.lib_deps}\n"
" internal_lib\n" " internal_lib\n"
"[env:b]\n" "[env:b]\n"
"lib_deps =\n" "lib_deps =\n"
" esphome/noise-c @ 0.1.26\n" " esphome/noise-c @ 0.1.24\n"
) )
mod = _load_script() mod = _load_script()
args = Namespace(libraries=True, platforms=True, tools=False) args = Namespace(libraries=True, platforms=True, tools=False)
libs, platforms, tools = mod.parse_specs(str(ini), args) libs, platforms, tools = mod.parse_specs(str(ini), args)
# exact-string duplicates collapse; distinct version pins survive # exact-string duplicates collapse; distinct version pins survive
assert libs == ["esphome/noise-c @ 0.1.26"] assert libs == ["esphome/noise-c @ 0.1.24"]
assert platforms == ["fake/platform@1"] assert platforms == ["fake/platform@1"]
assert tools == [] assert tools == []
assert mod.build_cli_args(libs, platforms, tools) == [ assert mod.build_cli_args(libs, platforms, tools) == [
"-l", "-l",
"esphome/noise-c @ 0.1.26", "esphome/noise-c @ 0.1.24",
"-p", "-p",
"fake/platform@1", "fake/platform@1",
] ]
@@ -162,13 +162,13 @@ def test_parallel_install_behavior(tmp_path: Path) -> None:
mod.parallel_install( mod.parallel_install(
cls, cls,
[ [
"esphome/noise-c @ 0.1.26", "esphome/noise-c @ 0.1.24",
"esphome/noise-c @ 0.1.26", "esphome/noise-c @ 0.1.24",
"esphome/already @ 1.0", "esphome/already @ 1.0",
"https://x/framework.tar.xz", "https://x/framework.tar.xz",
], ],
) )
assert cls.calls == ["esphome/noise-c @ 0.1.26"] assert cls.calls == ["esphome/noise-c @ 0.1.24"]
assert cls.lock_events == ["lock", "unlock"] assert cls.lock_events == ["lock", "unlock"]
@@ -205,7 +205,7 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None:
mod = _load_script() mod = _load_script()
cls = _reset_fake(str(tmp_path)) cls = _reset_fake(str(tmp_path))
cls.deps = { cls.deps = {
"esphome/noise-c @ 0.1.26": [ "esphome/noise-c @ 0.1.24": [
{"owner": "esphome", "name": "libsodium", "version": "^1.0"}, {"owner": "esphome", "name": "libsodium", "version": "^1.0"},
{"name": "SPI"}, {"name": "SPI"},
], ],
@@ -213,12 +213,12 @@ def test_parallel_install_runs_dependency_waves(tmp_path: Path) -> None:
{"owner": "esphome", "name": "libsodium", "version": "^1.0"}, {"owner": "esphome", "name": "libsodium", "version": "^1.0"},
], ],
} }
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26", "esphome/wg @ 1.0"]) mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24", "esphome/wg @ 1.0"])
assert len(cls.calls) == 3 # the shared dep installs exactly once assert len(cls.calls) == 3 # the shared dep installs exactly once
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"} assert {mod.spec_key(c) for c in cls.calls} == {"noise-c", "wg", "libsodium"}
# Wave-1 strings carry no compatibility; the dependency wave does # Wave-1 strings carry no compatibility; the dependency wave does
compats = dict(cls.compat_calls) compats = dict(cls.compat_calls)
assert compats["esphome/noise-c @ 0.1.26"] is None assert compats["esphome/noise-c @ 0.1.24"] is None
dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k) dep_compat = next(v for k, v in cls.compat_calls if "libsodium" in k)
assert dep_compat is not None # mirrors pio's install_dependency assert dep_compat is not None # mirrors pio's install_dependency
@@ -229,11 +229,11 @@ def test_dependency_wave_excludes_url_specs(tmp_path: Path) -> None:
mod = _load_script() mod = _load_script()
cls = _reset_fake(str(tmp_path)) cls = _reset_fake(str(tmp_path))
cls.deps = { cls.deps = {
"esphome/noise-c @ 0.1.26": [ "esphome/noise-c @ 0.1.24": [
{"name": "vendored", "version": "https://github.com/x/y.git"}, {"name": "vendored", "version": "https://github.com/x/y.git"},
], ],
} }
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"]) mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"])
assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"} assert {mod.spec_key(c) for c in cls.calls} == {"noise-c"}
@@ -348,13 +348,13 @@ def test_warm_store_still_walks_dependencies(tmp_path: Path) -> None:
"""Already-installed top-level packages still feed the dependency """Already-installed top-level packages still feed the dependency
wave; a warm store can be missing a transitive dep.""" wave; a warm store can be missing a transitive dep."""
mod = _load_script() mod = _load_script()
cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.26"}) cls = _reset_fake(str(tmp_path), installed={"esphome/noise-c @ 0.1.24"})
cls.deps = { cls.deps = {
"esphome/noise-c @ 0.1.26": [ "esphome/noise-c @ 0.1.24": [
{"owner": "esphome", "name": "libsodium", "version": "^1.0"}, {"owner": "esphome", "name": "libsodium", "version": "^1.0"},
], ],
} }
mod.parallel_install(cls, ["esphome/noise-c @ 0.1.26"]) mod.parallel_install(cls, ["esphome/noise-c @ 0.1.24"])
assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"] assert [mod.spec_key(c) for c in cls.calls] == ["libsodium"]
-3
View File
@@ -416,9 +416,6 @@ def test_perform_ota_no_auth(
"Update took 14.00 seconds (prepare 2.00, upload 5.00, commit 7.00)" "Update took 14.00 seconds (prepare 2.00, upload 5.00, commit 7.00)"
in caplog.text in caplog.text
) )
# The data phase timeout must outlast the device's 105 s data timeout
mock_socket.settimeout.assert_any_call(espota2.DATA_PHASE_TIMEOUT)
assert espota2.DATA_PHASE_TIMEOUT > 105.0
@pytest.mark.usefixtures("mock_time") @pytest.mark.usefixtures("mock_time")
+2 -2
View File
@@ -1663,7 +1663,7 @@ def test_preinstall_runs_dependency_waves(tmp_path: Path) -> None:
{"name": "SPI"}, {"name": "SPI"},
] ]
m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"]) m.dependency_to_spec.side_effect = lambda dep: _FakeSpec(name=dep["name"])
pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))]) pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))])
assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out assert installed == ["noise-c", "libsodium"] # dep deduped, SPI left out
# The dep wave carries its compatibility so _install searches qualified # The dep wave carries its compatibility so _install searches qualified
dep_call = m._install.call_args_list[-1] dep_call = m._install.call_args_list[-1]
@@ -1683,7 +1683,7 @@ def test_preinstall_dependency_wave_skips_seen_names(tmp_path: Path) -> None:
m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: ( m._install.side_effect = lambda spec, skip_dependencies, compatibility=None: (
installed.append(getattr(spec, "name", str(spec))) installed.append(getattr(spec, "name", str(spec)))
) )
pf._preinstall(m, [("noise-c@0.1.26", _FakeSpec(name="noise-c"))]) pf._preinstall(m, [("noise-c@0.1.24", _FakeSpec(name="noise-c"))])
assert installed == ["noise-c"] assert installed == ["noise-c"]