mirror of
https://github.com/esphome/esphome.git
synced 2026-09-18 10:38:38 +00:00
Merge remote-tracking branch 'upstream/platformio-retry-connection-errors' into integration
This commit is contained in:
@@ -1 +1 @@
|
||||
../.ai/instructions.md
|
||||
../AGENTS.md
|
||||
@@ -261,6 +261,7 @@ jobs:
|
||||
cpp-unit-tests-run-all: ${{ steps.determine.outputs.cpp-unit-tests-run-all }}
|
||||
cpp-unit-tests-components: ${{ steps.determine.outputs.cpp-unit-tests-components }}
|
||||
component-test-batches: ${{ steps.determine.outputs.component-test-batches }}
|
||||
validate-only-components: ${{ steps.determine.outputs.validate-only-components }}
|
||||
benchmarks: ${{ steps.determine.outputs.benchmarks }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
@@ -305,6 +306,7 @@ jobs:
|
||||
echo "cpp-unit-tests-run-all=$(echo "$output" | jq -r '.cpp_unit_tests_run_all')" >> $GITHUB_OUTPUT
|
||||
echo "cpp-unit-tests-components=$(echo "$output" | jq -c '.cpp_unit_tests_components')" >> $GITHUB_OUTPUT
|
||||
echo "component-test-batches=$(echo "$output" | jq -c '.component_test_batches')" >> $GITHUB_OUTPUT
|
||||
echo "validate-only-components=$(echo "$output" | jq -c '.validate_only_components')" >> $GITHUB_OUTPUT
|
||||
echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT
|
||||
- name: Save components graph cache
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
@@ -775,13 +777,45 @@ jobs:
|
||||
echo "Config validation passed! Starting compilation..."
|
||||
echo ""
|
||||
|
||||
# Compute the compile-stage component list. Components whose only
|
||||
# changes are validate.*.yaml files are config-only -- their source
|
||||
# and test fixtures didn't move, so rebuilding firmware adds no
|
||||
# signal. Subtract them from this batch before invoking compile.
|
||||
validate_only_json='${{ needs.determine-jobs.outputs.validate-only-components }}'
|
||||
if [ -z "$validate_only_json" ]; then
|
||||
validate_only_json='[]'
|
||||
fi
|
||||
if ! validate_only_csv=$(echo "$validate_only_json" | jq -r 'join(",")'); then
|
||||
echo "::error::Failed to render validate-only-components as CSV from: $validate_only_json"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$validate_only_csv" ]; then
|
||||
compile_csv="$components_csv"
|
||||
else
|
||||
components_sorted=$(echo "$components_csv" | tr ',' '\n' | sort -u)
|
||||
validate_sorted=$(echo "$validate_only_csv" | tr ',' '\n' | sort -u)
|
||||
if ! diff_out=$(comm -23 <(echo "$components_sorted") <(echo "$validate_sorted")); then
|
||||
echo "::error::Failed to compute compile component subset."
|
||||
exit 1
|
||||
fi
|
||||
compile_csv=$(echo "$diff_out" | paste -sd ',' -)
|
||||
skipped=$(comm -12 <(echo "$components_sorted") <(echo "$validate_sorted") | paste -sd ',' -)
|
||||
if [ -n "$skipped" ]; then
|
||||
echo "Validate-only components in this batch (skipping compile): $skipped"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Show disk space before compilation
|
||||
echo "Disk space before compilation:"
|
||||
df -h
|
||||
echo ""
|
||||
|
||||
# Run compilation with grouping and isolation
|
||||
python3 script/test_build_components.py -e compile -c "$components_csv" -f --isolate "$directly_changed_csv"
|
||||
if [ -n "$compile_csv" ]; then
|
||||
# Run compilation with grouping and isolation
|
||||
python3 script/test_build_components.py -e compile -c "$compile_csv" -f --isolate "$directly_changed_csv"
|
||||
else
|
||||
echo "All components in this batch are validate-only -- skipping compile stage."
|
||||
fi
|
||||
|
||||
test-native-idf:
|
||||
name: Test components with native ESP-IDF
|
||||
|
||||
@@ -398,13 +398,23 @@ This document provides essential context for AI models interacting with this pro
|
||||
│ ├── i2c/ # I2C bus
|
||||
│ └── spi/ # SPI bus
|
||||
└── components/[component]/
|
||||
├── common.yaml # Component-only config (no bus definitions)
|
||||
├── test.esp32-idf.yaml
|
||||
├── test.esp8266-ard.yaml
|
||||
└── test.rp2040-ard.yaml
|
||||
├── common.yaml # Component-only config (no bus definitions)
|
||||
├── test.esp32-idf.yaml # config + compile
|
||||
├── test.esp8266-ard.yaml # config + compile
|
||||
├── test-variant.esp32-idf.yaml # variant test, config + compile
|
||||
├── validate.esp32-idf.yaml # config-only (never compiled)
|
||||
└── validate-legacy.esp32-idf.yaml # config-only variant
|
||||
```
|
||||
Run them using `script/test_build_components`. Use `-c <component>` to test specific components and `-t <target>` for specific platforms.
|
||||
|
||||
* **Config-only test files (`validate.*.yaml`):** Use this prefix when a YAML file only needs to exercise schema/validation paths and does not need to be compiled. CI runs `validate.*.yaml` files with `esphome config` only and skips them during compile. The grammar mirrors `test.*.yaml`:
|
||||
- `validate.<platform>.yaml` — base config-only test
|
||||
- `validate-<variant>.<platform>.yaml` — config-only variant
|
||||
|
||||
Use this for things like deprecated-syntax migration tests, schema edge cases, or platform-specific validation branches where building firmware adds no signal. A component may have any mix of `test.*.yaml` and `validate.*.yaml` files. Validate files never participate in bus-grouping; each one runs as its own `esphome config` invocation.
|
||||
|
||||
When a PR's only edits to a component are `validate.*.yaml` files (no source changes, no `test.*.yaml` changes, and the component isn't pulled in as a dependency of another changed component), CI skips the compile stage for that component entirely and only runs config validation. This is decided in `script/determine-jobs.py` via `_component_change_is_validate_only` and surfaced as the `validate_only_components` output that the `test-build-components-split` job consumes.
|
||||
|
||||
* **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`:
|
||||
```yaml
|
||||
# test.esp32-idf.yaml — use packages for buses
|
||||
@@ -1416,6 +1416,15 @@ def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
return 0
|
||||
|
||||
|
||||
def command_config_hash(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
# generating code might modify config, so it must be done in order to generate
|
||||
# a hash that will match what was generated when compiling and then running
|
||||
# on the device
|
||||
generate_cpp_contents(config)
|
||||
safe_print(f"0x{CORE.config_hash:08x}")
|
||||
return 0
|
||||
|
||||
|
||||
def command_vscode(args: ArgsProtocol) -> int | None:
|
||||
from esphome import vscode
|
||||
|
||||
@@ -1951,6 +1960,7 @@ PRE_CONFIG_ACTIONS = {
|
||||
|
||||
POST_CONFIG_ACTIONS = {
|
||||
"config": command_config,
|
||||
"config-hash": command_config_hash,
|
||||
"compile": command_compile,
|
||||
"upload": command_upload,
|
||||
"logs": command_logs,
|
||||
@@ -2064,6 +2074,13 @@ def parse_args(argv):
|
||||
"--show-secrets", help="Show secrets in output.", action="store_true"
|
||||
)
|
||||
|
||||
parser_config_hash = subparsers.add_parser(
|
||||
"config-hash", help="Calculate the hash of the configuration."
|
||||
)
|
||||
parser_config_hash.add_argument(
|
||||
"configuration", help="Your YAML configuration file(s).", nargs="+"
|
||||
)
|
||||
|
||||
parser_compile = subparsers.add_parser(
|
||||
"compile", help="Read the configuration and compile a program."
|
||||
)
|
||||
|
||||
@@ -89,6 +89,16 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
|
||||
{extra_compile_options}
|
||||
|
||||
project({CORE.name})
|
||||
|
||||
# Emit raw JSON size data for ESPHome to read post-build.
|
||||
add_custom_command(
|
||||
TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD
|
||||
COMMAND ${{PYTHON}} -m esp_idf_size --ng --format=raw
|
||||
-o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json
|
||||
${{CMAKE_PROJECT_NAME}}.map
|
||||
WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}}
|
||||
VERBATIM
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -2026,6 +2026,7 @@ message VoiceAssistantAudio {
|
||||
|
||||
bytes data = 1 [(pointer_to_buffer) = true];
|
||||
bool end = 2;
|
||||
bytes data2 = 3 [(pointer_to_buffer) = true];
|
||||
}
|
||||
|
||||
enum VoiceAssistantTimerEvent {
|
||||
|
||||
@@ -2889,6 +2889,11 @@ bool VoiceAssistantAudio::decode_length(uint32_t field_id, ProtoLengthDelimited
|
||||
this->data_len = value.size();
|
||||
break;
|
||||
}
|
||||
case 3: {
|
||||
this->data2 = value.data();
|
||||
this->data2_len = value.size();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -2898,12 +2903,14 @@ uint8_t *VoiceAssistantAudio::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len);
|
||||
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->end);
|
||||
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->data2, this->data2_len);
|
||||
return pos;
|
||||
}
|
||||
uint32_t VoiceAssistantAudio::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
size += ProtoSize::calc_length(1, this->data_len);
|
||||
size += ProtoSize::calc_bool(1, this->end);
|
||||
size += ProtoSize::calc_length(1, this->data2_len);
|
||||
return size;
|
||||
}
|
||||
bool VoiceAssistantTimerEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) {
|
||||
|
||||
@@ -2435,13 +2435,15 @@ class VoiceAssistantEventResponse final : public ProtoDecodableMessage {
|
||||
class VoiceAssistantAudio final : public ProtoDecodableMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 106;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 21;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 40;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("voice_assistant_audio"); }
|
||||
#endif
|
||||
const uint8_t *data{nullptr};
|
||||
uint16_t data_len{0};
|
||||
bool end{false};
|
||||
const uint8_t *data2{nullptr};
|
||||
uint16_t data2_len{0};
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
|
||||
@@ -2173,6 +2173,7 @@ const char *VoiceAssistantAudio::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("VoiceAssistantAudio"));
|
||||
dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len);
|
||||
dump_field(out, ESPHOME_PSTR("end"), this->end);
|
||||
dump_bytes_field(out, ESPHOME_PSTR("data2"), this->data2, this->data2_len);
|
||||
return out.c_str();
|
||||
}
|
||||
const char *VoiceAssistantTimerEventResponse::dump_to(DumpBuffer &out) const {
|
||||
|
||||
@@ -207,6 +207,137 @@ void ConstAudioSourceBuffer::consume(size_t bytes) {
|
||||
this->data_start_ += bytes;
|
||||
}
|
||||
|
||||
std::unique_ptr<RingBufferAudioSource> RingBufferAudioSource::create(
|
||||
std::shared_ptr<ring_buffer::RingBuffer> ring_buffer, size_t max_fill_bytes, uint8_t alignment_bytes) {
|
||||
if (ring_buffer == nullptr || max_fill_bytes == 0 || alignment_bytes == 0 || alignment_bytes > MAX_ALIGNMENT_BYTES) {
|
||||
return nullptr;
|
||||
}
|
||||
return std::unique_ptr<RingBufferAudioSource>(
|
||||
new RingBufferAudioSource(std::move(ring_buffer), max_fill_bytes, alignment_bytes));
|
||||
}
|
||||
|
||||
RingBufferAudioSource::~RingBufferAudioSource() {
|
||||
if (this->acquired_item_ != nullptr) {
|
||||
this->ring_buffer_->receive_release(this->acquired_item_);
|
||||
this->acquired_item_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void RingBufferAudioSource::release_item_() {
|
||||
if (this->acquired_item_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
if (this->item_trailing_length_ > 0) {
|
||||
// Copy the trailing sub-frame bytes into the splice buffer before returning the item; the next
|
||||
// fill() will complete the frame from the head of the next chunk.
|
||||
std::memcpy(this->splice_buffer_, this->item_trailing_ptr_, this->item_trailing_length_);
|
||||
this->splice_length_ = this->item_trailing_length_;
|
||||
this->item_trailing_ptr_ = nullptr;
|
||||
this->item_trailing_length_ = 0;
|
||||
}
|
||||
this->ring_buffer_->receive_release(this->acquired_item_);
|
||||
this->acquired_item_ = nullptr;
|
||||
}
|
||||
|
||||
void RingBufferAudioSource::consume(size_t bytes) {
|
||||
bytes = std::min(bytes, this->current_available_);
|
||||
this->current_data_ += bytes;
|
||||
this->current_available_ -= bytes;
|
||||
// Promotion of queued data is deferred to fill() so callers see new data as a fresh return value
|
||||
// rather than appearing silently after consume(). When the held item has nothing left depending
|
||||
// on it (no exposed bytes and no queued region), release it now so the ring buffer can be
|
||||
// reclaimed by writers even if fill() is never called again.
|
||||
if (this->current_available_ == 0 && this->queued_length_ == 0) {
|
||||
this->release_item_();
|
||||
}
|
||||
}
|
||||
|
||||
bool RingBufferAudioSource::has_buffered_data() const {
|
||||
// splice_length_ is deliberately not considered here. It holds an incomplete frame whose completion
|
||||
// bytes must still arrive through the ring buffer, which ring_buffer_->available() already reports.
|
||||
// Counting it separately would strand a drain loop when a stream ends mid-frame and those completion
|
||||
// bytes never come.
|
||||
return (this->current_available_ > 0) || (this->queued_length_ > 0) || (this->ring_buffer_->available() > 0);
|
||||
}
|
||||
|
||||
size_t RingBufferAudioSource::fill(TickType_t ticks_to_wait, bool /*pre_shift*/) {
|
||||
if (this->current_available_ > 0) {
|
||||
// Caller has not finished consuming the current exposure
|
||||
return 0;
|
||||
}
|
||||
|
||||
// If a queued region (the aligned remainder of the new chunk after a splice frame) is waiting,
|
||||
// promote it to the exposed region and report its size as fresh data.
|
||||
if (this->queued_length_ > 0) {
|
||||
this->current_data_ = this->queued_data_;
|
||||
this->current_available_ = this->queued_length_;
|
||||
this->queued_data_ = nullptr;
|
||||
this->queued_length_ = 0;
|
||||
return this->current_available_;
|
||||
}
|
||||
|
||||
// Nothing exposed and nothing queued: release the previously held item (saving any sub-frame tail
|
||||
// to splice_buffer_) and acquire a new chunk.
|
||||
this->release_item_();
|
||||
|
||||
size_t chunk_length = 0;
|
||||
void *item = this->ring_buffer_->receive_acquire(chunk_length, this->max_fill_bytes_, ticks_to_wait);
|
||||
if (item == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint8_t *chunk_data = static_cast<uint8_t *>(item);
|
||||
bool exposing_splice_frame = false;
|
||||
|
||||
// Complete any pending splice frame from the head of the new chunk.
|
||||
if (this->splice_length_ > 0) {
|
||||
const size_t needed = static_cast<size_t>(this->alignment_bytes_) - this->splice_length_;
|
||||
if (chunk_length < needed) {
|
||||
// Not enough data to complete the spliced frame yet; absorb everything and wait for more.
|
||||
std::memcpy(this->splice_buffer_ + this->splice_length_, chunk_data, chunk_length);
|
||||
this->splice_length_ += chunk_length;
|
||||
this->ring_buffer_->receive_release(item);
|
||||
return 0;
|
||||
}
|
||||
std::memcpy(this->splice_buffer_ + this->splice_length_, chunk_data, needed);
|
||||
chunk_data += needed;
|
||||
chunk_length -= needed;
|
||||
this->splice_length_ = 0;
|
||||
exposing_splice_frame = true;
|
||||
}
|
||||
|
||||
this->acquired_item_ = item;
|
||||
|
||||
// Split the remaining chunk into its aligned region and a (possibly zero) sub-frame trailing tail.
|
||||
const size_t trailing = (this->alignment_bytes_ > 1) ? (chunk_length % this->alignment_bytes_) : 0;
|
||||
const size_t aligned_bytes = chunk_length - trailing;
|
||||
if (trailing > 0) {
|
||||
this->item_trailing_ptr_ = chunk_data + aligned_bytes;
|
||||
this->item_trailing_length_ = trailing;
|
||||
}
|
||||
|
||||
if (exposing_splice_frame) {
|
||||
// Expose the spliced frame from splice_buffer_, queuing the chunk's aligned region for the next
|
||||
// fill() call.
|
||||
this->current_data_ = this->splice_buffer_;
|
||||
this->current_available_ = this->alignment_bytes_;
|
||||
this->queued_data_ = chunk_data;
|
||||
this->queued_length_ = aligned_bytes;
|
||||
return this->alignment_bytes_;
|
||||
}
|
||||
|
||||
if (aligned_bytes == 0) {
|
||||
// The entire chunk is a sub-frame tail (only possible when alignment exceeds chunk size). Save it
|
||||
// to the splice buffer and release the item so the next fill() can complete the frame.
|
||||
this->release_item_();
|
||||
return 0;
|
||||
}
|
||||
|
||||
this->current_data_ = chunk_data;
|
||||
this->current_available_ = aligned_bytes;
|
||||
return aligned_bytes;
|
||||
}
|
||||
|
||||
} // namespace esphome::audio
|
||||
|
||||
#endif
|
||||
|
||||
@@ -214,6 +214,86 @@ class ConstAudioSourceBuffer : public AudioReadableBuffer {
|
||||
size_t length_{0};
|
||||
};
|
||||
|
||||
/// @brief Zero-copy audio source that reads directly from a ring buffer's internal storage.
|
||||
///
|
||||
/// Optionally enforces a minimum read alignment (e.g. one audio frame). When alignment_bytes > 1, the
|
||||
/// source transparently stitches frames that straddle the ring buffer's wrap boundary by buffering the
|
||||
/// trailing partial frame from one chunk and joining it with the head of the next chunk in a small
|
||||
/// internal splice buffer, so callers always see frame-aligned data.
|
||||
///
|
||||
/// Not thread-safe. The underlying ring_buffer::RingBuffer supports one producer and one consumer
|
||||
/// running concurrently, but a given RingBufferAudioSource (its acquired item, splice buffer, and
|
||||
/// queued region) must be used by only one thread, and that thread is the ring buffer's consumer.
|
||||
class RingBufferAudioSource : public AudioReadableBuffer {
|
||||
public:
|
||||
/// Maximum supported alignment. Sized to cover 32-bit samples across up to 2 channels (8 bytes).
|
||||
static constexpr size_t MAX_ALIGNMENT_BYTES = 8;
|
||||
|
||||
/// @brief Creates a new ring-buffer-backed audio source after validating its parameters.
|
||||
/// @param ring_buffer The ring buffer to read from. Must be non-null.
|
||||
/// @param max_fill_bytes Soft cap on bytes acquired per fill() call. Must be > 0.
|
||||
/// @param alignment_bytes Minimum exposed-region alignment in bytes (defaults to 1, i.e. byte-aligned).
|
||||
/// Pass bytes_per_frame to make every exposed region a whole number of frames. Must be in
|
||||
/// [1, MAX_ALIGNMENT_BYTES].
|
||||
/// @return unique_ptr if parameters are valid, nullptr otherwise
|
||||
static std::unique_ptr<RingBufferAudioSource> create(std::shared_ptr<ring_buffer::RingBuffer> ring_buffer,
|
||||
size_t max_fill_bytes, uint8_t alignment_bytes = 1);
|
||||
|
||||
~RingBufferAudioSource() override;
|
||||
|
||||
// AudioReadableBuffer interface
|
||||
const uint8_t *data() const override { return this->current_data_; }
|
||||
size_t available() const override { return this->current_available_; }
|
||||
void consume(size_t bytes) override;
|
||||
bool has_buffered_data() const override;
|
||||
/// pre_shift is ignored: there is no intermediate transfer buffer to compact, so an unconsumed
|
||||
/// exposure stays in place and fill() returns 0 until it is fully consumed.
|
||||
size_t fill(TickType_t ticks_to_wait, bool pre_shift) override;
|
||||
|
||||
/// @brief Returns a mutable pointer to the currently exposed audio data.
|
||||
/// The pointer may reference the ring buffer's internal storage or, when exposing a stitched frame
|
||||
/// across a wrap boundary, an internal splice buffer. In either case mutations are safe but data
|
||||
/// should be discarded after use, since the underlying storage will be reused on the next fill().
|
||||
/// Use only when the caller is the sole consumer of this source.
|
||||
uint8_t *mutable_data() { return this->current_data_; }
|
||||
|
||||
protected:
|
||||
/// @brief Constructs a new ring-buffer-backed audio source. Use create() instead, which validates
|
||||
/// arguments before construction.
|
||||
explicit RingBufferAudioSource(std::shared_ptr<ring_buffer::RingBuffer> ring_buffer, size_t max_fill_bytes,
|
||||
uint8_t alignment_bytes)
|
||||
: ring_buffer_(std::move(ring_buffer)), max_fill_bytes_(max_fill_bytes), alignment_bytes_(alignment_bytes) {}
|
||||
|
||||
/// @brief Releases the currently held ring buffer item, first copying any trailing sub-frame bytes
|
||||
/// into the splice buffer so they can be stitched with the next chunk.
|
||||
void release_item_();
|
||||
|
||||
std::shared_ptr<ring_buffer::RingBuffer> ring_buffer_;
|
||||
size_t max_fill_bytes_;
|
||||
|
||||
void *acquired_item_{nullptr};
|
||||
uint8_t *current_data_{nullptr};
|
||||
|
||||
// Sub-frame trailing bytes inside the held item that will be copied to splice_buffer_ on release.
|
||||
uint8_t *item_trailing_ptr_{nullptr};
|
||||
|
||||
// After the currently-exposed splice frame is consumed, fill() will promote this region (the aligned
|
||||
// remainder of the new chunk) to the exposed region. queued_length_ == 0 when nothing is queued.
|
||||
uint8_t *queued_data_{nullptr};
|
||||
|
||||
// Splice buffer holds the start of a partial frame whose remainder lives at the head of the next
|
||||
// chunk. While splice_length_ > 0, the buffer is incomplete and waiting for completion bytes.
|
||||
uint8_t splice_buffer_[MAX_ALIGNMENT_BYTES];
|
||||
|
||||
size_t current_available_{0};
|
||||
size_t queued_length_{0};
|
||||
|
||||
// item_trailing_length_ and splice_length_ are bounded by MAX_ALIGNMENT_BYTES.
|
||||
uint8_t alignment_bytes_;
|
||||
uint8_t item_trailing_length_{0};
|
||||
uint8_t splice_length_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::audio
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1754,7 +1754,9 @@ async def to_code(config):
|
||||
)
|
||||
else:
|
||||
cg.add_build_flag("-Wno-error=format")
|
||||
cg.add_build_flag("-Wno-error=maybe-uninitialized")
|
||||
cg.add_build_flag("-Wno-error=missing-field-initializers")
|
||||
cg.add_build_flag("-Wno-error=reorder")
|
||||
cg.add_build_flag("-Wno-error=volatile")
|
||||
|
||||
cg.set_cpp_standard("gnu++20")
|
||||
|
||||
@@ -143,7 +143,11 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
|
||||
const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_);
|
||||
|
||||
// The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info
|
||||
const size_t ring_buffer_size = this->current_stream_info_.ms_to_bytes(ring_buffer_duration);
|
||||
const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1);
|
||||
// Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and
|
||||
// avoids unnecessary single-frame splices.
|
||||
const size_t ring_buffer_size =
|
||||
(this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame;
|
||||
|
||||
// For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames
|
||||
const uint32_t frames_to_fill_single_dma_buffer = SPDIF_BLOCK_SAMPLES;
|
||||
@@ -151,13 +155,13 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
|
||||
this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer);
|
||||
|
||||
bool successful_setup = false;
|
||||
std::unique_ptr<audio::AudioSourceTransferBuffer> transfer_buffer =
|
||||
audio::AudioSourceTransferBuffer::create(bytes_to_fill_single_dma_buffer);
|
||||
std::unique_ptr<audio::RingBufferAudioSource> audio_source;
|
||||
|
||||
if (transfer_buffer != nullptr) {
|
||||
{
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
|
||||
if (temp_ring_buffer.use_count() == 1) {
|
||||
transfer_buffer->set_source(temp_ring_buffer);
|
||||
audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, bytes_to_fill_single_dma_buffer,
|
||||
static_cast<uint8_t>(bytes_per_frame));
|
||||
if (audio_source != nullptr) {
|
||||
this->audio_ring_buffer_ = temp_ring_buffer;
|
||||
successful_setup = true;
|
||||
}
|
||||
@@ -297,24 +301,24 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
|
||||
|
||||
if (!this->pause_state_) {
|
||||
while (real_frames_in_block < SPDIF_BLOCK_SAMPLES) {
|
||||
if (transfer_buffer->available() == 0) {
|
||||
size_t bytes_read = transfer_buffer->transfer_data_from_source(read_timeout_ticks);
|
||||
if (audio_source->available() == 0) {
|
||||
size_t bytes_read = audio_source->fill(read_timeout_ticks, false);
|
||||
if (bytes_read == 0) {
|
||||
break; // No upstream data within the read budget; silence-pad the remainder.
|
||||
}
|
||||
uint8_t *new_data = transfer_buffer->get_buffer_end() - bytes_read;
|
||||
uint8_t *new_data = audio_source->mutable_data();
|
||||
this->apply_software_volume_(new_data, bytes_read);
|
||||
this->swap_esp32_mono_samples_(new_data, bytes_read);
|
||||
}
|
||||
|
||||
const uint32_t frames_still_needed = SPDIF_BLOCK_SAMPLES - real_frames_in_block;
|
||||
const size_t bytes_still_needed = this->current_stream_info_.frames_to_bytes(frames_still_needed);
|
||||
const size_t bytes_to_feed = std::min(transfer_buffer->available(), bytes_still_needed);
|
||||
const size_t bytes_to_feed = std::min(audio_source->available(), bytes_still_needed);
|
||||
|
||||
uint32_t blocks_sent = 0;
|
||||
size_t pcm_consumed = 0;
|
||||
esp_err_t err = this->spdif_encoder_->write(transfer_buffer->get_buffer_start(), bytes_to_feed,
|
||||
write_timeout_ticks, &blocks_sent, &pcm_consumed);
|
||||
esp_err_t err = this->spdif_encoder_->write(audio_source->data(), bytes_to_feed, write_timeout_ticks,
|
||||
&blocks_sent, &pcm_consumed);
|
||||
if (err != ESP_OK) {
|
||||
// A failed (or timed-out) send leaves an unsent block in the encoder's stitch buffer;
|
||||
// resuming would credit the next iteration's bytes against an old block. Bail and
|
||||
@@ -325,7 +329,7 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
|
||||
}
|
||||
|
||||
if (pcm_consumed > 0) {
|
||||
transfer_buffer->decrease_buffer_length(pcm_consumed);
|
||||
audio_source->consume(pcm_consumed);
|
||||
real_frames_in_block += this->current_stream_info_.bytes_to_frames(pcm_consumed);
|
||||
}
|
||||
if (blocks_sent > 0) {
|
||||
@@ -387,9 +391,7 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
|
||||
this->spdif_encoder_->reset();
|
||||
}
|
||||
|
||||
if (transfer_buffer != nullptr) {
|
||||
transfer_buffer.reset();
|
||||
}
|
||||
audio_source.reset();
|
||||
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPED);
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ void I2SAudioSpeakerBase::loop() {
|
||||
}
|
||||
|
||||
if (event_group_bits & SpeakerEventGroupBits::ERR_ESP_NO_MEM) {
|
||||
ESP_LOGE(TAG, "Not enough memory");
|
||||
ESP_LOGE(TAG, "Speaker task setup failed (allocation, preload, or channel enable)");
|
||||
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,9 +36,7 @@ enum SpeakerEventGroupBits : uint32_t {
|
||||
ERR_ESP_NO_MEM = (1 << 19),
|
||||
|
||||
ERR_DROPPED_EVENT = (1 << 20), // ISR overflowed the event queue, dropping a completion event
|
||||
ERR_PARTIAL_WRITE = (1 << 21), // a DMA write returned fewer bytes than requested (or the encoder
|
||||
// failed to commit a complete block), which breaks the lockstep
|
||||
// invariant for every subsequent event
|
||||
ERR_PARTIAL_WRITE = (1 << 21), // i2s_channel_write returned fewer bytes than requested
|
||||
ERR_LOCKSTEP_DESYNC = (1 << 22), // i2s_event_queue_ and write_records_queue_ fell out of sync
|
||||
|
||||
ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits
|
||||
|
||||
@@ -17,7 +17,14 @@ namespace esphome::i2s_audio {
|
||||
static const char *const TAG = "i2s_audio.speaker.std";
|
||||
|
||||
static constexpr size_t DMA_BUFFERS_COUNT = 4;
|
||||
static constexpr size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT + 1;
|
||||
// Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight,
|
||||
// doubled so that a transient backlog never overruns the queue (which would desync the lockstep
|
||||
// invariant between i2s_event_queue_ and write_records_queue_).
|
||||
static constexpr size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT * 2;
|
||||
// Generous timeout for ``i2s_channel_write`` blocking. A buffer frees roughly every
|
||||
// DMA_BUFFER_DURATION_MS, so a multiple of that gives plenty of slack against scheduling jitter
|
||||
// without masking real failures.
|
||||
static constexpr TickType_t WRITE_TIMEOUT_TICKS = pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS * (DMA_BUFFERS_COUNT + 1));
|
||||
|
||||
void I2SAudioSpeaker::dump_config() {
|
||||
I2SAudioSpeakerBase::dump_config();
|
||||
@@ -44,31 +51,78 @@ void I2SAudioSpeaker::run_speaker_task() {
|
||||
const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_);
|
||||
|
||||
// The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info
|
||||
const size_t ring_buffer_size = this->current_stream_info_.ms_to_bytes(ring_buffer_duration);
|
||||
const uint32_t frames_to_fill_single_dma_buffer = this->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS);
|
||||
const size_t bytes_to_fill_single_dma_buffer =
|
||||
this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer);
|
||||
const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1);
|
||||
// Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and
|
||||
// avoids unnecessary single-frame splices.
|
||||
const size_t ring_buffer_size =
|
||||
(this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame;
|
||||
const uint32_t frames_per_dma_buffer = this->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS);
|
||||
const size_t dma_buffer_bytes = this->current_stream_info_.frames_to_bytes(frames_per_dma_buffer);
|
||||
|
||||
bool successful_setup = false;
|
||||
std::unique_ptr<audio::AudioSourceTransferBuffer> transfer_buffer =
|
||||
audio::AudioSourceTransferBuffer::create(bytes_to_fill_single_dma_buffer);
|
||||
|
||||
if (transfer_buffer != nullptr) {
|
||||
std::unique_ptr<audio::RingBufferAudioSource> audio_source;
|
||||
|
||||
// Pre-zeroed buffer used to silence-pad each DMA descriptor whenever real audio doesn't fully fill it.
|
||||
RAMAllocator<uint8_t> silence_allocator;
|
||||
uint8_t *silence_buffer = silence_allocator.allocate(dma_buffer_bytes);
|
||||
|
||||
if (silence_buffer != nullptr) {
|
||||
memset(silence_buffer, 0, dma_buffer_bytes);
|
||||
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
|
||||
if (temp_ring_buffer.use_count() == 1) {
|
||||
transfer_buffer->set_source(temp_ring_buffer);
|
||||
audio_source =
|
||||
audio::RingBufferAudioSource::create(temp_ring_buffer, dma_buffer_bytes, static_cast<uint8_t>(bytes_per_frame));
|
||||
|
||||
if (audio_source != nullptr) {
|
||||
// audio_source is nullptr if the ring buffer fails to allocate
|
||||
this->audio_ring_buffer_ = temp_ring_buffer;
|
||||
successful_setup = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (successful_setup) {
|
||||
// Preload every DMA descriptor with silence and push a matching zero-real-frames record per buffer.
|
||||
// This guarantees that every on_sent event has a corresponding write record from the start, so
|
||||
// ``i2s_event_queue_`` and ``write_records_queue_`` stay in lockstep for the entire task lifetime.
|
||||
for (size_t i = 0; i < DMA_BUFFERS_COUNT; i++) {
|
||||
size_t bytes_loaded = 0;
|
||||
esp_err_t err = i2s_channel_preload_data(this->tx_handle_, silence_buffer, dma_buffer_bytes, &bytes_loaded);
|
||||
if (err != ESP_OK || bytes_loaded != dma_buffer_bytes) {
|
||||
ESP_LOGV(TAG, "Failed to preload silence into DMA buffer %u (err=%d, loaded=%u)", (unsigned) i, (int) err,
|
||||
(unsigned) bytes_loaded);
|
||||
successful_setup = false;
|
||||
break;
|
||||
}
|
||||
uint32_t zero_real_frames = 0;
|
||||
if (xQueueSend(this->write_records_queue_, &zero_real_frames, 0) != pdTRUE) {
|
||||
// Should never happen: the queue was just reset and is sized for DMA_BUFFERS_COUNT * 2 entries.
|
||||
ESP_LOGV(TAG, "Failed to push preload write record");
|
||||
successful_setup = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (successful_setup) {
|
||||
// Register the on_sent callback BEFORE enabling the channel so the very first transmitted buffer
|
||||
// generates a queued event that pairs with the first preloaded silence record.
|
||||
const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb};
|
||||
i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this);
|
||||
|
||||
if (i2s_channel_enable(this->tx_handle_) != ESP_OK) {
|
||||
ESP_LOGV(TAG, "Failed to enable I2S channel");
|
||||
successful_setup = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!successful_setup) {
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM);
|
||||
} else {
|
||||
bool stop_gracefully = false;
|
||||
bool tx_dma_underflow = true;
|
||||
|
||||
uint32_t frames_written = 0;
|
||||
// Number of records currently in ``write_records_queue_`` that carry real audio. Used by graceful
|
||||
// stop to wait until every real-audio buffer has been confirmed played by an ISR event.
|
||||
uint32_t pending_real_buffers = 0;
|
||||
uint32_t last_data_received_time = millis();
|
||||
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING);
|
||||
@@ -77,11 +131,21 @@ void I2SAudioSpeaker::run_speaker_task() {
|
||||
// - Paused, OR
|
||||
// - No timeout configured, OR
|
||||
// - Timeout hasn't elapsed since last data
|
||||
//
|
||||
// Always-fill model: every iteration writes exactly one DMA buffer's worth, mixing real audio
|
||||
// and silence padding as needed. The blocking ``i2s_channel_write`` paces the loop at the DMA
|
||||
// consumption rate, and every buffer write is matched 1:1 with a record on ``write_records_queue_``.
|
||||
//
|
||||
// While paused, the real-audio fill is skipped and the entire DMA buffer is filled with silence;
|
||||
// the same blocking ``i2s_channel_write`` provides natural pacing (one buffer per ~DMA_BUFFER_DURATION_MS),
|
||||
// so the lockstep invariant is preserved without burning CPU.
|
||||
while (this->pause_state_ || !this->timeout_.has_value() ||
|
||||
(millis() - last_data_received_time) <= this->timeout_.value()) {
|
||||
uint32_t event_group_bits = xEventGroupGetBits(this->event_group_);
|
||||
|
||||
if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) {
|
||||
// COMMAND_STOP is set both by user-initiated stop() and by the ISR when it drops a completion
|
||||
// event (paired with ERR_DROPPED_EVENT so loop() can distinguish the two cases).
|
||||
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP);
|
||||
ESP_LOGV(TAG, "Exiting: COMMAND_STOP received");
|
||||
break;
|
||||
@@ -97,89 +161,126 @@ void I2SAudioSpeaker::run_speaker_task() {
|
||||
break;
|
||||
}
|
||||
|
||||
// Drain ISR-stamped completion events. Each event corresponds 1:1 with a write_records_queue_
|
||||
// entry by construction (preloaded records at startup, plus exactly one record pushed per
|
||||
// iteration alongside exactly one DMA-buffer-sized write).
|
||||
int64_t write_timestamp;
|
||||
bool lockstep_broken = false;
|
||||
while (xQueueReceive(this->i2s_event_queue_, &write_timestamp, 0)) {
|
||||
// Receives timing events from the I2S on_sent callback. If actual audio data was sent in this event, it passes
|
||||
// on the timing info via the audio_output_callback.
|
||||
uint32_t frames_sent = frames_to_fill_single_dma_buffer;
|
||||
if (frames_to_fill_single_dma_buffer > frames_written) {
|
||||
tx_dma_underflow = true;
|
||||
frames_sent = frames_written;
|
||||
const uint32_t frames_zeroed = frames_to_fill_single_dma_buffer - frames_written;
|
||||
write_timestamp -= this->current_stream_info_.frames_to_microseconds(frames_zeroed);
|
||||
} else {
|
||||
tx_dma_underflow = false;
|
||||
}
|
||||
frames_written -= frames_sent;
|
||||
|
||||
// Standard I2S mode: fire callback immediately for each event
|
||||
if (frames_sent > 0) {
|
||||
this->audio_output_callback_(frames_sent, write_timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
if (this->pause_state_) {
|
||||
// Pause state is accessed atomically, so thread safe
|
||||
// Delay so the task yields, then skip transferring audio data
|
||||
vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wait half the duration of the data already written to the DMA buffers for new audio data
|
||||
// The millisecond helper modifies the frames_written variable, so use the microsecond helper and divide by 1000
|
||||
uint32_t read_delay = (this->current_stream_info_.frames_to_microseconds(frames_written) / 1000) / 2;
|
||||
|
||||
size_t bytes_read = transfer_buffer->transfer_data_from_source(pdMS_TO_TICKS(read_delay));
|
||||
uint8_t *new_data = transfer_buffer->get_buffer_end() - bytes_read;
|
||||
|
||||
if (bytes_read > 0) {
|
||||
this->apply_software_volume_(new_data, bytes_read);
|
||||
this->swap_esp32_mono_samples_(new_data, bytes_read);
|
||||
}
|
||||
|
||||
if (transfer_buffer->available() == 0) {
|
||||
if (stop_gracefully && tx_dma_underflow) {
|
||||
uint32_t real_frames = 0;
|
||||
if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) {
|
||||
// Should never happen: would indicate the lockstep invariant is broken.
|
||||
ESP_LOGV(TAG, "Event without matching write record");
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC);
|
||||
lockstep_broken = true;
|
||||
break;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS / 2));
|
||||
} else {
|
||||
size_t bytes_written = 0;
|
||||
|
||||
if (tx_dma_underflow) {
|
||||
// Temporarily disable channel and callback to reset the I2S driver's internal DMA buffer queue
|
||||
i2s_channel_disable(this->tx_handle_);
|
||||
const i2s_event_callbacks_t null_callbacks = {.on_sent = nullptr};
|
||||
i2s_channel_register_event_callback(this->tx_handle_, &null_callbacks, this);
|
||||
i2s_channel_preload_data(this->tx_handle_, transfer_buffer->get_buffer_start(), transfer_buffer->available(),
|
||||
&bytes_written);
|
||||
} else {
|
||||
// Audio is already playing, use regular write to add to the DMA buffers
|
||||
i2s_channel_write(this->tx_handle_, transfer_buffer->get_buffer_start(), transfer_buffer->available(),
|
||||
&bytes_written, DMA_BUFFER_DURATION_MS);
|
||||
if (real_frames > 0) {
|
||||
pending_real_buffers--;
|
||||
// Real audio is packed at the start of each DMA buffer with any silence padding on the
|
||||
// tail, so the real audio finished playing earlier than the buffer-completion timestamp
|
||||
// by the duration of the trailing zeros.
|
||||
const uint32_t silence_frames = frames_per_dma_buffer - real_frames;
|
||||
const int64_t adjusted_ts =
|
||||
write_timestamp - this->current_stream_info_.frames_to_microseconds(silence_frames);
|
||||
this->audio_output_callback_(real_frames, adjusted_ts);
|
||||
}
|
||||
}
|
||||
if (lockstep_broken) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (bytes_written > 0) {
|
||||
last_data_received_time = millis();
|
||||
frames_written += this->current_stream_info_.bytes_to_frames(bytes_written);
|
||||
transfer_buffer->decrease_buffer_length(bytes_written);
|
||||
// Graceful stop: exit only after the source's exposed chunk is drained, the underlying ring
|
||||
// buffer has nothing left to hand over, and every real-audio buffer we submitted has been
|
||||
// confirmed played. ``has_buffered_data()`` returns bytes still sitting in the ring buffer
|
||||
// awaiting fill().
|
||||
if (stop_gracefully && audio_source->available() == 0 && !this->has_buffered_data() &&
|
||||
pending_real_buffers == 0) {
|
||||
ESP_LOGV(TAG, "Exiting: graceful stop complete");
|
||||
break;
|
||||
}
|
||||
|
||||
if (tx_dma_underflow) {
|
||||
tx_dma_underflow = false;
|
||||
// Enable the on_sent callback and channel after preload
|
||||
xQueueReset(this->i2s_event_queue_);
|
||||
const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb};
|
||||
i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this);
|
||||
i2s_channel_enable(this->tx_handle_);
|
||||
// Compose exactly one DMA buffer's worth: drain as much real audio as the source currently
|
||||
// exposes (may take multiple fill() calls when crossing a ring buffer wrap), then pad any
|
||||
// remainder with silence. All writes pack into the next free DMA descriptor in order, so the
|
||||
// descriptor ends up holding [real audio][silence padding].
|
||||
size_t bytes_written_total = 0;
|
||||
size_t real_bytes_total = 0;
|
||||
bool partial_write_failure = false;
|
||||
|
||||
if (!this->pause_state_) {
|
||||
while (bytes_written_total < dma_buffer_bytes) {
|
||||
size_t bytes_read = audio_source->fill(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS) / 2, false);
|
||||
if (bytes_read > 0) {
|
||||
uint8_t *new_data = audio_source->mutable_data() + audio_source->available() - bytes_read;
|
||||
this->apply_software_volume_(new_data, bytes_read);
|
||||
this->swap_esp32_mono_samples_(new_data, bytes_read);
|
||||
}
|
||||
|
||||
const size_t to_write = std::min(audio_source->available(), dma_buffer_bytes - bytes_written_total);
|
||||
if (to_write == 0) {
|
||||
// Ring buffer has nothing more to hand over right now; pad the rest of this DMA buffer
|
||||
// with silence so the lockstep invariant (one write per iteration) is preserved.
|
||||
break;
|
||||
}
|
||||
|
||||
size_t bw = 0;
|
||||
i2s_channel_write(this->tx_handle_, audio_source->data(), to_write, &bw, WRITE_TIMEOUT_TICKS);
|
||||
if (bw != to_write) {
|
||||
// A short real-audio write breaks DMA descriptor alignment for every subsequent event;
|
||||
// the only safe recovery is to restart the task.
|
||||
ESP_LOGV(TAG, "Partial real audio write: %u of %u bytes", (unsigned) bw, (unsigned) to_write);
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE);
|
||||
partial_write_failure = true;
|
||||
break;
|
||||
}
|
||||
audio_source->consume(bw);
|
||||
bytes_written_total += bw;
|
||||
real_bytes_total += bw;
|
||||
}
|
||||
if (real_bytes_total > 0) {
|
||||
last_data_received_time = millis();
|
||||
}
|
||||
}
|
||||
|
||||
if (partial_write_failure) {
|
||||
break;
|
||||
}
|
||||
|
||||
const size_t silence_bytes = dma_buffer_bytes - bytes_written_total;
|
||||
if (silence_bytes > 0) {
|
||||
size_t bw = 0;
|
||||
i2s_channel_write(this->tx_handle_, silence_buffer, silence_bytes, &bw, WRITE_TIMEOUT_TICKS);
|
||||
if (bw != silence_bytes) {
|
||||
// Same descriptor-alignment hazard as a partial real-audio write.
|
||||
ESP_LOGV(TAG, "Partial silence write: %u of %u bytes", (unsigned) bw, (unsigned) silence_bytes);
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const uint32_t real_frames_in_buffer = this->current_stream_info_.bytes_to_frames(real_bytes_total);
|
||||
// Push the matching write record. Capacity headroom in I2S_EVENT_QUEUE_COUNT guarantees this
|
||||
// succeeds even with a transient backlog of unprocessed events; if it ever fails the lockstep
|
||||
// invariant is broken and every subsequent timestamp would be silently wrong, so bail.
|
||||
if (xQueueSend(this->write_records_queue_, &real_frames_in_buffer, 0) != pdTRUE) {
|
||||
ESP_LOGV(TAG, "Exiting: write records queue full");
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC);
|
||||
break;
|
||||
}
|
||||
if (real_frames_in_buffer > 0) {
|
||||
pending_real_buffers++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING);
|
||||
|
||||
if (transfer_buffer != nullptr) {
|
||||
transfer_buffer.reset();
|
||||
audio_source.reset();
|
||||
|
||||
if (silence_buffer != nullptr) {
|
||||
silence_allocator.deallocate(silence_buffer, dma_buffer_bytes);
|
||||
silence_buffer = nullptr;
|
||||
}
|
||||
|
||||
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPED);
|
||||
@@ -300,7 +401,7 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream
|
||||
return err;
|
||||
}
|
||||
|
||||
i2s_channel_enable(this->tx_handle_);
|
||||
// The speaker task will enable the channel after preloading.
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@ void RfProxy::setup() {
|
||||
void RfProxy::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"RF Proxy '%s'\n"
|
||||
" Backend: remote_transmitter/receiver\n"
|
||||
" Supports Transmitter: %s\n"
|
||||
" Supports Receiver: %s",
|
||||
this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()),
|
||||
@@ -124,7 +123,9 @@ void RfProxy::dump_config() {
|
||||
}
|
||||
|
||||
void RfProxy::control(const radio_frequency::RadioFrequencyCall &call) {
|
||||
// RF: no IR carrier modulation
|
||||
// RF: no IR carrier modulation. Any RF front-end coordination (state turnaround, retuning)
|
||||
// happens via the radio_frequency entity's on_control trigger and remote_transmitter's
|
||||
// on_transmit/on_complete triggers — wired up in user YAML.
|
||||
transmit_raw_timings(this->transmitter_, 0, call);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,10 @@ class IrRfProxy : public infrared::Infrared {
|
||||
#endif // USE_IR_RF
|
||||
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
/// RfProxy - Radio Frequency platform implementation using remote_transmitter/receiver as backend
|
||||
/// RfProxy - Radio Frequency platform implementation using remote_transmitter/receiver as backend.
|
||||
/// Driver-agnostic: integration with specific RF front-end chips (CC1101, RFM69, etc.) is done
|
||||
/// in YAML by wiring their actions to `remote_transmitter`'s on_transmit/on_complete triggers and
|
||||
/// to this entity's on_control trigger (see radio_frequency component docs).
|
||||
class RfProxy : public radio_frequency::RadioFrequency {
|
||||
public:
|
||||
RfProxy() = default;
|
||||
|
||||
@@ -35,17 +35,19 @@ def _final_validate(config: ConfigType) -> None:
|
||||
if CONF_REMOTE_TRANSMITTER_ID not in config:
|
||||
return
|
||||
|
||||
transmitter_id = config[CONF_REMOTE_TRANSMITTER_ID]
|
||||
full_config = fv.full_config.get()
|
||||
transmitter_path = full_config.get_path_for_id(transmitter_id)[:-1]
|
||||
transmitter_path = full_config.get_path_for_id(config[CONF_REMOTE_TRANSMITTER_ID])[
|
||||
:-1
|
||||
]
|
||||
transmitter_config = full_config.get_config_for_path(transmitter_path)
|
||||
|
||||
duty_percent = transmitter_config.get(CONF_CARRIER_DUTY_PERCENT)
|
||||
if duty_percent is not None and duty_percent != 100:
|
||||
raise cv.Invalid(
|
||||
f"Transmitter '{transmitter_id}' must have '{CONF_CARRIER_DUTY_PERCENT}' "
|
||||
"set to 100% for RF transmission. Dedicated RF hardware handles modulation; "
|
||||
"applying a carrier duty cycle would corrupt the signal"
|
||||
f"Transmitter '{config[CONF_REMOTE_TRANSMITTER_ID]}' must have "
|
||||
f"'{CONF_CARRIER_DUTY_PERCENT}' set to 100% for RF transmission. "
|
||||
"Dedicated RF hardware handles modulation; applying a carrier duty cycle "
|
||||
"would corrupt the signal"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -23,7 +23,13 @@ static const size_t DATA_TIMEOUT_MS = 50;
|
||||
|
||||
static const uint32_t RING_BUFFER_DURATION_MS = 120;
|
||||
|
||||
#ifdef CONFIG_IDF_TARGET_ESP32P4
|
||||
// ESP32-P4 PIE-optimized esp-nn kernels (e.g. depthwise_conv_s8_ch1_pie) require
|
||||
// significantly more stack than other variants, causing stack protection faults at 3072.
|
||||
static const uint32_t INFERENCE_TASK_STACK_SIZE = 8192;
|
||||
#else
|
||||
static const uint32_t INFERENCE_TASK_STACK_SIZE = 3072;
|
||||
#endif
|
||||
static const UBaseType_t INFERENCE_TASK_PRIORITY = 3;
|
||||
|
||||
enum EventGroupBits : uint32_t {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import climate, uart
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_UPDATE_INTERVAL
|
||||
from esphome.types import ConfigType
|
||||
from esphome.const import CONF_ID, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType, TemplateArgsType
|
||||
|
||||
DEPENDENCIES = ["uart"]
|
||||
AUTO_LOAD = ["climate"]
|
||||
@@ -19,6 +22,18 @@ MitsubishiCN105Climate = mitsubishi_ns.class_(
|
||||
uart.UARTDevice,
|
||||
)
|
||||
|
||||
SetRemoteTemperatureAction = mitsubishi_ns.class_(
|
||||
"SetRemoteTemperatureAction",
|
||||
automation.Action,
|
||||
cg.Parented.template(MitsubishiCN105Climate),
|
||||
)
|
||||
|
||||
ClearRemoteTemperatureAction = mitsubishi_ns.class_(
|
||||
"ClearRemoteTemperatureAction",
|
||||
automation.Action,
|
||||
cg.Parented.template(MitsubishiCN105Climate),
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
climate.climate_schema(MitsubishiCN105Climate)
|
||||
.extend(uart.UART_DEVICE_SCHEMA)
|
||||
@@ -53,3 +68,55 @@ async def to_code(config: ConfigType) -> None:
|
||||
config[CONF_CURRENT_TEMPERATURE_MIN_INTERVAL]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"climate.mitsubishi_cn105.set_remote_temperature",
|
||||
SetRemoteTemperatureAction,
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate),
|
||||
cv.Required(CONF_TEMPERATURE): cv.templatable(
|
||||
cv.All(
|
||||
cv.temperature,
|
||||
cv.Range(min=8.0, max=39.5),
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def set_remote_temperature_action_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
|
||||
temperature = await cg.templatable(config[CONF_TEMPERATURE], args, float)
|
||||
cg.add(var.set_temperature(temperature))
|
||||
|
||||
return var
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"climate.mitsubishi_cn105.clear_remote_temperature",
|
||||
ClearRemoteTemperatureAction,
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def clear_remote_temperature_action_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
return var
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <numeric>
|
||||
@@ -92,6 +93,7 @@ void MitsubishiCN105::initialize() { this->set_state_(State::CONNECTING); }
|
||||
bool MitsubishiCN105::update() {
|
||||
if (const auto start = this->status_update_start_ms_) {
|
||||
if (this->pending_updates_.any()) {
|
||||
this->status_update_wait_credit_ms_ = std::min(this->update_interval_ms_, get_loop_time_ms() - *start);
|
||||
this->cancel_waiting_and_transition_to_(State::APPLYING_SETTINGS);
|
||||
return false;
|
||||
}
|
||||
@@ -105,6 +107,7 @@ bool MitsubishiCN105::update() {
|
||||
if (const auto start = this->write_timeout_start_ms_; start && (get_loop_time_ms() - *start) >= WRITE_TIMEOUT_MS) {
|
||||
this->write_timeout_start_ms_.reset();
|
||||
this->frame_parser_.reset();
|
||||
this->status_update_wait_credit_ms_ = 0;
|
||||
this->set_state_(State::READ_TIMEOUT);
|
||||
return false;
|
||||
}
|
||||
@@ -191,14 +194,14 @@ void MitsubishiCN105::did_transition_(State to) {
|
||||
}
|
||||
|
||||
case State::SCHEDULE_NEXT_STATUS_UPDATE:
|
||||
this->status_update_start_ms_ = get_loop_time_ms();
|
||||
this->status_update_start_ms_ = get_loop_time_ms() - this->status_update_wait_credit_ms_;
|
||||
this->status_update_wait_credit_ms_ = 0;
|
||||
this->current_status_msg_type_ = STATUS_MSG_SETTINGS;
|
||||
this->set_state_(State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
|
||||
break;
|
||||
|
||||
case State::APPLYING_SETTINGS:
|
||||
this->apply_settings_();
|
||||
this->pending_updates_.clear();
|
||||
break;
|
||||
|
||||
case State::SETTINGS_APPLIED:
|
||||
@@ -309,21 +312,21 @@ bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len)
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this->pending_updates_.has(UpdateFlag::POWER)) {
|
||||
if (!this->pending_updates_.contains(UpdateFlag::POWER)) {
|
||||
this->status_.power_on = payload[2] != 0;
|
||||
}
|
||||
|
||||
this->use_temperature_encoding_b_ = payload[10] != 0;
|
||||
if (!this->pending_updates_.has(UpdateFlag::TEMPERATURE)) {
|
||||
if (!this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) {
|
||||
this->status_.target_temperature = decode_temperature(-payload[4], payload[10], TARGET_TEMPERATURE_ENC_A_OFFSET);
|
||||
}
|
||||
|
||||
if (!this->pending_updates_.has(UpdateFlag::MODE)) {
|
||||
if (!this->pending_updates_.contains(UpdateFlag::MODE)) {
|
||||
const bool i_see = payload[3] > 0x08;
|
||||
this->status_.mode = lookup(PROTOCOL_MODE_MAP, payload[3] - (i_see ? 0x08 : 0)).value_or(Mode::UNKNOWN);
|
||||
}
|
||||
|
||||
if (!this->pending_updates_.has(UpdateFlag::FAN)) {
|
||||
if (!this->pending_updates_.contains(UpdateFlag::FAN)) {
|
||||
this->status_.fan_mode = lookup(PROTOCOL_FAN_MODE_MAP, payload[5]).value_or(FanMode::UNKNOWN);
|
||||
}
|
||||
|
||||
@@ -342,6 +345,27 @@ bool MitsubishiCN105::parse_status_room_temperature_(const uint8_t *payload, siz
|
||||
return true;
|
||||
}
|
||||
|
||||
void MitsubishiCN105::set_remote_temperature(float temperature) {
|
||||
if (std::isnan(temperature)) {
|
||||
ESP_LOGD(TAG, "Ignoring NaN remote temperature");
|
||||
return;
|
||||
}
|
||||
if (temperature < 8.0f || temperature > 39.5f) {
|
||||
ESP_LOGD(TAG, "Ignoring out-of-range remote temperature: %.1f", temperature);
|
||||
return;
|
||||
}
|
||||
this->set_remote_temperature_half_deg_(static_cast<uint8_t>(std::round(temperature * 2.0f)));
|
||||
}
|
||||
|
||||
void MitsubishiCN105::clear_remote_temperature() {
|
||||
this->set_remote_temperature_half_deg_(REMOTE_TEMPERATURE_DISABLED);
|
||||
}
|
||||
|
||||
void MitsubishiCN105::set_remote_temperature_half_deg_(uint8_t temperature_half_deg) {
|
||||
this->remote_temperature_half_deg_ = temperature_half_deg;
|
||||
this->pending_updates_.set(UpdateFlag::REMOTE_TEMPERATURE);
|
||||
}
|
||||
|
||||
void MitsubishiCN105::set_power(bool power_on) {
|
||||
this->status_.power_on = power_on;
|
||||
this->pending_updates_.set(UpdateFlag::POWER);
|
||||
@@ -377,30 +401,47 @@ void MitsubishiCN105::set_fan_mode(FanMode fan_mode) {
|
||||
}
|
||||
|
||||
void MitsubishiCN105::apply_settings_() {
|
||||
std::array<uint8_t, REQUEST_PAYLOAD_LEN> payload = {0x01};
|
||||
std::array<uint8_t, REQUEST_PAYLOAD_LEN> payload{};
|
||||
|
||||
if (this->pending_updates_.has(UpdateFlag::POWER)) {
|
||||
payload[1] |= 0x01;
|
||||
payload[3] = this->status_.power_on ? 0x01 : 0x00;
|
||||
}
|
||||
|
||||
if (this->pending_updates_.has(UpdateFlag::TEMPERATURE)) {
|
||||
payload[1] |= 0x04;
|
||||
if (this->use_temperature_encoding_b_) {
|
||||
payload[14] = static_cast<uint8_t>(std::round(this->status_.target_temperature * 2.0f) + 128);
|
||||
// Apply all other pending settings first; handle REMOTE_TEMPERATURE last
|
||||
if (this->pending_updates_.contains_only(UpdateFlag::REMOTE_TEMPERATURE)) {
|
||||
payload[0] = 0x07;
|
||||
if (this->remote_temperature_half_deg_ == REMOTE_TEMPERATURE_DISABLED) {
|
||||
payload[3] = 0x80;
|
||||
} else {
|
||||
payload[5] = static_cast<uint8_t>(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(this->status_.target_temperature));
|
||||
payload[1] = 0x01;
|
||||
payload[2] = static_cast<uint8_t>(this->remote_temperature_half_deg_ - 16);
|
||||
payload[3] = static_cast<uint8_t>(this->remote_temperature_half_deg_ + 128);
|
||||
}
|
||||
this->pending_updates_.clear(UpdateFlag::REMOTE_TEMPERATURE);
|
||||
} else {
|
||||
payload[0] = 0x01;
|
||||
if (this->pending_updates_.contains(UpdateFlag::POWER)) {
|
||||
payload[1] |= 0x01;
|
||||
payload[3] = this->status_.power_on ? 0x01 : 0x00;
|
||||
}
|
||||
}
|
||||
|
||||
if (this->pending_updates_.has(UpdateFlag::MODE) &&
|
||||
reverse_lookup(PROTOCOL_MODE_MAP, this->status_.mode, payload[4])) {
|
||||
payload[1] |= 0x02;
|
||||
}
|
||||
if (this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) {
|
||||
payload[1] |= 0x04;
|
||||
if (this->use_temperature_encoding_b_) {
|
||||
payload[14] = static_cast<uint8_t>(std::round(this->status_.target_temperature * 2.0f) + 128);
|
||||
} else {
|
||||
payload[5] =
|
||||
static_cast<uint8_t>(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(this->status_.target_temperature));
|
||||
}
|
||||
}
|
||||
|
||||
if (this->pending_updates_.has(UpdateFlag::FAN) &&
|
||||
reverse_lookup(PROTOCOL_FAN_MODE_MAP, this->status_.fan_mode, payload[6])) {
|
||||
payload[1] |= 0x08;
|
||||
if (this->pending_updates_.contains(UpdateFlag::MODE) &&
|
||||
reverse_lookup(PROTOCOL_MODE_MAP, this->status_.mode, payload[4])) {
|
||||
payload[1] |= 0x02;
|
||||
}
|
||||
|
||||
if (this->pending_updates_.contains(UpdateFlag::FAN) &&
|
||||
reverse_lookup(PROTOCOL_FAN_MODE_MAP, this->status_.fan_mode, payload[6])) {
|
||||
payload[1] |= 0x08;
|
||||
}
|
||||
|
||||
this->pending_updates_.clear(UpdateFlag::POWER, UpdateFlag::TEMPERATURE, UpdateFlag::MODE, UpdateFlag::FAN);
|
||||
}
|
||||
|
||||
this->send_packet_(make_packet(PACKET_TYPE_WRITE_SETTINGS_REQUEST, payload));
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <optional>
|
||||
#include "esphome/components/uart/uart.h"
|
||||
#include "esphome/core/finite_set_mask.h"
|
||||
|
||||
namespace esphome::mitsubishi_cn105 {
|
||||
|
||||
@@ -60,6 +61,8 @@ class MitsubishiCN105 {
|
||||
void set_target_temperature(float target_temperature);
|
||||
void set_mode(Mode mode);
|
||||
void set_fan_mode(FanMode fan_mode);
|
||||
void set_remote_temperature(float temperature);
|
||||
void clear_remote_temperature();
|
||||
|
||||
protected:
|
||||
enum class State : uint8_t {
|
||||
@@ -91,20 +94,25 @@ class MitsubishiCN105 {
|
||||
};
|
||||
|
||||
enum class UpdateFlag : uint8_t {
|
||||
TEMPERATURE = 1 << 0,
|
||||
POWER = 1 << 1,
|
||||
MODE = 1 << 2,
|
||||
FAN = 1 << 3,
|
||||
TEMPERATURE = 0,
|
||||
POWER = 1,
|
||||
MODE = 2,
|
||||
FAN = 3,
|
||||
REMOTE_TEMPERATURE = 4,
|
||||
};
|
||||
|
||||
struct UpdateFlags {
|
||||
void set(UpdateFlag f) { flags_ |= static_cast<uint8_t>(f); }
|
||||
void clear() { flags_ = 0; }
|
||||
bool any() const { return flags_ != 0; }
|
||||
bool has(UpdateFlag f) const { return (flags_ & static_cast<uint8_t>(f)) != 0; }
|
||||
template<typename... Flags> void set(Flags... flags) { (this->mask_.insert(flags), ...); }
|
||||
template<typename... Flags> void clear(Flags... flags) { (this->mask_.erase(flags), ...); }
|
||||
bool any() const { return !this->mask_.empty(); }
|
||||
bool contains(UpdateFlag flag) const { return this->mask_.count(flag); }
|
||||
bool contains_only(UpdateFlag flag) const { return this->mask_.get_mask() == Mask{flag}.get_mask(); }
|
||||
|
||||
protected:
|
||||
uint8_t flags_{0};
|
||||
using Mask =
|
||||
FiniteSetMask<UpdateFlag, DefaultBitPolicy<UpdateFlag, static_cast<int>(UpdateFlag::REMOTE_TEMPERATURE) + 1>>;
|
||||
|
||||
Mask mask_;
|
||||
};
|
||||
|
||||
void set_state_(State new_state);
|
||||
@@ -119,12 +127,14 @@ class MitsubishiCN105 {
|
||||
void cancel_waiting_and_transition_to_(State state);
|
||||
bool should_request_room_temperature_() const;
|
||||
void apply_settings_();
|
||||
void set_remote_temperature_half_deg_(uint8_t temperature_half_deg);
|
||||
template<typename T> void send_packet_(const T &packet) { this->send_packet_(packet.data(), packet.size()); }
|
||||
static bool should_transition(State from, State to);
|
||||
static const LogString *state_to_string(State state);
|
||||
|
||||
uart::UARTDevice &device_;
|
||||
uint32_t update_interval_ms_{1000};
|
||||
uint32_t status_update_wait_credit_ms_{0};
|
||||
uint32_t room_temperature_min_interval_ms_{60000};
|
||||
std::optional<uint32_t> write_timeout_start_ms_;
|
||||
std::optional<uint32_t> status_update_start_ms_;
|
||||
@@ -133,8 +143,11 @@ class MitsubishiCN105 {
|
||||
State state_{State::NOT_CONNECTED};
|
||||
UpdateFlags pending_updates_;
|
||||
bool use_temperature_encoding_b_{false};
|
||||
uint8_t current_status_msg_type_{0};
|
||||
FrameParser frame_parser_;
|
||||
uint8_t current_status_msg_type_{0};
|
||||
|
||||
static constexpr uint8_t REMOTE_TEMPERATURE_DISABLED = 0;
|
||||
uint8_t remote_temperature_half_deg_{REMOTE_TEMPERATURE_DISABLED};
|
||||
};
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/climate/climate.h"
|
||||
#include "esphome/components/uart/uart.h"
|
||||
@@ -18,8 +19,11 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public
|
||||
climate::ClimateTraits traits() override;
|
||||
void control(const climate::ClimateCall &call) override;
|
||||
|
||||
void set_update_interval(uint32_t ms) { hp_.set_update_interval(ms); }
|
||||
void set_current_temperature_min_interval(uint32_t ms) { hp_.set_room_temperature_min_interval(ms); }
|
||||
void set_update_interval(uint32_t ms) { this->hp_.set_update_interval(ms); }
|
||||
void set_current_temperature_min_interval(uint32_t ms) { this->hp_.set_room_temperature_min_interval(ms); }
|
||||
|
||||
void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); }
|
||||
void clear_remote_temperature() { this->hp_.clear_remote_temperature(); }
|
||||
|
||||
protected:
|
||||
void apply_values_();
|
||||
@@ -27,4 +31,18 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public
|
||||
MitsubishiCN105 hp_;
|
||||
};
|
||||
|
||||
template<typename... Ts>
|
||||
class SetRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(float, temperature)
|
||||
|
||||
void play(const Ts &...x) override { this->parent_->set_remote_temperature(this->temperature_.value(x...)); }
|
||||
};
|
||||
|
||||
template<typename... Ts>
|
||||
class ClearRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
|
||||
public:
|
||||
void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); }
|
||||
};
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105
|
||||
|
||||
@@ -182,7 +182,7 @@ void SourceSpeaker::loop() {
|
||||
break;
|
||||
}
|
||||
case speaker::STATE_RUNNING:
|
||||
if (!this->transfer_buffer_->has_buffered_data() &&
|
||||
if (!this->audio_source_->has_buffered_data() &&
|
||||
(this->pending_playback_frames_.load(std::memory_order_acquire) == 0)) {
|
||||
// No audio data in buffer waiting to get mixed and no frames are pending playback
|
||||
if ((this->timeout_ms_.has_value() && ((millis() - this->last_seen_data_ms_) > this->timeout_ms_.value())) ||
|
||||
@@ -254,15 +254,12 @@ void SourceSpeaker::send_command_(uint32_t command_bit, bool wake_loop) {
|
||||
void SourceSpeaker::start() { this->send_command_(SOURCE_SPEAKER_COMMAND_START, true); }
|
||||
|
||||
esp_err_t SourceSpeaker::start_() {
|
||||
const size_t ring_buffer_size = this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_);
|
||||
if (this->transfer_buffer_.use_count() == 0) {
|
||||
this->transfer_buffer_ =
|
||||
audio::AudioSourceTransferBuffer::create(this->audio_stream_info_.ms_to_bytes(TRANSFER_BUFFER_DURATION_MS));
|
||||
|
||||
if (this->transfer_buffer_ == nullptr) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
const size_t bytes_per_frame = this->audio_stream_info_.frames_to_bytes(1);
|
||||
// Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and
|
||||
// avoids unnecessary single-frame splices.
|
||||
const size_t ring_buffer_size =
|
||||
(this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame;
|
||||
if (this->audio_source_.use_count() == 0) {
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||
if (!temp_ring_buffer) {
|
||||
temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
|
||||
@@ -271,9 +268,15 @@ esp_err_t SourceSpeaker::start_() {
|
||||
|
||||
if (!temp_ring_buffer) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
} else {
|
||||
this->transfer_buffer_->set_source(temp_ring_buffer);
|
||||
}
|
||||
|
||||
std::unique_ptr<audio::RingBufferAudioSource> source = audio::RingBufferAudioSource::create(
|
||||
temp_ring_buffer, this->audio_stream_info_.ms_to_bytes(TRANSFER_BUFFER_DURATION_MS),
|
||||
static_cast<uint8_t>(bytes_per_frame));
|
||||
if (source == nullptr) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
this->audio_source_ = std::move(source);
|
||||
}
|
||||
|
||||
return this->parent_->start(this->audio_stream_info_);
|
||||
@@ -284,7 +287,7 @@ void SourceSpeaker::stop() { this->send_command_(SOURCE_SPEAKER_COMMAND_STOP); }
|
||||
void SourceSpeaker::finish() { this->send_command_(SOURCE_SPEAKER_COMMAND_FINISH); }
|
||||
|
||||
bool SourceSpeaker::has_buffered_data() const {
|
||||
return ((this->transfer_buffer_.use_count() > 0) && this->transfer_buffer_->has_buffered_data());
|
||||
return ((this->audio_source_.use_count() > 0) && this->audio_source_->has_buffered_data());
|
||||
}
|
||||
|
||||
void SourceSpeaker::set_mute_state(bool mute_state) {
|
||||
@@ -301,16 +304,18 @@ void SourceSpeaker::set_volume(float volume) {
|
||||
|
||||
float SourceSpeaker::get_volume() { return this->parent_->get_output_speaker()->get_volume(); }
|
||||
|
||||
size_t SourceSpeaker::process_data_from_source(std::shared_ptr<audio::AudioSourceTransferBuffer> &transfer_buffer,
|
||||
size_t SourceSpeaker::process_data_from_source(std::shared_ptr<audio::RingBufferAudioSource> &audio_source,
|
||||
TickType_t ticks_to_wait) {
|
||||
// Store current offset, as these samples are already ducked
|
||||
const size_t current_length = transfer_buffer->available();
|
||||
if (audio_source->available() > 0) {
|
||||
// Existing exposure was ducked when fill() promoted it; do not re-duck on partial-consume re-entry.
|
||||
return 0;
|
||||
}
|
||||
|
||||
size_t bytes_read = transfer_buffer->transfer_data_from_source(ticks_to_wait);
|
||||
size_t bytes_read = audio_source->fill(ticks_to_wait, false);
|
||||
|
||||
uint32_t samples_to_duck = this->audio_stream_info_.bytes_to_samples(bytes_read);
|
||||
if (samples_to_duck > 0) {
|
||||
int16_t *current_buffer = reinterpret_cast<int16_t *>(transfer_buffer->get_buffer_start() + current_length);
|
||||
int16_t *current_buffer = reinterpret_cast<int16_t *>(audio_source->mutable_data());
|
||||
|
||||
duck_samples(current_buffer, samples_to_duck, &this->current_ducking_db_reduction_,
|
||||
&this->ducking_transition_samples_remaining_, this->samples_per_ducking_step_,
|
||||
@@ -406,7 +411,7 @@ void SourceSpeaker::duck_samples(int16_t *input_buffer, uint32_t input_samples_t
|
||||
void SourceSpeaker::enter_stopping_state_() {
|
||||
this->state_ = speaker::STATE_STOPPING;
|
||||
this->stopping_start_ms_ = millis();
|
||||
this->transfer_buffer_.reset();
|
||||
this->audio_source_.reset();
|
||||
}
|
||||
|
||||
void MixerSpeaker::dump_config() {
|
||||
@@ -612,9 +617,9 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
|
||||
// Pre-allocate vectors to avoid heap allocation in the loop (max 8 source speakers per schema)
|
||||
FixedVector<SourceSpeaker *> speakers_with_data;
|
||||
FixedVector<std::shared_ptr<audio::AudioSourceTransferBuffer>> transfer_buffers_with_data;
|
||||
FixedVector<std::shared_ptr<audio::RingBufferAudioSource>> audio_sources_with_data;
|
||||
speakers_with_data.init(this_mixer->source_speakers_.size());
|
||||
transfer_buffers_with_data.init(this_mixer->source_speakers_.size());
|
||||
audio_sources_with_data.init(this_mixer->source_speakers_.size());
|
||||
|
||||
while (true) {
|
||||
uint32_t event_group_bits = xEventGroupGetBits(this_mixer->event_group_);
|
||||
@@ -629,27 +634,27 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
this_mixer->audio_stream_info_.value().bytes_to_frames(output_transfer_buffer->free());
|
||||
|
||||
speakers_with_data.clear();
|
||||
transfer_buffers_with_data.clear();
|
||||
audio_sources_with_data.clear();
|
||||
|
||||
for (auto &speaker : this_mixer->source_speakers_) {
|
||||
if (speaker->is_running() && !speaker->get_pause_state()) {
|
||||
// Speaker is running and not paused, so it possibly can provide audio data
|
||||
std::shared_ptr<audio::AudioSourceTransferBuffer> transfer_buffer = speaker->get_transfer_buffer().lock();
|
||||
if (transfer_buffer.use_count() == 0) {
|
||||
// No transfer buffer allocated, so skip processing this speaker
|
||||
std::shared_ptr<audio::RingBufferAudioSource> audio_source = speaker->get_audio_source().lock();
|
||||
if (audio_source.use_count() == 0) {
|
||||
// No audio source allocated, so skip processing this speaker
|
||||
continue;
|
||||
}
|
||||
speaker->process_data_from_source(transfer_buffer, 0); // Transfers and ducks audio from source ring buffers
|
||||
speaker->process_data_from_source(audio_source, 0); // Exposes and ducks audio from source ring buffers
|
||||
|
||||
if (transfer_buffer->available() > 0) {
|
||||
// Store the locked transfer buffers in their own vector to avoid releasing ownership until after the loop
|
||||
transfer_buffers_with_data.push_back(transfer_buffer);
|
||||
if (audio_source->available() > 0) {
|
||||
// Retain shared ownership across the mixing pass so the source isn't released mid-mix
|
||||
audio_sources_with_data.push_back(audio_source);
|
||||
speakers_with_data.push_back(speaker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (transfer_buffers_with_data.empty()) {
|
||||
if (audio_sources_with_data.empty()) {
|
||||
// No audio available for transferring, block task temporarily
|
||||
delay(TASK_DELAY_MS);
|
||||
continue;
|
||||
@@ -657,7 +662,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
|
||||
uint32_t frames_to_mix = output_frames_free;
|
||||
|
||||
if ((transfer_buffers_with_data.size() == 1) || this_mixer->queue_mode_) {
|
||||
if ((audio_sources_with_data.size() == 1) || this_mixer->queue_mode_) {
|
||||
// Only one speaker has audio data, just copy samples over
|
||||
|
||||
audio::AudioStreamInfo active_stream_info = speakers_with_data[0]->get_audio_stream_info();
|
||||
@@ -667,10 +672,10 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
// Speaker's sample rate matches the output speaker's, copy directly
|
||||
|
||||
const uint32_t frames_available_in_buffer =
|
||||
active_stream_info.bytes_to_frames(transfer_buffers_with_data[0]->available());
|
||||
active_stream_info.bytes_to_frames(audio_sources_with_data[0]->available());
|
||||
frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer);
|
||||
copy_frames(reinterpret_cast<int16_t *>(transfer_buffers_with_data[0]->get_buffer_start()),
|
||||
active_stream_info, reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end()),
|
||||
copy_frames(reinterpret_cast<const int16_t *>(audio_sources_with_data[0]->data()), active_stream_info,
|
||||
reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end()),
|
||||
this_mixer->audio_stream_info_.value(), frames_to_mix);
|
||||
|
||||
// Set playback delay for newly contributing source
|
||||
@@ -682,7 +687,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
|
||||
// Update source speaker pending frames
|
||||
speakers_with_data[0]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release);
|
||||
transfer_buffers_with_data[0]->decrease_buffer_length(active_stream_info.frames_to_bytes(frames_to_mix));
|
||||
audio_sources_with_data[0]->consume(active_stream_info.frames_to_bytes(frames_to_mix));
|
||||
|
||||
// Update output transfer buffer length and pipeline frame count
|
||||
output_transfer_buffer->increase_buffer_length(
|
||||
@@ -709,25 +714,25 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
}
|
||||
} else {
|
||||
// Determine how many frames to mix
|
||||
for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) {
|
||||
const uint32_t frames_available_in_buffer = speakers_with_data[i]->get_audio_stream_info().bytes_to_frames(
|
||||
transfer_buffers_with_data[i]->available());
|
||||
for (size_t i = 0; i < audio_sources_with_data.size(); ++i) {
|
||||
const uint32_t frames_available_in_buffer =
|
||||
speakers_with_data[i]->get_audio_stream_info().bytes_to_frames(audio_sources_with_data[i]->available());
|
||||
frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer);
|
||||
}
|
||||
int16_t *primary_buffer = reinterpret_cast<int16_t *>(transfer_buffers_with_data[0]->get_buffer_start());
|
||||
const int16_t *primary_buffer = reinterpret_cast<const int16_t *>(audio_sources_with_data[0]->data());
|
||||
audio::AudioStreamInfo primary_stream_info = speakers_with_data[0]->get_audio_stream_info();
|
||||
|
||||
// Mix two streams together
|
||||
for (size_t i = 1; i < transfer_buffers_with_data.size(); ++i) {
|
||||
for (size_t i = 1; i < audio_sources_with_data.size(); ++i) {
|
||||
mix_audio_samples(primary_buffer, primary_stream_info,
|
||||
reinterpret_cast<int16_t *>(transfer_buffers_with_data[i]->get_buffer_start()),
|
||||
reinterpret_cast<const int16_t *>(audio_sources_with_data[i]->data()),
|
||||
speakers_with_data[i]->get_audio_stream_info(),
|
||||
reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end()),
|
||||
this_mixer->audio_stream_info_.value(), frames_to_mix);
|
||||
|
||||
if (i != transfer_buffers_with_data.size() - 1) {
|
||||
if (i != audio_sources_with_data.size() - 1) {
|
||||
// Need to mix more streams together, point primary buffer and stream info to the already mixed output
|
||||
primary_buffer = reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end());
|
||||
primary_buffer = reinterpret_cast<const int16_t *>(output_transfer_buffer->get_buffer_end());
|
||||
primary_stream_info = this_mixer->audio_stream_info_.value();
|
||||
}
|
||||
}
|
||||
@@ -735,8 +740,8 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
// Get current pipeline depth for delay calculation (before incrementing)
|
||||
uint32_t current_pipeline_frames = this_mixer->frames_in_pipeline_.load(std::memory_order_acquire);
|
||||
|
||||
// Update source transfer buffer lengths and add new audio durations to the source speaker pending playbacks
|
||||
for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) {
|
||||
// Update source audio source consumption and add new audio durations to the source speaker pending playbacks
|
||||
for (size_t i = 0; i < audio_sources_with_data.size(); ++i) {
|
||||
// Set playback delay for newly contributing sources
|
||||
if (!speakers_with_data[i]->has_contributed_.load(std::memory_order_acquire)) {
|
||||
speakers_with_data[i]->playback_delay_frames_.store(current_pipeline_frames, std::memory_order_release);
|
||||
@@ -744,7 +749,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
}
|
||||
|
||||
speakers_with_data[i]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release);
|
||||
transfer_buffers_with_data[i]->decrease_buffer_length(
|
||||
audio_sources_with_data[i]->consume(
|
||||
speakers_with_data[i]->get_audio_stream_info().frames_to_bytes(frames_to_mix));
|
||||
}
|
||||
|
||||
|
||||
@@ -67,11 +67,13 @@ class SourceSpeaker : public speaker::Speaker, public Component {
|
||||
void set_pause_state(bool pause_state) override { this->pause_state_ = pause_state; }
|
||||
bool get_pause_state() const override { return this->pause_state_; }
|
||||
|
||||
/// @brief Transfers audio from the ring buffer into the transfer buffer. Ducks audio while transferring.
|
||||
/// @param transfer_buffer Locked shared_ptr to the transfer buffer (must be valid, not null)
|
||||
/// @brief Exposes the next ring buffer chunk (zero-copy) and ducks the freshly exposed bytes in place.
|
||||
/// If the source still has bytes from a prior partial consume, this is a no-op (those bytes were already
|
||||
/// ducked on the fill that exposed them).
|
||||
/// @param audio_source Locked shared_ptr to the audio source (must be valid, not null)
|
||||
/// @param ticks_to_wait FreeRTOS ticks to wait while waiting to read from the ring buffer.
|
||||
/// @return Number of bytes transferred from the ring buffer.
|
||||
size_t process_data_from_source(std::shared_ptr<audio::AudioSourceTransferBuffer> &transfer_buffer,
|
||||
/// @return Number of bytes newly exposed from the ring buffer.
|
||||
size_t process_data_from_source(std::shared_ptr<audio::RingBufferAudioSource> &audio_source,
|
||||
TickType_t ticks_to_wait);
|
||||
|
||||
/// @brief Sets the ducking level for the source speaker.
|
||||
@@ -83,7 +85,7 @@ class SourceSpeaker : public speaker::Speaker, public Component {
|
||||
void set_parent(MixerSpeaker *parent) { this->parent_ = parent; }
|
||||
void set_timeout(uint32_t ms) { this->timeout_ms_ = ms; }
|
||||
|
||||
std::weak_ptr<audio::AudioSourceTransferBuffer> get_transfer_buffer() { return this->transfer_buffer_; }
|
||||
std::weak_ptr<audio::RingBufferAudioSource> get_audio_source() { return this->audio_source_; }
|
||||
|
||||
protected:
|
||||
friend class MixerSpeaker;
|
||||
@@ -106,7 +108,7 @@ class SourceSpeaker : public speaker::Speaker, public Component {
|
||||
|
||||
MixerSpeaker *parent_;
|
||||
|
||||
std::shared_ptr<audio::AudioSourceTransferBuffer> transfer_buffer_;
|
||||
std::shared_ptr<audio::RingBufferAudioSource> audio_source_;
|
||||
std::weak_ptr<ring_buffer::RingBuffer> ring_buffer_;
|
||||
|
||||
uint32_t buffer_duration_ms_;
|
||||
|
||||
@@ -8,9 +8,10 @@ breaking changes policy. Use at your own risk.
|
||||
Once the API is considered stable, this warning will be removed.
|
||||
"""
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.const import CONF_ID, CONF_ON_CONTROL
|
||||
from esphome.core import CORE, coroutine_with_priority
|
||||
from esphome.core.entity_helpers import queue_entity_register, setup_entity
|
||||
from esphome.coroutine import CoroPriority
|
||||
@@ -42,6 +43,7 @@ def radio_frequency_schema(class_: type[cg.MockObjClass]) -> cv.Schema:
|
||||
return entity_schema.extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(class_),
|
||||
cv.Optional(CONF_ON_CONTROL): automation.validate_automation({}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -59,6 +61,11 @@ async def register_radio_frequency(var: cg.Pvariable, config: ConfigType) -> Non
|
||||
await setup_radio_frequency_core_(var, config)
|
||||
CORE.register_platform_component("radio_frequency", var)
|
||||
|
||||
for conf in config.get(CONF_ON_CONTROL, []):
|
||||
await automation.build_callback_automation(
|
||||
var, "add_on_control_callback", [(RadioFrequencyCall, "x")], conf
|
||||
)
|
||||
|
||||
|
||||
async def new_radio_frequency(config: ConfigType, *args) -> cg.Pvariable:
|
||||
"""Create a new RadioFrequency instance.
|
||||
|
||||
@@ -54,6 +54,10 @@ RadioFrequencyCall &RadioFrequencyCall::set_repeat_count(uint32_t count) {
|
||||
|
||||
void RadioFrequencyCall::perform() {
|
||||
if (this->parent_ != nullptr) {
|
||||
// Fire any on_control hooks (user-wired automations) before handing off to
|
||||
// the platform-specific control() — gives users a chance to react to call
|
||||
// parameters (e.g. retune an external RF front-end based on call.get_frequency()).
|
||||
this->parent_->control_callback_.call(*this);
|
||||
this->parent_->control(*this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,15 @@ class RadioFrequency : public Component, public EntityBase, public remote_base::
|
||||
this->receive_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
/// Add a callback to invoke when a transmit call is made on this entity.
|
||||
/// Fires before the platform-specific control() runs, with the call object
|
||||
/// (containing frequency, modulation, repeat count, etc.). Used by the
|
||||
/// `on_control` YAML trigger so users can wire any RF front-end driver
|
||||
/// (CC1101, RFM69, custom) to react to per-call parameters.
|
||||
template<typename F> void add_on_control_callback(F &&callback) {
|
||||
this->control_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
protected:
|
||||
friend class RadioFrequencyCall;
|
||||
|
||||
@@ -182,6 +191,8 @@ class RadioFrequency : public Component, public EntityBase, public remote_base::
|
||||
|
||||
// Callback manager for receive events (lazy: saves memory when no callbacks registered)
|
||||
LazyCallbackManager<void(remote_base::RemoteReceiveData)> receive_callback_;
|
||||
// Callback manager for on_control trigger (lazy: same memory savings)
|
||||
LazyCallbackManager<void(const RadioFrequencyCall &)> control_callback_;
|
||||
};
|
||||
|
||||
} // namespace esphome::radio_frequency
|
||||
|
||||
@@ -457,6 +457,19 @@ RP2040_BOARD_PINS = {
|
||||
"SS": 17,
|
||||
"TX": 12,
|
||||
},
|
||||
"challenger_2350_nbiot": {
|
||||
"LED": 15,
|
||||
"MISO": 16,
|
||||
"MOSI": 19,
|
||||
"RX": 13,
|
||||
"SCK": 18,
|
||||
"SCL": 21,
|
||||
"SCL1": 31,
|
||||
"SDA": 20,
|
||||
"SDA1": 31,
|
||||
"SS": 17,
|
||||
"TX": 12,
|
||||
},
|
||||
"challenger_2350_wifi6_ble5": {
|
||||
"LED": 7,
|
||||
"MISO": 16,
|
||||
@@ -1711,6 +1724,11 @@ BOARDS = {
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 47,
|
||||
},
|
||||
"challenger_2350_nbiot": {
|
||||
"name": "iLabs Challenger 2350 NB-IoT",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 47,
|
||||
},
|
||||
"challenger_2350_wifi6_ble5": {
|
||||
"name": "iLabs Challenger 2350 WiFi/BLE",
|
||||
"mcu": "rp2350",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#elif defined(USE_ESP32)
|
||||
#include <esp_ota_ops.h>
|
||||
#include <esp_system.h>
|
||||
#include <esp_image_format.h>
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -22,6 +23,37 @@ namespace esphome::safe_mode {
|
||||
|
||||
static const char *const TAG = "safe_mode";
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK) && !defined(USE_OTA_PARTITIONS)
|
||||
// Find a non-running app partition. If verify is true, only returns a partition
|
||||
// whose image passes verification (expensive: reads flash). Returns nullptr if none found.
|
||||
static const esp_partition_t *find_alternate_app_partition(bool verify) {
|
||||
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||
const esp_partition_t *result = nullptr;
|
||||
esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr);
|
||||
while (it != nullptr) {
|
||||
const esp_partition_t *p = esp_partition_get(it);
|
||||
if (p->address != running->address) {
|
||||
if (!verify) {
|
||||
result = p;
|
||||
break;
|
||||
}
|
||||
esp_image_metadata_t data = {};
|
||||
const esp_partition_pos_t part_pos = {
|
||||
.offset = p->address,
|
||||
.size = p->size,
|
||||
};
|
||||
if (esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &part_pos, &data) == ESP_OK) {
|
||||
result = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
it = esp_partition_next(it);
|
||||
}
|
||||
esp_partition_iterator_release(it);
|
||||
return result;
|
||||
}
|
||||
#endif
|
||||
|
||||
void SafeModeComponent::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Safe Mode:\n"
|
||||
@@ -34,7 +66,11 @@ void SafeModeComponent::dump_config() {
|
||||
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
|
||||
const char *state_str;
|
||||
if (this->ota_state_ == ESP_OTA_IMG_NEW) {
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
state_str = "support unknown";
|
||||
#else
|
||||
state_str = "not supported";
|
||||
#endif
|
||||
} else if (this->ota_state_ == ESP_OTA_IMG_PENDING_VERIFY) {
|
||||
state_str = "supported";
|
||||
} else {
|
||||
@@ -64,6 +100,18 @@ void SafeModeComponent::dump_config() {
|
||||
" See https://esphome.io/guides/faq.html#brownout-detector-was-triggered");
|
||||
}
|
||||
}
|
||||
if (!this->app_ota_possible_) {
|
||||
ESP_LOGW(TAG, "OTA updates are impossible.");
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
ESP_LOGW(TAG, " OTA partition table update or serial flashing is required.");
|
||||
#else
|
||||
if (find_alternate_app_partition(false) != nullptr) {
|
||||
ESP_LOGW(TAG, " Activate safe mode to reboot to the recovery partition.");
|
||||
} else {
|
||||
ESP_LOGE(TAG, " No recovery partition available; serial flashing is required.");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -124,8 +172,10 @@ bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t en
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
|
||||
// Check partition state to detect if bootloader supports rollback
|
||||
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||
esp_ota_get_state_partition(running, &this->ota_state_);
|
||||
const esp_partition_t *running_part = esp_ota_get_running_partition();
|
||||
esp_ota_get_state_partition(running_part, &this->ota_state_);
|
||||
const esp_partition_t *next_part = esp_ota_get_next_update_partition(nullptr);
|
||||
this->app_ota_possible_ = (next_part != nullptr && next_part != running_part);
|
||||
#endif
|
||||
|
||||
uint32_t rtc_val = this->read_rtc_();
|
||||
@@ -151,6 +201,28 @@ bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t en
|
||||
ESP_LOGE(TAG, "Boot loop detected");
|
||||
}
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK) && !defined(USE_OTA_PARTITIONS)
|
||||
// Allow recovery of soft-bricked devices
|
||||
// Instead of starting safe_mode, reboot to the other app partition if all conditions are met:
|
||||
// - app OTA is impossible (for example because the other app partition has type 'factory')
|
||||
// - the other app partition contains a valid app (for example Tasmota safeboot image or ESPHome)
|
||||
// - allow_partition_access is not configured making recovery via partition table update impossible
|
||||
// Image verification is deferred until here so the cost is only paid when entering safe mode,
|
||||
// not on every boot.
|
||||
if (!this->app_ota_possible_) {
|
||||
const esp_partition_t *rollback_part = find_alternate_app_partition(true);
|
||||
if (rollback_part != nullptr) {
|
||||
esp_err_t err = esp_ota_set_boot_partition(rollback_part);
|
||||
if (err == ESP_OK) {
|
||||
ESP_LOGW(TAG, "OTA updates are impossible. Rebooting to recovery app.");
|
||||
App.reboot();
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Failed to set recovery boot partition: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
this->status_set_error();
|
||||
this->set_timeout(enable_time, []() {
|
||||
ESP_LOGW(TAG, "Timeout, restarting");
|
||||
|
||||
@@ -48,11 +48,14 @@ class SafeModeComponent final : public Component {
|
||||
uint32_t safe_mode_enable_time_{60000}; ///< The time safe mode should remain active for
|
||||
uint32_t safe_mode_rtc_value_{0};
|
||||
uint32_t safe_mode_start_time_{0}; ///< stores when safe mode was enabled
|
||||
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
|
||||
esp_ota_img_states_t ota_state_{ESP_OTA_IMG_UNDEFINED}; // 4-byte enum
|
||||
#endif
|
||||
// Group 1-byte members together to minimize padding
|
||||
bool boot_successful_{false}; ///< set to true after boot is considered successful
|
||||
uint8_t safe_mode_num_attempts_{0};
|
||||
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
|
||||
esp_ota_img_states_t ota_state_{ESP_OTA_IMG_UNDEFINED};
|
||||
bool app_ota_possible_{true};
|
||||
#endif
|
||||
// Larger objects at the end
|
||||
ESPPreferenceObject rtc_;
|
||||
|
||||
@@ -206,12 +206,15 @@ async def to_code(config: ConfigType) -> None:
|
||||
)
|
||||
|
||||
# sendspin-cpp library
|
||||
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.4.0")
|
||||
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.5.0")
|
||||
|
||||
cg.add_define("USE_SENDSPIN", True) # for MDNS
|
||||
|
||||
data = _get_data()
|
||||
|
||||
# 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)
|
||||
|
||||
# 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_*).
|
||||
if data.artwork_support:
|
||||
@@ -264,7 +267,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
# Library defaults: priority 18 (one above httpd_priority 17 so the decoder is not
|
||||
# starved by the HTTP server during the initial encoded-audio burst at stream start),
|
||||
# interpolation/decode buffer locations PREFER_EXTERNAL.
|
||||
# decode buffer location PREFER_EXTERNAL.
|
||||
player_struct_fields = [
|
||||
("audio_formats", audio_format_structs),
|
||||
("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]),
|
||||
|
||||
@@ -188,14 +188,6 @@ void SendspinMediaSource::on_stream_end() {
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback)
|
||||
void SendspinMediaSource::on_stream_clear() {
|
||||
if (this->get_state() != media_source::MediaSourceState::IDLE) {
|
||||
// Only set to IDLE if we were previously in a non-IDLE state, to avoid duplicate state changes
|
||||
this->set_state_(media_source::MediaSourceState::IDLE);
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (PlayerRoleListener callback)
|
||||
void SendspinMediaSource::on_volume_changed(uint8_t volume) { this->request_volume_(volume / 100.0f); }
|
||||
|
||||
|
||||
@@ -49,9 +49,6 @@ class SendspinMediaSource : public SendspinChild,
|
||||
/// @brief Called when the audio stream ends (main loop thread).
|
||||
void on_stream_end() override;
|
||||
|
||||
/// @brief Called when the audio stream is cleared (main loop thread).
|
||||
void on_stream_clear() override;
|
||||
|
||||
/// @brief Called when volume changes (main loop thread).
|
||||
void on_volume_changed(uint8_t volume) override;
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "esphome/components/network/util.h"
|
||||
#ifdef USE_ETHERNET
|
||||
#include "esphome/components/ethernet/ethernet_component.h"
|
||||
#endif
|
||||
#ifdef USE_WIFI
|
||||
#include "esphome/components/wifi/wifi_component.h"
|
||||
#endif
|
||||
@@ -63,7 +66,7 @@ void SendspinHub::dump_config() {
|
||||
"Sendspin Hub:\n"
|
||||
" Client ID: %s\n"
|
||||
" Task stack in PSRAM: %s",
|
||||
get_mac_address_pretty_into_buffer(mac_buf), YESNO(this->task_stack_in_psram_));
|
||||
get_client_id_into_buffer(mac_buf), YESNO(this->task_stack_in_psram_));
|
||||
}
|
||||
|
||||
// --- Delegating methods ---
|
||||
@@ -89,11 +92,23 @@ void SendspinHub::update_state(sendspin::SendspinClientState state) {
|
||||
}
|
||||
}
|
||||
|
||||
const char *SendspinHub::get_client_id_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) {
|
||||
// The server matches client_id against the L2 source MAC of the device's multicast traffic.
|
||||
// ESP-IDF derives the ethernet MAC as base+3 by default on ESP32-S3, so we cannot use the
|
||||
// eFuse base MAC when ethernet is the active interface.
|
||||
#ifdef USE_ETHERNET
|
||||
if (ethernet::global_eth_component != nullptr) {
|
||||
return ethernet::global_eth_component->get_eth_mac_address_pretty_into_buffer(buf);
|
||||
}
|
||||
#endif
|
||||
return get_mac_address_pretty_into_buffer(buf);
|
||||
}
|
||||
|
||||
sendspin::SendspinClientConfig SendspinHub::build_client_config_() {
|
||||
sendspin::SendspinClientConfig config;
|
||||
|
||||
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
config.client_id = get_mac_address_pretty_into_buffer(mac_buf);
|
||||
config.client_id = SendspinHub::get_client_id_into_buffer(mac_buf);
|
||||
config.name = App.get_friendly_name();
|
||||
config.product_name = App.get_name();
|
||||
config.manufacturer = "ESPHome";
|
||||
|
||||
@@ -35,7 +35,9 @@ namespace esphome::sendspin_ {
|
||||
/// without each subcomponent having to pick a priority independently. Children run
|
||||
/// one step later than hub so they can assume hub's setup() has already completed.
|
||||
namespace sendspin_priority {
|
||||
inline constexpr float HUB = esphome::setup_priority::PROCESSOR;
|
||||
// AFTER_WIFI so the hub runs after the wifi/ethernet drivers are up and we can read the active
|
||||
// interface's MAC for client_id.
|
||||
inline constexpr float HUB = esphome::setup_priority::AFTER_WIFI;
|
||||
inline constexpr float CHILD = HUB - 1.0f;
|
||||
} // namespace sendspin_priority
|
||||
|
||||
@@ -149,6 +151,10 @@ class SendspinHub final : public Component,
|
||||
/// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info.
|
||||
sendspin::SendspinClientConfig build_client_config_();
|
||||
|
||||
/// @brief Writes the active network interface's MAC into @p buf and returns its data pointer.
|
||||
/// Uses the ethernet MAC if ethernet is configured, otherwise the base MAC (used by wifi).
|
||||
static const char *get_client_id_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf);
|
||||
|
||||
// --- SendspinClientListener overrides ---
|
||||
void on_group_update(const sendspin::GroupUpdateObject &group) override;
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ CONF_ON_TIMER_CANCELLED = "on_timer_cancelled"
|
||||
CONF_ON_TIMER_FINISHED = "on_timer_finished"
|
||||
CONF_ON_TIMER_TICK = "on_timer_tick"
|
||||
|
||||
MAX_MICROPHONE_SOURCES = 2
|
||||
|
||||
|
||||
voice_assistant_ns = cg.esphome_ns.namespace("voice_assistant")
|
||||
VoiceAssistant = voice_assistant_ns.class_("VoiceAssistant", cg.Component)
|
||||
@@ -90,13 +92,20 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(VoiceAssistant),
|
||||
cv.Optional(
|
||||
CONF_MICROPHONE, default={}
|
||||
): microphone.microphone_source_schema(
|
||||
min_bits_per_sample=16,
|
||||
max_bits_per_sample=16,
|
||||
min_channels=1,
|
||||
max_channels=1,
|
||||
cv.Optional(CONF_MICROPHONE, default=[{}]): cv.All(
|
||||
cv.ensure_list(
|
||||
microphone.microphone_source_schema(
|
||||
min_bits_per_sample=16,
|
||||
max_bits_per_sample=16,
|
||||
min_channels=1,
|
||||
max_channels=1,
|
||||
)
|
||||
),
|
||||
cv.Length(
|
||||
min=1,
|
||||
max=MAX_MICROPHONE_SOURCES,
|
||||
msg=f"Voice Assistant supports at most {MAX_MICROPHONE_SOURCES} microphone sources",
|
||||
),
|
||||
),
|
||||
cv.Exclusive(CONF_MEDIA_PLAYER, "output"): cv.use_id(
|
||||
media_player.MediaPlayer
|
||||
@@ -179,10 +188,10 @@ CONFIG_SCHEMA = cv.All(
|
||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(
|
||||
CONF_MICROPHONE
|
||||
): microphone.final_validate_microphone_source_schema(
|
||||
"voice_assistant", sample_rate=16000
|
||||
cv.Optional(CONF_MICROPHONE): cv.ensure_list(
|
||||
microphone.final_validate_microphone_source_schema(
|
||||
"voice_assistant", sample_rate=16000
|
||||
)
|
||||
),
|
||||
},
|
||||
extra=cv.ALLOW_EXTRA,
|
||||
@@ -194,9 +203,14 @@ async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
mic_source = await microphone.microphone_source_to_code(config[CONF_MICROPHONE])
|
||||
mic_sources = config[CONF_MICROPHONE]
|
||||
mic_source = await microphone.microphone_source_to_code(mic_sources[0])
|
||||
cg.add(var.set_microphone_source(mic_source))
|
||||
|
||||
if len(mic_sources) > 1:
|
||||
mic_source2 = await microphone.microphone_source_to_code(mic_sources[1])
|
||||
cg.add(var.set_microphone_source2(mic_source2))
|
||||
|
||||
if CONF_MICRO_WAKE_WORD in config:
|
||||
mww = await cg.get_variable(config[CONF_MICRO_WAKE_WORD])
|
||||
cg.add(var.set_micro_wake_word(mww))
|
||||
|
||||
@@ -31,11 +31,21 @@ VoiceAssistant::VoiceAssistant() { global_voice_assistant = this; }
|
||||
void VoiceAssistant::setup() {
|
||||
this->mic_source_->add_data_callback([this](const std::vector<uint8_t> &data) {
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_;
|
||||
if (this->ring_buffer_.use_count() > 1) {
|
||||
if (temp_ring_buffer != nullptr) {
|
||||
temp_ring_buffer->write((void *) data.data(), data.size());
|
||||
}
|
||||
});
|
||||
|
||||
// Second microphone channel
|
||||
if (this->mic_source2_ != nullptr) {
|
||||
this->mic_source2_->add_data_callback([this](const std::vector<uint8_t> &data) {
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer2_;
|
||||
if (temp_ring_buffer != nullptr) {
|
||||
temp_ring_buffer->write((void *) data.data(), data.size());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#ifdef USE_MEDIA_PLAYER
|
||||
if (this->media_player_ != nullptr) {
|
||||
this->media_player_->add_on_state_callback([this](media_player::MediaPlayerState state) {
|
||||
@@ -115,9 +125,9 @@ bool VoiceAssistant::allocate_buffers_() {
|
||||
}
|
||||
#endif
|
||||
|
||||
if (this->ring_buffer_.use_count() == 0) {
|
||||
if (this->ring_buffer_ == nullptr) {
|
||||
this->ring_buffer_ = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE);
|
||||
if (this->ring_buffer_.use_count() == 0) {
|
||||
if (this->ring_buffer_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Could not allocate ring buffer");
|
||||
return false;
|
||||
}
|
||||
@@ -132,6 +142,26 @@ bool VoiceAssistant::allocate_buffers_() {
|
||||
}
|
||||
}
|
||||
|
||||
// Second microphone channel
|
||||
if (this->mic_source2_ != nullptr) {
|
||||
if (this->ring_buffer2_ == nullptr) {
|
||||
this->ring_buffer2_ = ring_buffer::RingBuffer::create(RING_BUFFER_SIZE);
|
||||
if (this->ring_buffer2_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Could not allocate second ring buffer");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (this->send_buffer2_ == nullptr) {
|
||||
RAMAllocator<uint8_t> send_allocator;
|
||||
this->send_buffer2_ = send_allocator.allocate(SEND_BUFFER_SIZE);
|
||||
if (this->send_buffer2_ == nullptr) {
|
||||
ESP_LOGW(TAG, "Could not allocate second send buffer");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -144,6 +174,15 @@ void VoiceAssistant::clear_buffers_() {
|
||||
this->ring_buffer_->reset();
|
||||
}
|
||||
|
||||
// Second microphone channel
|
||||
if (this->send_buffer2_ != nullptr) {
|
||||
memset(this->send_buffer2_, 0, SEND_BUFFER_SIZE);
|
||||
}
|
||||
|
||||
if (this->ring_buffer2_ != nullptr) {
|
||||
this->ring_buffer2_->reset();
|
||||
}
|
||||
|
||||
#ifdef USE_SPEAKER
|
||||
if ((this->speaker_ != nullptr) && (this->speaker_buffer_ != nullptr)) {
|
||||
memset(this->speaker_buffer_, 0, SPEAKER_BUFFER_SIZE);
|
||||
@@ -162,10 +201,17 @@ void VoiceAssistant::deallocate_buffers_() {
|
||||
this->send_buffer_ = nullptr;
|
||||
}
|
||||
|
||||
if (this->ring_buffer_.use_count() > 0) {
|
||||
this->ring_buffer_.reset();
|
||||
this->ring_buffer_.reset();
|
||||
|
||||
// Second microphone channel
|
||||
if (this->send_buffer2_ != nullptr) {
|
||||
RAMAllocator<uint8_t> send_deallocator;
|
||||
send_deallocator.deallocate(this->send_buffer2_, SEND_BUFFER_SIZE);
|
||||
this->send_buffer2_ = nullptr;
|
||||
}
|
||||
|
||||
this->ring_buffer2_.reset();
|
||||
|
||||
#ifdef USE_SPEAKER
|
||||
if ((this->speaker_ != nullptr) && (this->speaker_buffer_ != nullptr)) {
|
||||
RAMAllocator<uint8_t> speaker_deallocator;
|
||||
@@ -183,7 +229,8 @@ void VoiceAssistant::reset_conversation_id() {
|
||||
void VoiceAssistant::loop() {
|
||||
if (this->api_client_ == nullptr && this->state_ != State::IDLE && this->state_ != State::STOP_MICROPHONE &&
|
||||
this->state_ != State::STOPPING_MICROPHONE) {
|
||||
if (this->mic_source_->is_running() || this->state_ == State::STARTING_MICROPHONE) {
|
||||
if (this->mic_source_->is_running() || (this->mic_source2_ && this->mic_source2_->is_running()) ||
|
||||
this->state_ == State::STARTING_MICROPHONE) {
|
||||
this->set_state_(State::STOP_MICROPHONE, State::IDLE);
|
||||
} else {
|
||||
this->set_state_(State::IDLE, State::IDLE);
|
||||
@@ -215,11 +262,14 @@ void VoiceAssistant::loop() {
|
||||
this->clear_buffers_();
|
||||
|
||||
this->mic_source_->start();
|
||||
if (this->mic_source2_) {
|
||||
this->mic_source2_->start();
|
||||
}
|
||||
this->set_state_(State::STARTING_MICROPHONE);
|
||||
break;
|
||||
}
|
||||
case State::STARTING_MICROPHONE: {
|
||||
if (this->mic_source_->is_running()) {
|
||||
if (this->mic_source_->is_running() && (!this->mic_source2_ || this->mic_source2_->is_running())) {
|
||||
this->set_state_(this->desired_state_);
|
||||
}
|
||||
break;
|
||||
@@ -266,15 +316,44 @@ void VoiceAssistant::loop() {
|
||||
break; // State changed when udp server port received
|
||||
}
|
||||
case State::STREAMING_MICROPHONE: {
|
||||
size_t available = this->ring_buffer_->available();
|
||||
while (available >= SEND_BUFFER_SIZE) {
|
||||
size_t read_bytes = this->ring_buffer_->read((void *) this->send_buffer_, SEND_BUFFER_SIZE, 0);
|
||||
if (this->audio_mode_ == AUDIO_MODE_API) {
|
||||
if (this->audio_mode_ == AUDIO_MODE_API) {
|
||||
// API audio
|
||||
// Both microphone channels are sent, if configured
|
||||
bool is_available = this->ring_buffer_->available() >= SEND_BUFFER_SIZE;
|
||||
bool is_available2 = false;
|
||||
if (this->mic_source2_) {
|
||||
is_available2 = this->ring_buffer2_->available() >= SEND_BUFFER_SIZE;
|
||||
}
|
||||
|
||||
while (is_available || is_available2) {
|
||||
api::VoiceAssistantAudio msg;
|
||||
msg.data = this->send_buffer_;
|
||||
msg.data_len = read_bytes;
|
||||
|
||||
if (is_available) {
|
||||
size_t read_bytes = this->ring_buffer_->read((void *) this->send_buffer_, SEND_BUFFER_SIZE, 0);
|
||||
msg.data = this->send_buffer_;
|
||||
msg.data_len = read_bytes;
|
||||
}
|
||||
|
||||
// Second microphone channel
|
||||
if (is_available2) {
|
||||
size_t read_bytes = this->ring_buffer2_->read((void *) this->send_buffer2_, SEND_BUFFER_SIZE, 0);
|
||||
msg.data2 = this->send_buffer2_;
|
||||
msg.data2_len = read_bytes;
|
||||
}
|
||||
|
||||
this->api_client_->send_message(msg);
|
||||
} else {
|
||||
is_available = this->ring_buffer_->available() >= SEND_BUFFER_SIZE;
|
||||
if (this->mic_source2_) {
|
||||
is_available2 = this->ring_buffer2_->available() >= SEND_BUFFER_SIZE;
|
||||
} else {
|
||||
is_available2 = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// UDP (will eventually be deprecated)
|
||||
// Only the primary microphone channel is used
|
||||
while (this->ring_buffer_->available() >= SEND_BUFFER_SIZE) {
|
||||
size_t read_bytes = this->ring_buffer_->read((void *) this->send_buffer_, SEND_BUFFER_SIZE, 0);
|
||||
if (!this->udp_socket_running_) {
|
||||
if (!this->start_udp_socket_()) {
|
||||
this->set_state_(State::STOP_MICROPHONE, State::IDLE);
|
||||
@@ -284,14 +363,23 @@ void VoiceAssistant::loop() {
|
||||
this->socket_->sendto(this->send_buffer_, read_bytes, 0, (struct sockaddr *) &this->dest_addr_,
|
||||
sizeof(this->dest_addr_));
|
||||
}
|
||||
available = this->ring_buffer_->available();
|
||||
}
|
||||
|
||||
} // audio mode
|
||||
break;
|
||||
}
|
||||
case State::STOP_MICROPHONE: {
|
||||
if (this->mic_source_->is_running()) {
|
||||
this->mic_source_->stop();
|
||||
// Check both microphone channels
|
||||
bool is_running = this->mic_source_->is_running();
|
||||
bool is_running2 = false;
|
||||
if (this->mic_source2_) {
|
||||
is_running2 = this->mic_source2_->is_running();
|
||||
}
|
||||
if (is_running || is_running2) {
|
||||
if (is_running) {
|
||||
this->mic_source_->stop();
|
||||
}
|
||||
if (is_running2) {
|
||||
this->mic_source2_->stop();
|
||||
}
|
||||
this->set_state_(State::STOPPING_MICROPHONE);
|
||||
} else {
|
||||
this->set_state_(this->desired_state_);
|
||||
@@ -299,7 +387,13 @@ void VoiceAssistant::loop() {
|
||||
break;
|
||||
}
|
||||
case State::STOPPING_MICROPHONE: {
|
||||
if (this->mic_source_->is_stopped()) {
|
||||
// Check both microphone channels
|
||||
bool is_stopped = this->mic_source_->is_stopped();
|
||||
bool is_stopped2 = true;
|
||||
if (this->mic_source2_) {
|
||||
is_stopped2 = this->mic_source2_->is_stopped();
|
||||
}
|
||||
if (is_stopped && is_stopped2) {
|
||||
this->set_state_(this->desired_state_);
|
||||
}
|
||||
break;
|
||||
@@ -504,7 +598,8 @@ void VoiceAssistant::start_streaming() {
|
||||
ESP_LOGD(TAG, "Client started, streaming microphone");
|
||||
this->audio_mode_ = AUDIO_MODE_API;
|
||||
|
||||
if (this->mic_source_->is_running()) {
|
||||
// Both microphone channels
|
||||
if (this->mic_source_->is_running() && (!this->mic_source2_ || this->mic_source2_->is_running())) {
|
||||
this->set_state_(State::STREAMING_MICROPHONE, State::STREAMING_MICROPHONE);
|
||||
} else {
|
||||
this->set_state_(State::START_MICROPHONE, State::STREAMING_MICROPHONE);
|
||||
@@ -520,6 +615,10 @@ void VoiceAssistant::start_streaming(struct sockaddr_storage *addr, uint16_t por
|
||||
ESP_LOGD(TAG, "Client started, streaming microphone");
|
||||
this->audio_mode_ = AUDIO_MODE_UDP;
|
||||
|
||||
if (this->mic_source2_ != nullptr) {
|
||||
ESP_LOGW(TAG, "UDP audio mode does not support a second microphone channel; only the primary will be streamed");
|
||||
}
|
||||
|
||||
memcpy(&this->dest_addr_, addr, sizeof(this->dest_addr_));
|
||||
if (this->dest_addr_.ss_family == AF_INET) {
|
||||
((struct sockaddr_in *) &this->dest_addr_)->sin_port = htons(port);
|
||||
@@ -534,6 +633,7 @@ void VoiceAssistant::start_streaming(struct sockaddr_storage *addr, uint16_t por
|
||||
return;
|
||||
}
|
||||
|
||||
// Only primary microphone channel over UDP
|
||||
if (this->mic_source_->is_running()) {
|
||||
this->set_state_(State::STREAMING_MICROPHONE, State::STREAMING_MICROPHONE);
|
||||
} else {
|
||||
|
||||
@@ -40,6 +40,7 @@ enum VoiceAssistantFeature : uint32_t {
|
||||
FEATURE_TIMERS = 1 << 3,
|
||||
FEATURE_ANNOUNCE = 1 << 4,
|
||||
FEATURE_START_CONVERSATION = 1 << 5,
|
||||
FEATURE_MULTI_CHANNEL_AUDIO = 1 << 6,
|
||||
};
|
||||
|
||||
enum class State {
|
||||
@@ -120,6 +121,7 @@ class VoiceAssistant : public Component {
|
||||
void failed_to_start();
|
||||
|
||||
void set_microphone_source(microphone::MicrophoneSource *mic_source) { this->mic_source_ = mic_source; }
|
||||
void set_microphone_source2(microphone::MicrophoneSource *mic_source2) { this->mic_source2_ = mic_source2; }
|
||||
#ifdef USE_MICRO_WAKE_WORD
|
||||
void set_micro_wake_word(micro_wake_word::MicroWakeWord *mww) { this->micro_wake_word_ = mww; }
|
||||
#endif
|
||||
@@ -149,6 +151,9 @@ class VoiceAssistant : public Component {
|
||||
uint32_t flags = 0;
|
||||
flags |= VoiceAssistantFeature::FEATURE_VOICE_ASSISTANT;
|
||||
flags |= VoiceAssistantFeature::FEATURE_API_AUDIO;
|
||||
if (this->mic_source2_ != nullptr) {
|
||||
flags |= VoiceAssistantFeature::FEATURE_MULTI_CHANNEL_AUDIO;
|
||||
}
|
||||
#ifdef USE_SPEAKER
|
||||
if (this->speaker_ != nullptr) {
|
||||
flags |= VoiceAssistantFeature::FEATURE_SPEAKER;
|
||||
@@ -276,6 +281,7 @@ class VoiceAssistant : public Component {
|
||||
bool timer_tick_running_{false};
|
||||
|
||||
microphone::MicrophoneSource *mic_source_{nullptr};
|
||||
microphone::MicrophoneSource *mic_source2_{nullptr};
|
||||
#ifdef USE_SPEAKER
|
||||
void write_speaker_();
|
||||
speaker::Speaker *speaker_{nullptr};
|
||||
@@ -301,6 +307,7 @@ class VoiceAssistant : public Component {
|
||||
std::string wake_word_;
|
||||
|
||||
std::shared_ptr<ring_buffer::RingBuffer> ring_buffer_;
|
||||
std::shared_ptr<ring_buffer::RingBuffer> ring_buffer2_;
|
||||
|
||||
bool use_wake_word_;
|
||||
uint8_t noise_suppression_level_;
|
||||
@@ -309,6 +316,7 @@ class VoiceAssistant : public Component {
|
||||
uint32_t conversation_timeout_;
|
||||
|
||||
uint8_t *send_buffer_{nullptr};
|
||||
uint8_t *send_buffer2_{nullptr};
|
||||
|
||||
bool continuous_{false};
|
||||
bool silence_detection_;
|
||||
|
||||
@@ -32,10 +32,9 @@ void ZigbeeAttribute::report_(bool has_lock) {
|
||||
return;
|
||||
}
|
||||
if (has_lock or esp_zb_lock_acquire(10 / portTICK_PERIOD_MS)) {
|
||||
esp_zb_zcl_report_attr_cmd_t cmd = {
|
||||
.address_mode = ESP_ZB_APS_ADDR_MODE_16_ENDP_PRESENT,
|
||||
.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_CLI,
|
||||
};
|
||||
esp_zb_zcl_report_attr_cmd_t cmd = {};
|
||||
cmd.address_mode = ESP_ZB_APS_ADDR_MODE_16_ENDP_PRESENT;
|
||||
cmd.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_CLI;
|
||||
cmd.zcl_basic_cmd.dst_addr_u.addr_short = 0x0000;
|
||||
cmd.zcl_basic_cmd.dst_endpoint = 1;
|
||||
cmd.zcl_basic_cmd.src_endpoint = this->endpoint_id_;
|
||||
@@ -50,14 +49,13 @@ void ZigbeeAttribute::report_(bool has_lock) {
|
||||
}
|
||||
|
||||
esp_zb_zcl_reporting_info_t ZigbeeAttribute::get_reporting_info() {
|
||||
esp_zb_zcl_reporting_info_t reporting_info = {
|
||||
.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV,
|
||||
.ep = this->endpoint_id_,
|
||||
.cluster_id = this->cluster_id_,
|
||||
.cluster_role = this->role_,
|
||||
.attr_id = this->attr_id_,
|
||||
.manuf_code = ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC,
|
||||
};
|
||||
esp_zb_zcl_reporting_info_t reporting_info = {};
|
||||
reporting_info.direction = ESP_ZB_ZCL_CMD_DIRECTION_TO_SRV;
|
||||
reporting_info.ep = this->endpoint_id_;
|
||||
reporting_info.cluster_id = this->cluster_id_;
|
||||
reporting_info.cluster_role = this->role_;
|
||||
reporting_info.attr_id = this->attr_id_;
|
||||
reporting_info.manuf_code = ESP_ZB_ZCL_ATTR_NON_MANUFACTURER_SPECIFIC;
|
||||
reporting_info.dst.profile_id = ESP_ZB_AF_HA_PROFILE_ID;
|
||||
reporting_info.u.send_info.min_interval = 10; /*!< Actual minimum reporting interval */
|
||||
reporting_info.u.send_info.max_interval = 0; /*!< Actual maximum reporting interval */
|
||||
|
||||
@@ -37,8 +37,8 @@ class ZigbeeAttribute : public Component {
|
||||
role_(role),
|
||||
attr_id_(attr_id),
|
||||
attr_type_(attr_type),
|
||||
scale_(scale),
|
||||
max_size_(max_size) {}
|
||||
max_size_(max_size),
|
||||
scale_(scale) {}
|
||||
void loop() override;
|
||||
template<typename T> void add_attr(T value);
|
||||
esp_zb_zcl_reporting_info_t get_reporting_info();
|
||||
|
||||
@@ -204,10 +204,9 @@ static void esp_zb_task_(void *pvParameters) {
|
||||
|
||||
void ZigbeeComponent::setup() {
|
||||
global_zigbee = this;
|
||||
esp_zb_platform_config_t config = {
|
||||
.radio_config = ESP_ZB_DEFAULT_RADIO_CONFIG(),
|
||||
.host_config = ESP_ZB_DEFAULT_HOST_CONFIG(),
|
||||
};
|
||||
esp_zb_platform_config_t config = {};
|
||||
config.radio_config = ESP_ZB_DEFAULT_RADIO_CONFIG();
|
||||
config.host_config = ESP_ZB_DEFAULT_HOST_CONFIG();
|
||||
#ifdef USE_WIFI
|
||||
if (esp_coex_wifi_i154_enable() != ESP_OK) {
|
||||
this->mark_failed();
|
||||
|
||||
@@ -57,6 +57,15 @@ FILTER_IDF_LINES: list[str] = [
|
||||
# line, so a NOTICE often arrives prefixed with ".NOTICE:" or
|
||||
# "...........NOTICE:".
|
||||
r"\.*NOTICE: ",
|
||||
# ``idf.py size`` prefaces its table with a centered banner; the
|
||||
# per-region table below already makes the structure obvious.
|
||||
r"\s*Memory Type Usage Summary",
|
||||
# Prefix match for esp-idf-size's trailing "Note:" paragraph (no
|
||||
# upstream flag suppresses it).
|
||||
r"Note: The reported total sizes may be smaller than those in the",
|
||||
# Drop the blank line rich emits after the note so the build log
|
||||
# doesn't end with an orphan gap before ESPHome's own status lines.
|
||||
r"\s*$",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""PlatformIO-format RAM/Flash one-liners after a native ESP-IDF build.
|
||||
|
||||
``idf.py size`` (chained onto ``idf.py build`` in
|
||||
``toolchain.run_compile``) prints the per-region table inline as part
|
||||
of the build. This module adds two summary lines underneath,
|
||||
byte-identical to PlatformIO's output:
|
||||
|
||||
RAM: [==== ] 26.5% (used 47932 bytes from 180736 bytes)
|
||||
Flash: [=== ] 48.4% (used 888511 bytes from 1835008 bytes)
|
||||
|
||||
The format matches ``script/ci_memory_impact_extract.py`` so CI memory
|
||||
analysis works unchanged on native ESP-IDF builds. RAM total is the
|
||||
DRAM region size from the linker map; Flash total is taken from
|
||||
``partitions.csv`` using PlatformIO's rule (first app partition whose
|
||||
subtype is ``factory`` or ``ota_0``; see
|
||||
``platform-espressif32/builder/main.py::_update_max_upload_size``).
|
||||
|
||||
Structured size data is produced at link time by a CMake POST_BUILD
|
||||
custom command (see ``build_gen/espidf.py``) which writes
|
||||
``esp_idf_size.json`` next to the ELF. We read that file here rather
|
||||
than re-running ``esp_idf_size`` from Python.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_SIZE_SUFFIXES = {"K": 1024, "M": 1024 * 1024}
|
||||
|
||||
|
||||
def _parse_size(token: str) -> int:
|
||||
token = token.strip()
|
||||
if not token:
|
||||
return 0
|
||||
if token.startswith(("0x", "0X")):
|
||||
return int(token, 16)
|
||||
suffix = token[-1].upper()
|
||||
if suffix in _SIZE_SUFFIXES:
|
||||
return int(token[:-1]) * _SIZE_SUFFIXES[suffix]
|
||||
return int(token)
|
||||
|
||||
|
||||
def _find_app_partition_size(partitions_csv: Path) -> int:
|
||||
"""Return the size of the firmware's app partition.
|
||||
|
||||
Mirrors PlatformIO's ``platform-espressif32/builder/main.py::
|
||||
_update_max_upload_size``: take the first ``app``-type partition
|
||||
whose subtype is ``factory`` or ``ota_0``. Order matters because
|
||||
layouts like Adafruit's ``partitions-4MB-tinyuf2.csv`` repurpose
|
||||
``factory`` for a UF2 bootloader before the real OTA slot, so a
|
||||
naive "prefer factory" rule would pick the wrong row. Raises
|
||||
``ValueError`` if no qualifying partition is present.
|
||||
"""
|
||||
if not partitions_csv.is_file():
|
||||
raise ValueError(f"partitions.csv not found at {partitions_csv}")
|
||||
for row in csv.reader(partitions_csv.read_text().splitlines()):
|
||||
cells = [c.strip() for c in row]
|
||||
if not cells or cells[0].startswith("#") or len(cells) < 5:
|
||||
continue
|
||||
ptype, psubtype, psize = cells[1], cells[2], cells[4]
|
||||
if ptype in ("app", "0") and psubtype in ("factory", "ota_0"):
|
||||
return _parse_size(psize)
|
||||
raise ValueError(f"No app+factory or app+ota_0 partition in {partitions_csv}")
|
||||
|
||||
|
||||
def _format_bar(used: int, total: int) -> str:
|
||||
"""Match PlatformIO's ``_format_availale_bytes`` (pioupload.py) exactly."""
|
||||
pct_raw = used / total if total else 0
|
||||
blocks = 10
|
||||
filled = min(int(round(blocks * pct_raw)), blocks)
|
||||
progress = "=" * filled
|
||||
return (
|
||||
f"[{progress:<{blocks}}] {pct_raw: 6.1%} "
|
||||
f"(used {used:d} bytes from {total:d} bytes)"
|
||||
)
|
||||
|
||||
|
||||
def print_summary(size_json: Path, partitions_csv: Path | None) -> None:
|
||||
"""Print PlatformIO-shaped RAM and Flash one-liners.
|
||||
|
||||
Failures are non-fatal: the build has already succeeded, we just couldn't
|
||||
summarize. Logs the cause at debug level.
|
||||
"""
|
||||
if not size_json.is_file():
|
||||
_LOGGER.debug("Skipping size summary: %s not found", size_json)
|
||||
return
|
||||
try:
|
||||
data = json.loads(size_json.read_text())
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
_LOGGER.debug("Skipping size summary: %s", e)
|
||||
return
|
||||
|
||||
dram = data.get("memory_types", {}).get("DRAM") or {}
|
||||
ram_used = dram.get("used")
|
||||
ram_total = dram.get("size")
|
||||
if ram_total and ram_used is not None:
|
||||
print(f"RAM: {_format_bar(ram_used, ram_total)}")
|
||||
|
||||
image_size = data.get("image_size")
|
||||
if image_size is None or partitions_csv is None:
|
||||
return
|
||||
try:
|
||||
app_size = _find_app_partition_size(partitions_csv)
|
||||
except ValueError as e:
|
||||
_LOGGER.debug("Skipping Flash summary: %s", e)
|
||||
return
|
||||
print(f"Flash: {_format_bar(image_size, app_size)}")
|
||||
@@ -12,6 +12,7 @@ import subprocess
|
||||
from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.espidf.framework import check_esp_idf_install, get_framework_env
|
||||
from esphome.espidf.size_summary import print_summary
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -341,8 +342,14 @@ def run_compile(config, verbose: bool) -> int:
|
||||
|
||||
args.extend(_get_sdkconfig_args())
|
||||
args.append("build")
|
||||
args.append("size")
|
||||
|
||||
return run_idf_py(*args)
|
||||
rc = run_idf_py(*args)
|
||||
if rc == 0:
|
||||
size_json = CORE.relative_build_path("build", "esp_idf_size.json")
|
||||
partitions = CORE.relative_build_path("partitions.csv")
|
||||
print_summary(size_json, partitions if partitions.is_file() else None)
|
||||
return rc
|
||||
|
||||
|
||||
def get_firmware_path() -> Path:
|
||||
|
||||
@@ -100,6 +100,6 @@ dependencies:
|
||||
esp32async/asynctcp:
|
||||
version: 3.4.91
|
||||
sendspin/sendspin-cpp:
|
||||
version: 0.4.0
|
||||
version: 0.5.0
|
||||
lvgl/lvgl:
|
||||
version: 9.5.0
|
||||
|
||||
@@ -51,9 +51,13 @@ def patch_file_downloader() -> None:
|
||||
"""Retry PlatformIO package downloads with exponential backoff.
|
||||
|
||||
PlatformIO's ``FileDownloader`` uses an ``HTTPSession`` without built-in
|
||||
retry for 502/503 errors. We wrap ``__init__`` to retry on
|
||||
``PackageException`` and close the session between attempts so a new
|
||||
TCP connection can route to a different CDN edge node.
|
||||
retry. We wrap ``__init__`` to retry on transient failures and close the
|
||||
session between attempts so a new TCP connection can route to a different
|
||||
CDN edge node. We catch both ``PackageException`` (raised when the server
|
||||
returns a non-200 status such as 502/503) and ``OSError`` -- which covers
|
||||
``requests.exceptions.ConnectionError``, ``ReadTimeout``, and
|
||||
``ChunkedEncodingError`` (all subclasses of ``OSError``) that get raised
|
||||
when the connection is aborted before a response is parsed.
|
||||
"""
|
||||
from platformio.package.download import FileDownloader
|
||||
from platformio.package.exception import PackageException
|
||||
@@ -70,7 +74,7 @@ def patch_file_downloader() -> None:
|
||||
try:
|
||||
original_init(self, *args, **kwargs)
|
||||
return
|
||||
except PackageException as e:
|
||||
except (PackageException, OSError) as e:
|
||||
if attempt < max_retries - 1:
|
||||
delay = 2 ** (attempt + 1)
|
||||
_LOGGER.warning(
|
||||
|
||||
@@ -139,7 +139,7 @@ def add_context(value: Any, context_vars: dict[str, Any] | None) -> Any:
|
||||
value.set_context({**value.vars, **(context_vars or {})})
|
||||
return value
|
||||
|
||||
if context_vars and isinstance(value, (dict, list, str)):
|
||||
if context_vars and isinstance(value, (dict, list, str, Lambda)):
|
||||
value = add_class_to_obj(value, ConfigContext)
|
||||
value.set_context(context_vars)
|
||||
return value
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ platformio==6.1.19
|
||||
esptool==5.2.0
|
||||
click==8.3.3
|
||||
esphome-dashboard==20260425.0
|
||||
aioesphomeapi==44.24.2
|
||||
aioesphomeapi==45.0.0
|
||||
zeroconf==0.148.0
|
||||
puremagic==1.30
|
||||
ruamel.yaml==0.19.1 # dashboard_import
|
||||
|
||||
@@ -34,7 +34,7 @@ from typing import Any
|
||||
# Add esphome to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from helpers import BASE_BUS_COMPONENTS
|
||||
from helpers import BASE_BUS_COMPONENTS, is_validate_only_file
|
||||
|
||||
from esphome import yaml_util
|
||||
from esphome.config_helpers import Extend, Remove
|
||||
@@ -283,6 +283,13 @@ def analyze_component(component_dir: Path) -> tuple[dict[str, list[str]], bool,
|
||||
|
||||
# Analyze all YAML files in the component directory
|
||||
for yaml_file in component_dir.glob("*.yaml"):
|
||||
# validate.*.yaml files are config-only -- they don't compile, so
|
||||
# their contents must not influence compile-time grouping decisions
|
||||
# (e.g. a !extend used only to exercise schema validation must not
|
||||
# disqualify the whole component from being grouped).
|
||||
if is_validate_only_file(yaml_file):
|
||||
continue
|
||||
|
||||
analysis = analyze_yaml_file(yaml_file)
|
||||
|
||||
# Track if any file uses extend/remove
|
||||
|
||||
+6
-1
@@ -1068,7 +1068,12 @@ PACKAGE_BUS_RE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
@lint_content_check(include=["tests/components/*/test.*.yaml"])
|
||||
@lint_content_check(
|
||||
include=[
|
||||
"tests/components/*/test.*.yaml",
|
||||
"tests/components/*/validate.*.yaml",
|
||||
]
|
||||
)
|
||||
def lint_test_package_key_matches_bus(fname, content):
|
||||
"""Ensure package keys match the common bus directory name.
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ The CI workflow uses this information to:
|
||||
- Skip or run downstream esphome/device-builder tests against the PR's Python code
|
||||
- Determine which components to test individually
|
||||
- Decide how to split component tests (if there are many)
|
||||
- Identify directly-changed components whose only edits are validate.*.yaml files,
|
||||
so CI can skip the compile stage for them and run config validation only
|
||||
- Run memory impact analysis whenever there are changed components (merged config), and also for core-only changes
|
||||
|
||||
Usage:
|
||||
@@ -68,6 +70,7 @@ from helpers import (
|
||||
get_integration_test_files_for_components,
|
||||
get_target_branch,
|
||||
git_ls_files,
|
||||
is_validate_only_file,
|
||||
parse_test_filename,
|
||||
root_path,
|
||||
)
|
||||
@@ -600,14 +603,41 @@ def _component_has_tests(component: str) -> bool:
|
||||
"""Check if a component has test files.
|
||||
|
||||
Cached to avoid repeated filesystem operations for the same component.
|
||||
Validate files (validate.*.yaml) count -- they exercise schema validation
|
||||
in CI even though they are never compiled.
|
||||
|
||||
Args:
|
||||
component: Component name to check
|
||||
|
||||
Returns:
|
||||
True if the component has test YAML files
|
||||
True if the component has test or validate YAML files
|
||||
"""
|
||||
return bool(get_component_test_files(component, all_variants=True))
|
||||
return bool(
|
||||
get_component_test_files(component, all_variants=True, include_validate=True)
|
||||
)
|
||||
|
||||
|
||||
def _component_change_is_validate_only(component: str, changed: list[str]) -> bool:
|
||||
"""Return True if every changed file for this component is a validate.*.yaml.
|
||||
|
||||
Used to decide whether a directly-changed component can skip the compile
|
||||
stage in CI. A component qualifies when:
|
||||
- at least one file under ``tests/components/<component>/`` changed, AND
|
||||
- no source file under ``esphome/components/<component>/`` changed, AND
|
||||
- every changed test file is a ``validate.*.yaml`` or
|
||||
``validate-*.yaml`` (i.e. no regular ``test.*.yaml`` was touched).
|
||||
"""
|
||||
test_prefix = f"tests/components/{component}/"
|
||||
src_prefix = f"esphome/components/{component}/"
|
||||
test_changes: list[Path] = []
|
||||
for path in changed:
|
||||
if path.startswith(src_prefix):
|
||||
return False
|
||||
if path.startswith(test_prefix):
|
||||
test_changes.append(Path(path))
|
||||
if not test_changes:
|
||||
return False
|
||||
return all(is_validate_only_file(p) for p in test_changes)
|
||||
|
||||
|
||||
def _select_platform_by_preference(
|
||||
@@ -977,6 +1007,17 @@ def main() -> None:
|
||||
if component not in directly_changed_components
|
||||
]
|
||||
|
||||
# Components whose only changes are validate.*.yaml files can skip the
|
||||
# compile stage in CI -- their source and test fixtures didn't move, so
|
||||
# rebuilding firmware adds no signal. Only directly-changed components
|
||||
# qualify: a component pulled in transitively (because a dependency
|
||||
# changed) still needs the compile to catch regressions.
|
||||
validate_only_components = sorted(
|
||||
component
|
||||
for component in directly_changed_with_tests
|
||||
if _component_change_is_validate_only(component, changed)
|
||||
)
|
||||
|
||||
# Detect components for memory impact analysis (merged config)
|
||||
memory_impact = detect_memory_impact_config(args.branch)
|
||||
|
||||
@@ -1073,6 +1114,7 @@ def main() -> None:
|
||||
"cpp_unit_tests_run_all": cpp_run_all,
|
||||
"cpp_unit_tests_components": cpp_components,
|
||||
"component_test_batches": component_test_batches,
|
||||
"validate_only_components": validate_only_components,
|
||||
"benchmarks": run_benchmarks,
|
||||
}
|
||||
|
||||
|
||||
+25
-3
@@ -117,7 +117,7 @@ def get_component_from_path(file_path: str) -> str | None:
|
||||
|
||||
|
||||
def get_component_test_files(
|
||||
component: str, *, all_variants: bool = False
|
||||
component: str, *, all_variants: bool = False, include_validate: bool = False
|
||||
) -> list[Path]:
|
||||
"""Get test files for a component.
|
||||
|
||||
@@ -126,6 +126,10 @@ def get_component_test_files(
|
||||
all_variants: If True, returns all test files including variants (test-*.yaml).
|
||||
If False, returns only base test files (test.*.yaml).
|
||||
Default is False.
|
||||
include_validate: If True, also returns config-only files (validate.*.yaml,
|
||||
and validate-*.yaml when all_variants is True). These files
|
||||
are validated with `esphome config` but never compiled.
|
||||
Default is False.
|
||||
|
||||
Returns:
|
||||
List of test file paths for the component, or empty list if none exist
|
||||
@@ -136,9 +140,27 @@ def get_component_test_files(
|
||||
|
||||
if all_variants:
|
||||
# Match both test.*.yaml and test-*.yaml patterns
|
||||
return list(tests_dir.glob("test[.-]*.yaml"))
|
||||
files = list(tests_dir.glob("test[.-]*.yaml"))
|
||||
if include_validate:
|
||||
files.extend(tests_dir.glob("validate[.-]*.yaml"))
|
||||
return files
|
||||
# Match only test.*.yaml (base tests)
|
||||
return list(tests_dir.glob("test.*.yaml"))
|
||||
files = list(tests_dir.glob("test.*.yaml"))
|
||||
if include_validate:
|
||||
files.extend(tests_dir.glob("validate.*.yaml"))
|
||||
return files
|
||||
|
||||
|
||||
def is_validate_only_file(test_file: Path) -> bool:
|
||||
"""Return True if the given path is a config-only validate file.
|
||||
|
||||
Validate files follow the same grammar as test files but with a
|
||||
``validate`` prefix instead of ``test``: ``validate.<platform>.yaml``
|
||||
or ``validate-<variant>.<platform>.yaml``. They are exercised with
|
||||
``esphome config`` only and skipped during compile.
|
||||
"""
|
||||
name = test_file.name
|
||||
return name.startswith("validate.") or name.startswith("validate-")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -44,14 +44,21 @@ ALL_PLATFORMS = "all"
|
||||
def has_test_files(component_name: str, tests_dir: Path) -> bool:
|
||||
"""Check if a component has test files.
|
||||
|
||||
Validate files (validate.*.yaml) count -- a component with only config-only
|
||||
test files still needs a CI runner for schema validation.
|
||||
|
||||
Args:
|
||||
component_name: Name of the component
|
||||
tests_dir: Path to tests/components directory (unused, kept for compatibility)
|
||||
|
||||
Returns:
|
||||
True if the component has test.*.yaml or test-*.yaml files
|
||||
True if the component has test.*.yaml, test-*.yaml, or validate.*.yaml files
|
||||
"""
|
||||
return bool(get_component_test_files(component_name, all_variants=True))
|
||||
return bool(
|
||||
get_component_test_files(
|
||||
component_name, all_variants=True, include_validate=True
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_intelligent_batches(
|
||||
|
||||
@@ -39,7 +39,11 @@ from script.analyze_component_buses import (
|
||||
merge_compatible_bus_groups,
|
||||
uses_local_file_references,
|
||||
)
|
||||
from script.helpers import get_component_test_files, split_conflicting_groups
|
||||
from script.helpers import (
|
||||
get_component_test_files,
|
||||
is_validate_only_file,
|
||||
split_conflicting_groups,
|
||||
)
|
||||
from script.merge_component_configs import merge_component_configs
|
||||
|
||||
|
||||
@@ -83,7 +87,10 @@ def show_disk_space_if_ci(esphome_command: str) -> None:
|
||||
|
||||
|
||||
def find_component_tests(
|
||||
components_dir: Path, component_pattern: str = "*", base_only: bool = False
|
||||
components_dir: Path,
|
||||
component_pattern: str = "*",
|
||||
base_only: bool = False,
|
||||
include_validate: bool = False,
|
||||
) -> dict[str, list[Path]]:
|
||||
"""Find all component test files.
|
||||
|
||||
@@ -91,6 +98,8 @@ def find_component_tests(
|
||||
components_dir: Path to tests/components directory
|
||||
component_pattern: Glob pattern for component names
|
||||
base_only: If True, only find base test files (test.*.yaml), not variant files (test-*.yaml)
|
||||
include_validate: If True, also include config-only files (validate.*.yaml).
|
||||
These are run with `esphome config` only and never compiled.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping component name to list of test files
|
||||
@@ -102,7 +111,11 @@ def find_component_tests(
|
||||
continue
|
||||
|
||||
# Get test files using helper function
|
||||
test_files = get_component_test_files(comp_dir.name, all_variants=not base_only)
|
||||
test_files = get_component_test_files(
|
||||
comp_dir.name,
|
||||
all_variants=not base_only,
|
||||
include_validate=include_validate,
|
||||
)
|
||||
if test_files:
|
||||
component_tests[comp_dir.name] = test_files
|
||||
|
||||
@@ -836,12 +849,25 @@ def run_grouped_component_tests(
|
||||
# With grouping:
|
||||
# - 1 build per group (regardless of how many components)
|
||||
# - Individual components still need all their platform builds
|
||||
# - Validate files of grouped components still run individually
|
||||
# (they're config-only and bypass the grouped compile, see
|
||||
# run_individual_component_test), so each adds one more invocation.
|
||||
individual_test_file_count = sum(
|
||||
len(all_tests[comp]) for comp in individual_tests if comp in all_tests
|
||||
)
|
||||
|
||||
grouped_component_set = {c for _, _, comps in groups_to_test for c in comps}
|
||||
grouped_validate_file_count = sum(
|
||||
1
|
||||
for comp in grouped_component_set
|
||||
for test_file in all_tests.get(comp, [])
|
||||
if is_validate_only_file(test_file)
|
||||
)
|
||||
|
||||
total_grouped_components = sum(len(comps) for _, _, comps in groups_to_test)
|
||||
total_builds_with_grouping = len(groups_to_test) + individual_test_file_count
|
||||
total_builds_with_grouping = (
|
||||
len(groups_to_test) + individual_test_file_count + grouped_validate_file_count
|
||||
)
|
||||
builds_saved = total_test_files - total_builds_with_grouping
|
||||
|
||||
print(f"\n{'=' * 80}")
|
||||
@@ -854,6 +880,10 @@ def run_grouped_component_tests(
|
||||
print(
|
||||
f" • {individual_test_file_count} individual builds ({len(individual_tests)} components)"
|
||||
)
|
||||
if grouped_validate_file_count:
|
||||
print(
|
||||
f" • {grouped_validate_file_count} validate-only invocations for grouped components"
|
||||
)
|
||||
if total_test_files > 0:
|
||||
reduction_pct = (builds_saved / total_test_files) * 100
|
||||
print(f" • Saves {builds_saved} builds ({reduction_pct:.1f}% reduction)")
|
||||
@@ -937,8 +967,13 @@ def run_individual_component_test(
|
||||
tested_components: Set of already tested components
|
||||
test_results: List to append test results
|
||||
"""
|
||||
# Skip if already tested in a group
|
||||
if (component, platform_with_version) in tested_components:
|
||||
# Validate files (validate.*.yaml) are config-only and never participate
|
||||
# in compile-time bus grouping, so always run them individually even when
|
||||
# the (component, platform) pair was covered by a group test.
|
||||
if (
|
||||
not is_validate_only_file(test_file)
|
||||
and (component, platform_with_version) in tested_components
|
||||
):
|
||||
return
|
||||
|
||||
test_result = run_esphome_test(
|
||||
@@ -992,13 +1027,23 @@ def test_components(
|
||||
# Get platform base files
|
||||
platform_bases = get_platform_base_files(build_components_dir)
|
||||
|
||||
# Validate files (validate.*.yaml) are config-only -- they exercise
|
||||
# schema/validation paths but are never compiled. Include them when running
|
||||
# `config` or `clean`; exclude them under `compile` so they never reach a
|
||||
# toolchain build.
|
||||
include_validate = esphome_command != "compile"
|
||||
|
||||
# Find all component tests
|
||||
all_tests = {}
|
||||
for pattern in component_patterns:
|
||||
# Skip empty patterns (happens when components list is empty string)
|
||||
if not pattern:
|
||||
continue
|
||||
all_tests.update(find_component_tests(tests_dir, pattern, base_only))
|
||||
all_tests.update(
|
||||
find_component_tests(
|
||||
tests_dir, pattern, base_only, include_validate=include_validate
|
||||
)
|
||||
)
|
||||
|
||||
# If no components found, build a reference configuration for baseline comparison
|
||||
# Create a synthetic "empty" component test that will build just the base config
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
cc1101:
|
||||
id: cc1101_radio
|
||||
cs_pin: ${cs_pin}
|
||||
frequency: 433.92MHz
|
||||
modulation_type: ASK/OOK
|
||||
output_power: 10
|
||||
|
||||
# Dual-pin wiring (recommended by the CC1101 docs):
|
||||
# CC1101 GDO0 → ${gdo0_pin} (remote_transmitter)
|
||||
# CC1101 GDO2 → ${gdo2_pin} (remote_receiver)
|
||||
remote_transmitter:
|
||||
id: rf_tx
|
||||
pin: ${gdo0_pin}
|
||||
carrier_duty_percent: 100%
|
||||
# Switch the chip into TX state for the duration of each transmission and back to RX
|
||||
# afterwards. Driver-agnostic: any RF front-end with begin_tx/begin_rx-style actions
|
||||
# can be wired this way.
|
||||
on_transmit:
|
||||
then:
|
||||
- cc1101.begin_tx: cc1101_radio
|
||||
on_complete:
|
||||
then:
|
||||
- cc1101.begin_rx: cc1101_radio
|
||||
|
||||
remote_receiver:
|
||||
id: rf_rx
|
||||
pin: ${gdo2_pin}
|
||||
|
||||
radio_frequency:
|
||||
- platform: ir_rf_proxy
|
||||
id: rf_proxy_cc1101_tx
|
||||
name: "CC1101 RF Transmitter"
|
||||
frequency: 433.92MHz
|
||||
remote_transmitter_id: rf_tx
|
||||
# Optional: retune the CC1101 per-transmit when the API request specifies a
|
||||
# different carrier frequency. Demonstrates the on_control trigger.
|
||||
on_control:
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: "return x.get_frequency().has_value() && *x.get_frequency() > 0;"
|
||||
then:
|
||||
- cc1101.set_frequency:
|
||||
id: cc1101_radio
|
||||
value: !lambda "return *x.get_frequency();"
|
||||
- platform: ir_rf_proxy
|
||||
id: rf_proxy_cc1101_rx
|
||||
name: "CC1101 RF Receiver"
|
||||
frequency: 433.92MHz
|
||||
remote_receiver_id: rf_rx
|
||||
@@ -0,0 +1,9 @@
|
||||
substitutions:
|
||||
cs_pin: GPIO5
|
||||
gdo0_pin: GPIO4
|
||||
gdo2_pin: GPIO16
|
||||
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
|
||||
cc1101: !include common-cc1101.yaml
|
||||
@@ -0,0 +1,9 @@
|
||||
substitutions:
|
||||
cs_pin: GPIO5
|
||||
gdo0_pin: GPIO4
|
||||
gdo2_pin: GPIO16
|
||||
|
||||
packages:
|
||||
common: !include common.yaml
|
||||
spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml
|
||||
cc1101: !include common-cc1101.yaml
|
||||
@@ -375,14 +375,22 @@ TEST(MitsubishiCN105Tests, ApplyFanModeSpeed1) {
|
||||
TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) {
|
||||
auto ctx = TestContext{};
|
||||
|
||||
ctx.sut.set_update_interval(2000);
|
||||
ctx.sut.set_current_time(5000);
|
||||
|
||||
// Waiting for next scheduled status update
|
||||
ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED;
|
||||
ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE);
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
|
||||
EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional<uint32_t>{5000});
|
||||
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
|
||||
|
||||
// Nothing to do in update (rx empty, no timeout)
|
||||
ctx.sut.set_current_time(5500);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_TRUE(ctx.uart.tx.empty());
|
||||
EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional<uint32_t>{5000});
|
||||
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
|
||||
|
||||
// Write new values
|
||||
ctx.sut.use_temperature_encoding_b_ = true;
|
||||
@@ -392,11 +400,52 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) {
|
||||
ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::AUTO);
|
||||
|
||||
// Waiting for next status update must be interrupted and new values send to AC
|
||||
ctx.sut.set_current_time(6000);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
|
||||
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 1000);
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x0F, 0x00, 0x00, 0x01, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB2, 0x00, 0xBB));
|
||||
|
||||
// Write ACK response
|
||||
ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E});
|
||||
ctx.sut.set_current_time(6500);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
|
||||
EXPECT_EQ(ctx.sut.status_update_start_ms_, std::optional<uint32_t>{6500 - 1000});
|
||||
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, SetAndClearRemoteRoomTemp) {
|
||||
auto ctx = TestContext{};
|
||||
|
||||
// Set remote temperature
|
||||
ctx.sut.set_remote_temperature(28.5f);
|
||||
|
||||
ctx.sut.state_ = TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE;
|
||||
ctx.sut.set_state(TestableMitsubishiCN105::State::APPLYING_SETTINGS);
|
||||
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x07, 0x01, 0x29, 0xB9, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x94));
|
||||
|
||||
// Write ACK response
|
||||
ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E});
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
|
||||
|
||||
ctx.uart.tx.clear();
|
||||
|
||||
// Clear remote temperature
|
||||
ctx.sut.clear_remote_temperature();
|
||||
|
||||
ctx.sut.set_state(TestableMitsubishiCN105::State::APPLYING_SETTINGS);
|
||||
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x07, 0x00, 0x00, 0x80, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF7));
|
||||
|
||||
// Write ACK response
|
||||
ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E});
|
||||
@@ -404,4 +453,102 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) {
|
||||
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) {
|
||||
auto ctx = TestContext{};
|
||||
|
||||
// Queue normal settings plus remote temperature together.
|
||||
ctx.sut.use_temperature_encoding_b_ = true;
|
||||
ctx.sut.set_power(false);
|
||||
ctx.sut.set_target_temperature(25.0f);
|
||||
ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT);
|
||||
ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::AUTO);
|
||||
ctx.sut.set_remote_temperature(28.5f);
|
||||
|
||||
// First apply sends only the normal settings write.
|
||||
ctx.sut.state_ = TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE;
|
||||
ctx.sut.set_state(TestableMitsubishiCN105::State::APPLYING_SETTINGS);
|
||||
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x0F, 0x00, 0x00, 0x01, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB2, 0x00, 0xBB));
|
||||
EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
|
||||
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::POWER));
|
||||
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::TEMPERATURE));
|
||||
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::MODE));
|
||||
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::FAN));
|
||||
|
||||
// ACK the first write. Remote temperature should still be pending afterward.
|
||||
ctx.uart.tx.clear();
|
||||
ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E});
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
|
||||
EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
|
||||
|
||||
// The next apply sends the remote-temperature packet and clears the last pending flag.
|
||||
ctx.uart.tx.clear();
|
||||
ctx.sut.set_state(TestableMitsubishiCN105::State::APPLYING_SETTINGS);
|
||||
|
||||
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x07, 0x01, 0x29, 0xB9, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x94));
|
||||
EXPECT_FALSE(ctx.sut.pending_updates_.any());
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) {
|
||||
auto ctx = TestContext{};
|
||||
ctx.sut.set_update_interval(2000);
|
||||
ctx.sut.set_current_time(5000);
|
||||
|
||||
// Start in the scheduled status update wait state.
|
||||
ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED;
|
||||
ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE);
|
||||
ASSERT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
|
||||
ASSERT_EQ(ctx.sut.status_update_start_ms_, std::optional<uint32_t>{5000});
|
||||
ASSERT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
|
||||
|
||||
// Interrupt that wait with a write so credit is accumulated.
|
||||
ctx.sut.use_temperature_encoding_b_ = true;
|
||||
ctx.sut.set_power(false);
|
||||
ctx.sut.set_target_temperature(25.0f);
|
||||
ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT);
|
||||
ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::AUTO);
|
||||
ctx.sut.set_current_time(6000);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
ASSERT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
|
||||
ASSERT_FALSE(ctx.sut.status_update_start_ms_.has_value());
|
||||
ASSERT_EQ(ctx.sut.status_update_wait_credit_ms_, 1000);
|
||||
|
||||
// Do not ACK the write. Advance time far enough to force timeout/reconnect
|
||||
// handling and verify that stale wait credit is cleared during recovery.
|
||||
ctx.sut.set_current_time(36000);
|
||||
ASSERT_FALSE(ctx.sut.update());
|
||||
EXPECT_NE(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
|
||||
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
|
||||
EXPECT_FALSE(ctx.sut.status_update_start_ms_.has_value());
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, SetOutOfRangeRemoteRoomTempIsIgnored) {
|
||||
auto ctx = TestContext{};
|
||||
|
||||
ctx.sut.set_remote_temperature(7.0f);
|
||||
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
|
||||
|
||||
ctx.sut.set_remote_temperature(40.0f);
|
||||
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
|
||||
|
||||
ctx.sut.set_remote_temperature(NAN);
|
||||
EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, SetMinRemoteRoomTemp) {
|
||||
auto ctx = TestContext{};
|
||||
ctx.sut.set_remote_temperature(8.0f);
|
||||
EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
|
||||
}
|
||||
|
||||
TEST(MitsubishiCN105Tests, SetMaxRemoteRoomTemp) {
|
||||
auto ctx = TestContext{};
|
||||
ctx.sut.set_remote_temperature(39.5f);
|
||||
EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE));
|
||||
}
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105::testing
|
||||
|
||||
@@ -42,10 +42,13 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 {
|
||||
public:
|
||||
using MitsubishiCN105::MitsubishiCN105;
|
||||
using MitsubishiCN105::State;
|
||||
using MitsubishiCN105::UpdateFlag;
|
||||
using MitsubishiCN105::state_;
|
||||
using MitsubishiCN105::write_timeout_start_ms_;
|
||||
using MitsubishiCN105::status_update_start_ms_;
|
||||
using MitsubishiCN105::use_temperature_encoding_b_;
|
||||
using MitsubishiCN105::status_update_wait_credit_ms_;
|
||||
using MitsubishiCN105::pending_updates_;
|
||||
|
||||
void set_state(State s) { this->set_state_(s); }
|
||||
void apply_settings() { this->apply_settings_(); }
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
climate:
|
||||
- platform: mitsubishi_cn105
|
||||
id: ac
|
||||
name: "AC Test"
|
||||
uart_id: uart_bus
|
||||
|
||||
esphome:
|
||||
on_boot:
|
||||
then:
|
||||
- climate.mitsubishi_cn105.set_remote_temperature:
|
||||
id: ac
|
||||
temperature: 22.0
|
||||
- climate.mitsubishi_cn105.clear_remote_temperature:
|
||||
id: ac
|
||||
|
||||
@@ -4,6 +4,7 @@ sensor:
|
||||
temperature:
|
||||
name: Temperature
|
||||
humidity:
|
||||
i2c_id: i2c_bus
|
||||
name: Humidity
|
||||
pressure:
|
||||
name: Pressure
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
ethernet:
|
||||
type: OPENETH
|
||||
|
||||
psram:
|
||||
mode: quad
|
||||
|
||||
sendspin:
|
||||
id: sendspin_hub_id
|
||||
task_stack_in_psram: true
|
||||
@@ -31,6 +31,11 @@ microphone:
|
||||
i2s_din_pin: ${i2s_din_pin}
|
||||
adc_type: external
|
||||
pdm: false
|
||||
- platform: i2s_audio
|
||||
id: mic_id_external2
|
||||
i2s_din_pin: ${i2s_din_pin2}
|
||||
adc_type: external
|
||||
pdm: false
|
||||
|
||||
speaker:
|
||||
- platform: i2s_audio
|
||||
@@ -40,9 +45,12 @@ speaker:
|
||||
|
||||
voice_assistant:
|
||||
microphone:
|
||||
microphone: mic_id_external
|
||||
gain_factor: 4
|
||||
channels: 0
|
||||
- microphone: mic_id_external
|
||||
gain_factor: 4
|
||||
channels: 0
|
||||
- microphone: mic_id_external2
|
||||
gain_factor: 4
|
||||
channels: 0
|
||||
speaker: speaker_id
|
||||
micro_wake_word: mww_id
|
||||
conversation_timeout: 60s
|
||||
|
||||
@@ -3,6 +3,7 @@ substitutions:
|
||||
i2s_bclk_pin: GPIO5
|
||||
i2s_mclk_pin: GPIO15
|
||||
i2s_din_pin: GPIO13
|
||||
i2s_din_pin2: GPIO14
|
||||
i2s_dout_pin: GPIO12
|
||||
|
||||
<<: !include common-idf.yaml
|
||||
|
||||
@@ -2215,3 +2215,230 @@ def test_should_run_benchmarks_with_branch() -> None:
|
||||
mock_changed.return_value = []
|
||||
determine_jobs.should_run_benchmarks("release")
|
||||
mock_changed.assert_called_with("release")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _component_change_is_validate_only
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("component", "changed", "expected"),
|
||||
[
|
||||
# Only a base validate file changed.
|
||||
(
|
||||
"foo",
|
||||
["tests/components/foo/validate.esp32-idf.yaml"],
|
||||
True,
|
||||
),
|
||||
# Only a validate variant changed.
|
||||
(
|
||||
"foo",
|
||||
["tests/components/foo/validate-legacy.esp32-idf.yaml"],
|
||||
True,
|
||||
),
|
||||
# Multiple validate files (all validate).
|
||||
(
|
||||
"foo",
|
||||
[
|
||||
"tests/components/foo/validate.esp32-idf.yaml",
|
||||
"tests/components/foo/validate-legacy.esp32-idf.yaml",
|
||||
],
|
||||
True,
|
||||
),
|
||||
# Mixed: validate + regular test must NOT be classified as validate-only.
|
||||
(
|
||||
"foo",
|
||||
[
|
||||
"tests/components/foo/validate.esp32-idf.yaml",
|
||||
"tests/components/foo/test.esp32-idf.yaml",
|
||||
],
|
||||
False,
|
||||
),
|
||||
# Regular test only.
|
||||
(
|
||||
"foo",
|
||||
["tests/components/foo/test.esp32-idf.yaml"],
|
||||
False,
|
||||
),
|
||||
# Source change disqualifies even if a validate file is also touched.
|
||||
(
|
||||
"foo",
|
||||
[
|
||||
"esphome/components/foo/foo.cpp",
|
||||
"tests/components/foo/validate.esp32-idf.yaml",
|
||||
],
|
||||
False,
|
||||
),
|
||||
# No matching files at all.
|
||||
("foo", ["esphome/core/helpers.cpp"], False),
|
||||
# Filenames merely starting with "validate" but not following the
|
||||
# grammar must not match (defensive against accidental classification).
|
||||
(
|
||||
"foo",
|
||||
["tests/components/foo/validatesomething.yaml"],
|
||||
False,
|
||||
),
|
||||
# An unrelated component's validate change doesn't affect this one.
|
||||
(
|
||||
"foo",
|
||||
["tests/components/bar/validate.esp32-idf.yaml"],
|
||||
False,
|
||||
),
|
||||
# common.yaml change in the component dir disqualifies.
|
||||
(
|
||||
"foo",
|
||||
[
|
||||
"tests/components/foo/common.yaml",
|
||||
"tests/components/foo/validate.esp32-idf.yaml",
|
||||
],
|
||||
False,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_component_change_is_validate_only(
|
||||
component: str, changed: list[str], expected: bool
|
||||
) -> None:
|
||||
"""The validate-only classifier rejects anything beyond validate.* edits."""
|
||||
assert (
|
||||
determine_jobs._component_change_is_validate_only(component, changed)
|
||||
is expected
|
||||
)
|
||||
|
||||
|
||||
def test_main_emits_validate_only_components(
|
||||
mock_determine_integration_tests: Mock,
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
mock_should_run_import_time: Mock,
|
||||
mock_should_run_device_builder: Mock,
|
||||
mock_changed_files: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Directly-changed components whose only edits are validate.*.yaml are
|
||||
listed in `validate_only_components` so CI can skip their compile stage.
|
||||
"""
|
||||
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
||||
|
||||
mock_determine_integration_tests.return_value = (False, [])
|
||||
mock_should_run_clang_tidy.return_value = False
|
||||
mock_should_run_clang_format.return_value = False
|
||||
mock_should_run_python_linters.return_value = False
|
||||
mock_should_run_import_time.return_value = False
|
||||
mock_should_run_device_builder.return_value = False
|
||||
mock_determine_cpp_unit_tests.return_value = (False, [])
|
||||
|
||||
# foo: only validate file changed (qualifies)
|
||||
# bar: test file changed (does not qualify)
|
||||
mock_changed_files.return_value = [
|
||||
"tests/components/foo/validate.esp32-idf.yaml",
|
||||
"tests/components/bar/test.esp32-idf.yaml",
|
||||
]
|
||||
|
||||
with (
|
||||
patch("sys.argv", ["determine-jobs.py"]),
|
||||
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"get_changed_components",
|
||||
return_value=["foo", "bar"],
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"filter_component_and_test_files",
|
||||
side_effect=lambda f: f.startswith("tests/components/"),
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"get_components_with_dependencies",
|
||||
side_effect=lambda files, deps: ["foo", "bar"],
|
||||
),
|
||||
patch.object(determine_jobs, "_component_has_tests", return_value=True),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"detect_memory_impact_config",
|
||||
return_value={"should_run": "false"},
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"create_intelligent_batches",
|
||||
return_value=([["foo", "bar"]], {}),
|
||||
),
|
||||
):
|
||||
determine_jobs.main()
|
||||
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
assert output["validate_only_components"] == ["foo"]
|
||||
|
||||
|
||||
def test_main_validate_only_excludes_transitive_components(
|
||||
mock_determine_integration_tests: Mock,
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
mock_should_run_import_time: Mock,
|
||||
mock_should_run_device_builder: Mock,
|
||||
mock_changed_files: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A component pulled in only as a dependency must NOT be considered
|
||||
validate-only, even if it has no source changes -- its dependency moved,
|
||||
so the compile is still required.
|
||||
"""
|
||||
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
||||
|
||||
mock_determine_integration_tests.return_value = (False, [])
|
||||
mock_should_run_clang_tidy.return_value = False
|
||||
mock_should_run_clang_format.return_value = False
|
||||
mock_should_run_python_linters.return_value = False
|
||||
mock_should_run_import_time.return_value = False
|
||||
mock_should_run_device_builder.return_value = False
|
||||
mock_determine_cpp_unit_tests.return_value = (False, [])
|
||||
|
||||
# Only foo's validate file changed directly. bar is a transitive dep.
|
||||
mock_changed_files.return_value = [
|
||||
"tests/components/foo/validate.esp32-idf.yaml",
|
||||
]
|
||||
|
||||
with (
|
||||
patch("sys.argv", ["determine-jobs.py"]),
|
||||
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"get_changed_components",
|
||||
return_value=["foo", "bar"], # bar pulled in via dependencies
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"filter_component_and_test_files",
|
||||
side_effect=lambda f: f.startswith("tests/components/"),
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"get_components_with_dependencies",
|
||||
# deps=False -> directly_changed = [foo]; deps=True -> [foo, bar]
|
||||
side_effect=lambda files, deps: ["foo", "bar"] if deps else ["foo"],
|
||||
),
|
||||
patch.object(determine_jobs, "_component_has_tests", return_value=True),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"detect_memory_impact_config",
|
||||
return_value={"should_run": "false"},
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"create_intelligent_batches",
|
||||
return_value=([["foo", "bar"]], {}),
|
||||
),
|
||||
):
|
||||
determine_jobs.main()
|
||||
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
# Only foo (directly changed, validate-only). bar is a transitive dep
|
||||
# and still needs compile despite no source change of its own.
|
||||
assert output["validate_only_components"] == ["foo"]
|
||||
|
||||
@@ -1624,3 +1624,171 @@ def test_split_conflicting_groups_preserves_original_signature_for_first_bucket(
|
||||
platform, signature = next(iter(extra))
|
||||
assert platform == "esp32"
|
||||
assert signature.startswith("i2c__conflict")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_component_test_files / is_validate_only_file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_component_tests(tmp_path: Path) -> Path:
|
||||
"""Create a fake tests/components/ tree and return the repo root.
|
||||
|
||||
Layout for component "demo":
|
||||
test.esp32-idf.yaml
|
||||
test.esp8266-ard.yaml
|
||||
test-variant.esp32-idf.yaml
|
||||
validate.esp32-idf.yaml
|
||||
validate-legacy.esp32-idf.yaml
|
||||
|
||||
Layout for component "validate_only":
|
||||
validate.esp32-idf.yaml (only validate files)
|
||||
|
||||
Layout for component "no_tests":
|
||||
common.yaml (no test/validate files at all)
|
||||
"""
|
||||
tests_dir = tmp_path / "tests" / "components"
|
||||
|
||||
demo = tests_dir / "demo"
|
||||
demo.mkdir(parents=True)
|
||||
(demo / "test.esp32-idf.yaml").write_text("")
|
||||
(demo / "test.esp8266-ard.yaml").write_text("")
|
||||
(demo / "test-variant.esp32-idf.yaml").write_text("")
|
||||
(demo / "validate.esp32-idf.yaml").write_text("")
|
||||
(demo / "validate-legacy.esp32-idf.yaml").write_text("")
|
||||
|
||||
validate_only = tests_dir / "validate_only"
|
||||
validate_only.mkdir(parents=True)
|
||||
(validate_only / "validate.esp32-idf.yaml").write_text("")
|
||||
|
||||
no_tests = tests_dir / "no_tests"
|
||||
no_tests.mkdir(parents=True)
|
||||
(no_tests / "common.yaml").write_text("")
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _names(paths: list[Path]) -> set[str]:
|
||||
return {p.name for p in paths}
|
||||
|
||||
|
||||
def test_get_component_test_files_default_excludes_validate(
|
||||
fake_component_tests: Path, monkeypatch: MonkeyPatch
|
||||
) -> None:
|
||||
"""Default behaviour: only base test.*.yaml; no variants, no validate."""
|
||||
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
|
||||
|
||||
files = helpers.get_component_test_files("demo")
|
||||
|
||||
assert _names(files) == {"test.esp32-idf.yaml", "test.esp8266-ard.yaml"}
|
||||
|
||||
|
||||
def test_get_component_test_files_all_variants_excludes_validate(
|
||||
fake_component_tests: Path, monkeypatch: MonkeyPatch
|
||||
) -> None:
|
||||
"""all_variants=True picks up test variants but still skips validate."""
|
||||
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
|
||||
|
||||
files = helpers.get_component_test_files("demo", all_variants=True)
|
||||
|
||||
assert _names(files) == {
|
||||
"test.esp32-idf.yaml",
|
||||
"test.esp8266-ard.yaml",
|
||||
"test-variant.esp32-idf.yaml",
|
||||
}
|
||||
|
||||
|
||||
def test_get_component_test_files_include_validate_base_only(
|
||||
fake_component_tests: Path, monkeypatch: MonkeyPatch
|
||||
) -> None:
|
||||
"""include_validate=True with base-only adds validate.*.yaml only."""
|
||||
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
|
||||
|
||||
files = helpers.get_component_test_files("demo", include_validate=True)
|
||||
|
||||
assert _names(files) == {
|
||||
"test.esp32-idf.yaml",
|
||||
"test.esp8266-ard.yaml",
|
||||
"validate.esp32-idf.yaml",
|
||||
}
|
||||
|
||||
|
||||
def test_get_component_test_files_include_validate_all_variants(
|
||||
fake_component_tests: Path, monkeypatch: MonkeyPatch
|
||||
) -> None:
|
||||
"""include_validate=True with all_variants adds validate variants too."""
|
||||
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
|
||||
|
||||
files = helpers.get_component_test_files(
|
||||
"demo", all_variants=True, include_validate=True
|
||||
)
|
||||
|
||||
assert _names(files) == {
|
||||
"test.esp32-idf.yaml",
|
||||
"test.esp8266-ard.yaml",
|
||||
"test-variant.esp32-idf.yaml",
|
||||
"validate.esp32-idf.yaml",
|
||||
"validate-legacy.esp32-idf.yaml",
|
||||
}
|
||||
|
||||
|
||||
def test_get_component_test_files_validate_only_component(
|
||||
fake_component_tests: Path, monkeypatch: MonkeyPatch
|
||||
) -> None:
|
||||
"""A component with only validate files is invisible without the flag."""
|
||||
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
|
||||
|
||||
assert helpers.get_component_test_files("validate_only") == []
|
||||
assert helpers.get_component_test_files("validate_only", all_variants=True) == []
|
||||
|
||||
files = helpers.get_component_test_files(
|
||||
"validate_only", all_variants=True, include_validate=True
|
||||
)
|
||||
assert _names(files) == {"validate.esp32-idf.yaml"}
|
||||
|
||||
|
||||
def test_get_component_test_files_missing_component(
|
||||
fake_component_tests: Path, monkeypatch: MonkeyPatch
|
||||
) -> None:
|
||||
"""Unknown components return an empty list, regardless of flags."""
|
||||
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
|
||||
|
||||
assert (
|
||||
helpers.get_component_test_files(
|
||||
"does_not_exist", all_variants=True, include_validate=True
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
def test_get_component_test_files_component_without_tests(
|
||||
fake_component_tests: Path, monkeypatch: MonkeyPatch
|
||||
) -> None:
|
||||
"""A component with only common.yaml and no test/validate files returns []."""
|
||||
monkeypatch.setattr(helpers, "root_path", str(fake_component_tests))
|
||||
|
||||
assert (
|
||||
helpers.get_component_test_files(
|
||||
"no_tests", all_variants=True, include_validate=True
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filename", "expected"),
|
||||
[
|
||||
("validate.esp32-idf.yaml", True),
|
||||
("validate-legacy.esp32-idf.yaml", True),
|
||||
("validate.host.yaml", True),
|
||||
("test.esp32-idf.yaml", False),
|
||||
("test-variant.esp32-idf.yaml", False),
|
||||
("common.yaml", False),
|
||||
# Defensive: a hypothetical name starting with "validate" but not
|
||||
# following the grammar must not be classified as a validate file.
|
||||
("validatesomething.yaml", False),
|
||||
],
|
||||
)
|
||||
def test_is_validate_only_file(filename: str, expected: bool, tmp_path: Path) -> None:
|
||||
assert helpers.is_validate_only_file(tmp_path / filename) is expected
|
||||
|
||||
@@ -29,6 +29,7 @@ from esphome.__main__ import (
|
||||
command_analyze_memory,
|
||||
command_bundle,
|
||||
command_clean_all,
|
||||
command_config_hash,
|
||||
command_rename,
|
||||
command_run,
|
||||
command_update_all,
|
||||
@@ -3439,6 +3440,33 @@ def test_command_wizard(tmp_path: Path) -> None:
|
||||
mock_wizard.assert_called_once_with(config_file)
|
||||
|
||||
|
||||
def test_command_config_hash(
|
||||
tmp_path: Path,
|
||||
capfd: CaptureFixture[str],
|
||||
) -> None:
|
||||
"""command_config_hash runs codegen then prints CORE.config_hash.
|
||||
|
||||
The printed format must match `0x{config_hash:08x}` used by
|
||||
generate_build_info_data_cpp so the value can be compared byte-for-byte
|
||||
against the ESPHOME_CONFIG_HASH embedded in firmware.
|
||||
"""
|
||||
setup_core(tmp_path=tmp_path, config={"esphome": {"name": "test"}})
|
||||
args = MockArgs()
|
||||
|
||||
# generate_cpp_contents requires real components to be loaded; mock it out
|
||||
# so this test isolates the command's output contract. The command must
|
||||
# still call it (codegen can mutate config, which affects the hash).
|
||||
with patch("esphome.__main__.generate_cpp_contents") as mock_generate:
|
||||
result = command_config_hash(args, CORE.config)
|
||||
|
||||
assert result == 0
|
||||
mock_generate.assert_called_once_with(CORE.config)
|
||||
|
||||
output = strip_ansi_codes(capfd.readouterr().out).strip()
|
||||
assert re.fullmatch(r"0x[0-9a-f]{8}", output)
|
||||
assert output == f"0x{CORE.config_hash:08x}"
|
||||
|
||||
|
||||
def test_command_rename_invalid_characters(
|
||||
tmp_path: Path, capfd: CaptureFixture[str]
|
||||
) -> None:
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from contextlib import contextmanager
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, call, patch
|
||||
|
||||
@@ -867,6 +870,56 @@ def test_patch_file_downloader_closes_session_and_response_between_retries() ->
|
||||
mock_session.close.assert_called_once()
|
||||
|
||||
|
||||
def test_patch_file_downloader_retries_on_connection_error() -> None:
|
||||
"""Test patch_file_downloader retries on transport-layer errors (OSError subclasses).
|
||||
|
||||
``requests.exceptions.ConnectionError`` and ``ReadTimeout`` subclass
|
||||
``OSError`` and are raised when the connection is aborted before any HTTP
|
||||
response is parsed -- e.g. ``RemoteDisconnected`` mid-download. These must
|
||||
retry too, not just ``PackageException``.
|
||||
"""
|
||||
mock_exception_cls = type("PackageException", (Exception,), {})
|
||||
call_count = 0
|
||||
|
||||
def failing_init(self, *args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3:
|
||||
raise ConnectionError(
|
||||
f"Connection aborted attempt {call_count}: RemoteDisconnected"
|
||||
)
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"platformio": MagicMock(),
|
||||
"platformio.package": MagicMock(),
|
||||
"platformio.package.download": SimpleNamespace(
|
||||
FileDownloader=type(
|
||||
"FileDownloader", (), {"__init__": failing_init}
|
||||
)
|
||||
),
|
||||
"platformio.package.exception": SimpleNamespace(
|
||||
PackageException=mock_exception_cls
|
||||
),
|
||||
},
|
||||
),
|
||||
patch("time.sleep") as mock_sleep,
|
||||
):
|
||||
runner.patch_file_downloader()
|
||||
|
||||
from platformio.package.download import FileDownloader
|
||||
|
||||
instance = object.__new__(FileDownloader)
|
||||
FileDownloader.__init__(instance, "http://example.com/file.zip")
|
||||
|
||||
assert call_count == 3
|
||||
assert mock_sleep.call_count == 2
|
||||
mock_sleep.assert_any_call(2)
|
||||
mock_sleep.assert_any_call(4)
|
||||
|
||||
|
||||
def test_patch_file_downloader_idempotent() -> None:
|
||||
"""Test patch_file_downloader does not stack wrappers when called multiple times."""
|
||||
mock_exception_cls = type("PackageException", (Exception,), {})
|
||||
@@ -903,6 +956,74 @@ def test_patch_file_downloader_idempotent() -> None:
|
||||
assert call_count == 1
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _flaky_http_server(fail_first_n: int, fail_mode: str):
|
||||
"""Local HTTP server that fails the first ``fail_first_n`` requests.
|
||||
|
||||
``fail_mode="drop"`` closes the TCP connection without responding, so
|
||||
the client raises ``RemoteDisconnected`` -- the exact CI failure mode.
|
||||
``fail_mode="502"`` returns an HTTP 502, triggering ``PackageException``.
|
||||
"""
|
||||
state = {"hits": 0}
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
def handle_one_request(self) -> None:
|
||||
state["hits"] += 1
|
||||
if state["hits"] <= fail_first_n and fail_mode == "drop":
|
||||
return # Skip read+respond → kernel sends FIN → RemoteDisconnected
|
||||
super().handle_one_request()
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
if state["hits"] <= fail_first_n and fail_mode == "502":
|
||||
self.send_error(502)
|
||||
return
|
||||
body = b"esphome-test-payload"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None: # noqa: A002
|
||||
pass # silence default stderr logging
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield server.server_address[1], state
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fail_mode", ["drop", "502"])
|
||||
def test_patch_file_downloader_recovers_against_real_server(
|
||||
tmp_path: Path, fail_mode: str
|
||||
) -> None:
|
||||
"""End-to-end: real PlatformIO ``FileDownloader`` against a local server
|
||||
that fails twice then succeeds. Exercises the real
|
||||
requests/urllib3/http.client stack for both failure modes:
|
||||
|
||||
- ``drop``: TCP close mid-request → ``RemoteDisconnected`` → caught as
|
||||
``OSError`` by the retry patch (the CI failure path).
|
||||
- ``502``: HTTP error response → ``PackageException`` (the original path).
|
||||
"""
|
||||
runner.patch_file_downloader()
|
||||
from platformio.package.download import FileDownloader
|
||||
|
||||
with (
|
||||
_flaky_http_server(fail_first_n=2, fail_mode=fail_mode) as (port, state),
|
||||
patch("time.sleep"),
|
||||
):
|
||||
fd = FileDownloader(f"http://127.0.0.1:{port}/payload.bin")
|
||||
fd.set_destination(str(tmp_path / "out.bin"))
|
||||
fd.start(with_progress=False, silent=True)
|
||||
|
||||
assert state["hits"] == 3 # 2 failures + 1 success
|
||||
assert (tmp_path / "out.bin").read_bytes() == b"esphome-test-payload"
|
||||
|
||||
|
||||
def _filter_through_redirect(line: str) -> str:
|
||||
"""Write a line through RedirectText with FILTER_PLATFORMIO_LINES and return what passes."""
|
||||
import io
|
||||
|
||||
@@ -818,3 +818,23 @@ def test_resolve_include_error_no_expanded_from_for_literal_filename(
|
||||
substitutions.resolve_include(include, [], substitutions.ContextVars())
|
||||
|
||||
assert "expanded from" not in str(exc_info.value)
|
||||
|
||||
|
||||
def test_include_vars_applied_to_lambda_value(tmp_path: Path) -> None:
|
||||
"""!include vars: must substitute into a top-level !lambda value in the included file.
|
||||
|
||||
Regression test for the case where the included file's root is a Lambda;
|
||||
add_context() previously only tagged dict/list/str, so the include's vars
|
||||
never reached the substitution pass for Lambda content.
|
||||
"""
|
||||
included = tmp_path / "lambda.yaml"
|
||||
included.write_text('!lambda |-\n return "${foo}";\n')
|
||||
|
||||
include = yaml_util.IncludeFile(
|
||||
tmp_path / "main.yaml", "lambda.yaml", {"foo": "bar"}, yaml_util.load_yaml
|
||||
)
|
||||
config = OrderedDict({"value": include.load()})
|
||||
result = substitutions.do_substitution_pass(config)
|
||||
|
||||
assert isinstance(result["value"], Lambda)
|
||||
assert result["value"].value == 'return "bar";'
|
||||
|
||||
Reference in New Issue
Block a user