Compare commits

...
Author SHA1 Message Date
J. Nick Koston 92f9a97869 Keep the state waiter apart from the states dict 2026-10-21 10:54:58 -10:00
J. Nick Koston 90ecc02630 Explain the backing timeout, drop a repeated comment and name the line waiter consistently 2026-10-21 10:52:49 -10:00
J. Nick Koston 73b08d732c [tests] Use LineWaiter for the integration tests that only wait on log lines 2026-10-21 10:28:59 -10:00
J. Nick Koston 63b610bf17 [tests] Wait for the ld2410 query ACK log line instead of asserting it arrived (#21191) 2026-10-21 10:13:22 -10:00
J. Nick Koston 62c9e05541 [tests] Wait for the template sensor's first reading before the Home Assistant service tests (#21302) 2026-10-21 09:56:20 -10:00
esphome[bot] 671ac3e0bc Bump bundled esphome-device-builder to 1.26.0 (#21299) 2026-10-21 09:45:18 -10:00
dependabot[bot] 6671ded245 Bump platformdirs from 4.13.3 to 4.13.4 (#21297)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-21 19:32:36 +00:00
dependabot[bot] 9010c4bb2e Bump filelock from 4.2.1 to 4.2.2 (#21295)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-21 19:32:16 +00:00
J. Nick Koston e0a48ff683 [waveshare_epaper] Set removal of the deprecated component for 2027.5.0 (#20692) 2026-10-21 19:06:38 +00:00
e3d7b957a7 [mqtt] increase TLS options and compatibility (#14318)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-21 19:02:31 +00:00
J. Nick Koston 9ab230f98d [speaker] Build the pipeline task names on the stack (#21203) 2026-10-21 08:41:59 -10:00
Jonathan Swoboda 35883270fe [ci] Ignore BUILD_ALWAYS external project byproducts in the idf.py check (#21291) 2026-10-21 08:35:58 -10:00
J. Nick Koston da37e89b9d [tests] Let the offline probe finish before checking that the continuous modbus poll stopped (#21293) 2026-10-21 08:35:38 -10:00
J. Nick Koston 32bbf55f2a [tests] Clear testing_mode after every test (#21292) 2026-10-21 08:35:22 -10:00
Kevin Ahrendt 16759c848f [i2s_audio] Convert audio to the configured slot width on the original ESP32 (#20893) 2026-10-21 14:25:38 -04:00
J. Nick Koston 244e419f82 [tests] Drop the timeout wrappers around the integration state helpers (#21245) 2026-10-21 08:04:00 -10:00
J. Nick Koston 04f53f0ab4 [tests] Fail the test when the integration state helpers time out (#21244) 2026-10-21 08:03:59 -10:00
9eef601f99 [network][socket] Allow disabling IPV4 (#14526)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-21 13:57:49 -04:00
J. Nick Koston 454dbe59db [tests] Speed up the modbus uart_mock integration tests (#21250) 2026-10-21 07:48:38 -10:00
147 changed files with 2072 additions and 1316 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.25.0
RUN uv pip install --no-cache-dir esphome-device-builder==1.26.0
RUN \
platformio settings set enable_telemetry No \
+5 -23
View File
@@ -7,7 +7,7 @@ from esphome import automation
import esphome.codegen as cg
from esphome.components.const import CONF_DESCRIPTION, CONF_HOST
from esphome.components.logger import request_log_listener
from esphome.components.network import DOMAIN as NETWORK_DOMAIN
from esphome.components.network import require_ipv4, require_ipv6
# ENCRYPTION_SCHEMA and validate_encryption_key are re-exported for external
# components and downstream consumers that import them from api
@@ -28,7 +28,6 @@ from esphome.const import (
CONF_DATA,
CONF_DATA_TEMPLATE,
CONF_DELAY,
CONF_ENABLE_IPV6,
CONF_ENCRYPTION,
CONF_EVENT,
CONF_ID,
@@ -52,7 +51,6 @@ from esphome.const import (
)
from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority
from esphome.cpp_generator import Expression, MockObj, TemplateArgsType
import esphome.final_validate as fv
from esphome.helpers import fnv1_hash
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigFragmentType, ConfigType
@@ -333,7 +331,10 @@ def _validate_outgoing_host(value: str) -> IPv4Address | IPv6Address:
"give the address without the '%' part"
)
if (mapped := address.ipv4_mapped) is not None:
return mapped
address = mapped
# The build must carry the host's address family for the dial to work
require = require_ipv6 if isinstance(address, IPv6Address) else require_ipv4
require("api.outgoing_connection")(value)
return address
@@ -479,31 +480,12 @@ def _action_strings(conf: ConfigType, has_metadata: bool) -> list[str | None]:
return strings
def _validate_outgoing_host_ipv6(config: ConfigType) -> ConfigType:
"""An IPv6 host can never be parsed, so never dialed, without IPv6."""
if (
(outgoing := config.get(CONF_OUTGOING_CONNECTION)) is None
or (host := outgoing.get(CONF_HOST)) is None
or host.version != 6
):
return config
network_conf = fv.full_config.get().get(NETWORK_DOMAIN) or {}
if not network_conf.get(CONF_ENABLE_IPV6):
raise cv.Invalid(
"outgoing_connection host is an IPv6 address but IPv6 is not "
"enabled; set 'network: enable_ipv6: true'",
path=[CONF_OUTGOING_CONNECTION, CONF_HOST],
)
return config
def _validate_wizard(config: ConfigType) -> ConfigType:
wizard.final_validate(config)
return config
FINAL_VALIDATE_SCHEMA = cv.All(
_validate_outgoing_host_ipv6,
_validate_wizard,
)
@@ -38,12 +38,14 @@ static bool peer_to_target(APIConnection *conn, SavedOutgoingTarget &target) {
const auto *bytes = reinterpret_cast<const uint8_t *>(&addr6->sin6_addr);
uint32_t prefix[3];
memcpy(prefix, bytes, sizeof(prefix));
#if USE_NETWORK_IPV4
// A dual-stack listener reports an IPv4 peer as ::ffff:a.b.c.d
if (prefix[0] == 0 && prefix[1] == 0 && prefix[2] == htonl(0xFFFFUL)) {
target.family = AF_INET;
memcpy(target.addr, bytes + sizeof(prefix), sizeof(struct in_addr));
return true;
}
#endif
// A link-local target is only reachable through the interface it came in
// on. Device platforms number interfaces from one; a host build can hand
// out an index too large to store, and a truncated one dials the wrong
@@ -57,6 +59,7 @@ static bool peer_to_target(APIConnection *conn, SavedOutgoingTarget &target) {
return true;
}
#endif
#if USE_NETWORK_IPV4
if (family != AF_INET) {
return false;
}
@@ -64,6 +67,9 @@ static bool peer_to_target(APIConnection *conn, SavedOutgoingTarget &target) {
target.family = AF_INET;
memcpy(target.addr, &addr4->sin_addr, sizeof(addr4->sin_addr));
return true;
#else
return false;
#endif
}
#endif
@@ -85,6 +91,7 @@ socklen_t OutgoingConnectionManager::target_sockaddr_(struct sockaddr_storage *a
return sizeof(*addr6);
}
#endif
#if USE_NETWORK_IPV4
if (this->saved_.family != AF_INET) {
return 0;
}
@@ -94,6 +101,9 @@ socklen_t OutgoingConnectionManager::target_sockaddr_(struct sockaddr_storage *a
addr4->sin_port = htons(API_OUTGOING_CONNECTION_PORT);
memcpy(&addr4->sin_addr, this->saved_.addr, sizeof(addr4->sin_addr));
return sizeof(*addr4);
#else
return 0;
#endif
#endif
}
+2 -1
View File
@@ -1,5 +1,5 @@
import esphome.codegen as cg
from esphome.components import socket
from esphome.components import network, socket
from esphome.components.light.effects import register_addressable_effect
from esphome.components.light.types import AddressableLightEffect
import esphome.config_validation as cv
@@ -38,6 +38,7 @@ CONFIG_SCHEMA = cv.All(
),
}
).extend(cv.COMPONENT_SCHEMA),
network.require_ipv4("e131"),
socket.consume_sockets(1, "e131", socket.SocketType.UDP),
)
+10
View File
@@ -711,6 +711,15 @@ def set_core_data(config):
return config
def _require_ipv4_on_arduino(config: ConfigType) -> ConfigType:
"""The Arduino core's IPAddress.cpp does not compile with IPv4 disabled."""
if CORE.using_arduino:
from esphome.components import network
network.require_ipv4(PLATFORM_ESP32)(config)
return config
def get_esp32_variant(core_obj=None):
return (core_obj or CORE).data[KEY_ESP32][KEY_VARIANT]
@@ -2494,6 +2503,7 @@ CONFIG_SCHEMA = cv.All(
_set_default_framework,
_check_versions,
set_core_data,
_require_ipv4_on_arduino,
cv.has_at_least_one_key(CONF_BOARD, CONF_VARIANT),
)
+2
View File
@@ -7,6 +7,7 @@ from typing import Any
from esphome.build_helpers.native import native_backend
import esphome.codegen as cg
from esphome.components import network
import esphome.config_validation as cv
from esphome.const import (
CONF_BOARD,
@@ -369,6 +370,7 @@ CONFIG_SCHEMA = cv.All(
_warn_platformio_toolchain,
_validate_native_toolchain,
set_core_data,
network.require_ipv4(PLATFORM_ESP8266),
)
+3
View File
@@ -8,6 +8,7 @@ from esphome.components.network import (
get_network_priority,
get_priority_interfaces_from_full_config,
ip_address_literal,
require_ipv4,
)
from esphome.config_helpers import (
filter_source_files_from_defines,
@@ -22,6 +23,7 @@ from esphome.const import (
CONF_DNS2,
CONF_DOMAIN,
CONF_ENABLE_ON_BOOT,
CONF_ETHERNET,
CONF_GATEWAY,
CONF_ID,
CONF_INTERRUPT_PIN,
@@ -556,6 +558,7 @@ CONFIG_SCHEMA = cv.All(
upper=True,
),
_validate,
require_ipv4(CONF_ETHERNET),
)
+2
View File
@@ -1,6 +1,7 @@
import sys
import esphome.codegen as cg
from esphome.components import network
import esphome.config_validation as cv
from esphome.const import (
CONF_MAC_ADDRESS,
@@ -44,6 +45,7 @@ CONFIG_SCHEMA = cv.All(
# is no PlatformIO backend, so a CLI --toolchain must name this one
cv.resolve_toolchain("host", (Toolchain.HOST,), Toolchain.HOST),
set_core_data,
network.require_ipv4(PLATFORM_HOST),
)
@@ -107,13 +107,10 @@ def _set_stream_limits(config: ConfigType) -> ConfigType:
)(config)
return config
# The original ESP32 cannot lay out sub-16-bit slots that match ESPHome's packed audio, so the smallest
# stream it accepts is 16-bit (see start_i2s_driver); the other variants handle 8-bit.
min_bits_per_sample = 16 if esp32.get_esp32_variant() == esp32.VARIANT_ESP32 else 8
# The configured bits per sample sets the I2S slot width, but the speaker narrows wider streams down to it
# in place before clocking them out (see start_i2s_driver). Advertise up to 32-bit so those wider streams
# are accepted rather than forcing an upstream conversion.
# The configured bits per sample sets the I2S slot width, but the speaker converts streams of other widths
# before clocking them out (see start_i2s_driver), widening them on the original ESP32. Advertise 8- to 32-bit
# so those streams are accepted rather than forcing an upstream conversion.
min_bits_per_sample = 8
max_bits_per_sample = 32
if config[CONF_I2S_MODE] == CONF_PRIMARY:
@@ -153,10 +150,12 @@ def _validate_esp32_variant(config: ConfigType) -> ConfigType:
if variant not in INTERNAL_DAC_VARIANTS:
raise cv.Invalid(f"{variant} does not have an internal DAC")
elif variant == esp32.VARIANT_ESP32 and config[CONF_BITS_PER_SAMPLE] == 8:
# The original ESP32 I2S peripheral packs each sample into a whole number of 16-bit words, so an
# 8-bit slot does not line up with ESPHome's tightly packed audio (see start_i2s_driver). Reject it
# at config time rather than emitting corrupted output at runtime.
raise cv.Invalid("8-bit audio is not supported on the original ESP32")
# An 8-bit slot is rejected on the original ESP32: no known DAC needs one, and it is untested there.
# 8-bit streams still work; they are widened to the 16- or 32-bit slot (see start_i2s_driver).
raise cv.Invalid(
f"{CONF_BITS_PER_SAMPLE} cannot be 8bit on the original ESP32",
path=[CONF_BITS_PER_SAMPLE],
)
return config
@@ -208,8 +208,8 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public
audio::AudioStreamInfo current_stream_info_; // Format of the audio in the ring buffer (the I2S input)
// Format actually clocked out of the I2S peripheral. Same channel count and sample rate as
// current_stream_info_, but the bits per sample may be narrower when the incoming stream is wider than
// the speaker's configured slot bit width. Set by start_i2s_driver before the speaker task starts.
// current_stream_info_, but the bits per sample may be converted to the speaker's configured slot bit width.
// Set by start_i2s_driver before the speaker task starts.
audio::AudioStreamInfo output_stream_info_;
gpio_num_t dout_pin_;
@@ -38,6 +38,11 @@ static constexpr size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT * 2;
// 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));
#ifdef USE_ESP32_VARIANT_ESP32
// Stack buffer that widened audio passes through on the original ESP32. Small enough for the speaker task's stack,
// large enough that the extra i2s_channel_write calls cost little.
static constexpr size_t WIDEN_BUFFER_BYTES = 512;
#endif
// Requested frames per DMA buffer for the given stream, clamped so the byte size stays within the ESP-IDF
// maximum DMA descriptor size. This is only the value handed to the channel config: ESP-IDF may still adjust
@@ -66,9 +71,8 @@ void I2SAudioSpeaker::dump_config() {
}
ESP_LOGCONFIG(TAG, " Communication format: %s", fmt_str);
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) {
// The width of each I2S slot. It is also the narrowing ceiling: streams wider than this are narrowed to
// it. A stream narrower than the slot is left at its own width and clocked into the wider slot, so this
// is not necessarily the sample data width (which depends on the incoming stream).
// The width of each I2S slot. Wider streams are narrowed to it. Narrower streams are padded into the slot by
// the peripheral, except on the original ESP32, where they are widened to it.
ESP_LOGCONFIG(TAG, " Slot bit width: %u", (unsigned) static_cast<uint32_t>(this->slot_bit_width_));
}
}
@@ -92,11 +96,14 @@ void I2SAudioSpeaker::run_speaker_task() {
const size_t ring_buffer_size =
(this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / source_alignment) * source_alignment;
// Per-frame byte widths and whether the task must narrow the bit depth before writing to the I2S peripheral.
// Per-frame byte widths and whether the task must convert the bit depth before writing to the I2S peripheral.
const uint8_t channels = this->current_stream_info_.get_channels();
const uint8_t input_bytes_per_sample = this->current_stream_info_.get_bits_per_sample() / 8;
const uint8_t output_bytes_per_sample = this->output_stream_info_.get_bits_per_sample() / 8;
const bool narrowing = input_bytes_per_sample != output_bytes_per_sample;
const bool narrowing = input_bytes_per_sample > output_bytes_per_sample;
#ifdef USE_ESP32_VARIANT_ESP32
const bool widening = input_bytes_per_sample < output_bytes_per_sample;
#endif
// ESP-IDF may allocate smaller (or cache-line-rounded) DMA buffers than dma_buffer_frames() requested: it
// clamps each descriptor to the max DMA descriptor size and, on targets that route internal memory through
@@ -323,20 +330,26 @@ void I2SAudioSpeaker::run_speaker_task() {
const size_t output_bytes = this->output_stream_info_.frames_to_bytes(frames_to_write);
uint8_t *chunk = audio_source->mutable_data();
if (narrowing) {
// Narrow the bit depth in place: output exactly aliases input with the same channel count and a
// smaller width, which copy_frames handles as a single forward pass. Only the frames about to be
// consumed are overwritten, so any unprocessed tail stays intact for the next iteration.
esp_audio_libs::pcm_convert::copy_frames(chunk, chunk, input_bytes_per_sample, channels,
output_bytes_per_sample, channels, frames_to_write);
}
this->swap_esp32_mono_samples_(chunk, output_bytes);
size_t bw = 0;
i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS);
#ifdef USE_ESP32_VARIANT_ESP32
if (widening) {
bw = this->write_widened_(chunk, frames_to_write);
} else
#endif
{
if (narrowing) {
// Narrow the bit depth in place: output exactly aliases input with the same channel count and a
// smaller width, which copy_frames handles as a single forward pass. Only the frames about to be
// consumed are overwritten, so any unprocessed tail stays intact for the next iteration.
esp_audio_libs::pcm_convert::copy_frames(chunk, chunk, input_bytes_per_sample, channels,
output_bytes_per_sample, channels, frames_to_write);
}
this->swap_esp32_mono_samples_(chunk, output_bytes);
i2s_channel_write(this->tx_handle_, chunk, output_bytes, &bw, WRITE_TIMEOUT_TICKS);
}
if (bw != output_bytes) {
// A short write breaks DMA descriptor alignment for every subsequent event. Drop the chunk rather
// than retry it: it was already narrowed in place.
// than retry it: it may already have been converted in place.
ESP_LOGE(TAG, "Partial DMA write (%u of %u bytes), resyncing DMA lockstep", (unsigned) bw,
(unsigned) output_bytes);
audio_source->consume(input_bytes);
@@ -416,26 +429,26 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream
return ESP_ERR_NOT_SUPPORTED;
}
// When the stream is wider than the configured slot bit width, the speaker task narrows each frame in place
// before handing it to the I2S peripheral. Compute the output format here so the driver, DMA buffers, and
// the task's conversion all agree on the clocked-out width. A stream no wider than the slot width is passed
// through unchanged (the slot may still be wider than the data, the existing behavior).
// Compute the output width so the driver, DMA buffers, and the task's conversion all agree. A stream wider than
// the configured slot is narrowed to it, and a narrower stream keeps its width for the peripheral to pad into the
// slot. The original ESP32 cannot clock a slot wider than its data, so there every stream is converted to the
// slot's memory width instead.
uint8_t output_bits_per_sample = audio_stream_info.get_bits_per_sample();
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) {
#ifdef USE_ESP32_VARIANT_ESP32
output_bits_per_sample = this->slot_memory_bits_();
#else
const uint8_t configured_bits = static_cast<uint8_t>(this->slot_bit_width_);
if (output_bits_per_sample > configured_bits) {
output_bits_per_sample = configured_bits;
}
#endif
}
this->output_stream_info_ = audio::AudioStreamInfo(output_bits_per_sample, audio_stream_info.get_channels(),
audio_stream_info.get_sample_rate());
#ifdef USE_ESP32_VARIANT_ESP32
// The original ESP32 I2S peripheral stores each sample in a whole number of 16-bit words (a 24-bit sample
// occupies 4 bytes in the DMA buffer, an 8-bit sample 2 bytes), but ESPHome's audio pipeline packs samples
// tightly (3 bytes for 24-bit, 1 for 8-bit). The two layouts only line up when the bit depth is a multiple
// of 16. The check is on the output width since that is what reaches the peripheral; a wider input is fine
// as long as it narrows to a 16- or 32-bit slot.
// Only a slot left at AUTO can reach this; every configured slot is converted to 16- or 32-bit samples above
if (output_bits_per_sample % 16 != 0) {
ESP_LOGE(TAG, "ESP32 supports only 16- or 32-bit output, got %u-bit", (unsigned) output_bits_per_sample);
return ESP_ERR_NOT_SUPPORTED;
@@ -487,8 +500,8 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream
#ifdef USE_I2S_AUDIO_FULL_DUPLEX
audio::AudioStreamInfo I2SAudioSpeaker::full_duplex_stream_info_() const {
return audio::AudioStreamInfo(static_cast<uint8_t>(this->slot_bit_width_),
this->slot_mode_ == I2S_SLOT_MODE_STEREO ? 2 : 1, this->sample_rate_);
return audio::AudioStreamInfo(this->slot_memory_bits_(), this->slot_mode_ == I2S_SLOT_MODE_STEREO ? 2 : 1,
this->sample_rate_);
}
void I2SAudioSpeaker::build_full_duplex_config(i2s_chan_config_t &chan_cfg, i2s_std_config_t &std_cfg) {
@@ -530,8 +543,14 @@ void I2SAudioSpeaker::build_i2s_config_(const audio::AudioStreamInfo &output_str
slot_mask = I2S_STD_SLOT_BOTH;
}
// Configure the data bit width from the output (post-narrowing) format, which is what is clocked out.
const i2s_data_bit_width_t data_bit_width = (i2s_data_bit_width_t) output_stream_info.get_bits_per_sample();
// Configure the data bit width from the output (post-conversion) format, which is what is clocked out.
i2s_data_bit_width_t data_bit_width = (i2s_data_bit_width_t) output_stream_info.get_bits_per_sample();
#ifdef USE_ESP32_VARIANT_ESP32
// The original ESP32 clocks data at the slot width; a 24-bit slot sends the top 24 bits of each 32-bit sample
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) {
data_bit_width = (i2s_data_bit_width_t) this->slot_bit_width_;
}
#endif
i2s_std_slot_config_t slot_cfg;
switch (this->i2s_comm_fmt_) {
case I2SCommFmt::PCM:
@@ -545,23 +564,10 @@ void I2SAudioSpeaker::build_i2s_config_(const audio::AudioStreamInfo &output_str
break;
}
#ifdef USE_ESP32_VARIANT_ESP32
// There seems to be a bug on the ESP32 (non-variant) platform where setting the slot bit width higher than the
// bits per sample causes the audio to play too fast. Setting the ws_width to the configured slot bit width seems
// to make it play at the correct speed while sending more bits per slot.
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) {
uint32_t configured_bit_width = static_cast<uint32_t>(this->slot_bit_width_);
slot_cfg.ws_width = configured_bit_width;
if (configured_bit_width > 16) {
slot_cfg.msb_right = false;
}
}
#else
slot_cfg.slot_bit_width = this->slot_bit_width_;
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) {
slot_cfg.ws_width = static_cast<uint32_t>(this->slot_bit_width_);
}
#endif // USE_ESP32_VARIANT_ESP32
slot_cfg.slot_mask = slot_mask;
i2s_std_gpio_config_t gpio_cfg = this->parent_->get_pin_config();
@@ -574,6 +580,45 @@ void I2SAudioSpeaker::build_i2s_config_(const audio::AudioStreamInfo &output_str
};
}
uint8_t I2SAudioSpeaker::slot_memory_bits_() const {
#ifdef USE_ESP32_VARIANT_ESP32
// The original ESP32 stores a 24-bit slot's samples in 32-bit words
if (this->slot_bit_width_ == I2S_SLOT_BIT_WIDTH_24BIT) {
return 32;
}
#endif
return static_cast<uint8_t>(this->slot_bit_width_);
}
#ifdef USE_ESP32_VARIANT_ESP32
size_t I2SAudioSpeaker::write_widened_(const uint8_t *input, uint32_t frames) {
alignas(4) uint8_t widened[WIDEN_BUFFER_BYTES];
const uint8_t channels = this->current_stream_info_.get_channels();
const uint8_t input_bytes_per_sample = this->current_stream_info_.get_bits_per_sample() / 8;
const uint8_t output_bytes_per_sample = this->output_stream_info_.get_bits_per_sample() / 8;
const uint32_t frames_per_pass = WIDEN_BUFFER_BYTES / this->output_stream_info_.frames_to_bytes(1);
size_t bytes_written = 0;
while (frames > 0) {
const uint32_t pass_frames = std::min(frames, frames_per_pass);
esp_audio_libs::pcm_convert::copy_frames(input, widened, input_bytes_per_sample, channels, output_bytes_per_sample,
channels, pass_frames);
const size_t pass_bytes = this->output_stream_info_.frames_to_bytes(pass_frames);
// 8-bit mono widened to 16-bit mono needs the pair swap; passes hold whole pairs since writes are pair-aligned
this->swap_esp32_mono_samples_(widened, pass_bytes);
size_t bw = 0;
i2s_channel_write(this->tx_handle_, widened, pass_bytes, &bw, WRITE_TIMEOUT_TICKS);
bytes_written += bw;
if (bw != pass_bytes) {
break;
}
input += this->current_stream_info_.frames_to_bytes(pass_frames);
frames -= pass_frames;
}
return bytes_written;
}
#endif // USE_ESP32_VARIANT_ESP32
} // namespace esphome::i2s_audio
#endif // USE_ESP32
@@ -38,6 +38,17 @@ class I2SAudioSpeaker final : public I2SAudioSpeakerBase {
audio::AudioStreamInfo full_duplex_stream_info_() const;
#endif
/// @brief Bits per sample that the configured slot takes in DMA memory.
uint8_t slot_memory_bits_() const;
#ifdef USE_ESP32_VARIANT_ESP32
/// @brief Widens frames to the output bit depth through a small stack buffer and writes them to the peripheral.
/// @param input Frames in the incoming stream format
/// @param frames Number of frames to widen and write
/// @return Bytes written, which is short of the widened size if a write timed out
size_t write_widened_(const uint8_t *input, uint32_t frames);
#endif
/// @brief Frame multiple for DMA buffers and writes; 2 keeps ESP32 mono sample pairs word-aligned.
static uint32_t dma_frame_multiple(const audio::AudioStreamInfo &stream_info) {
return esp32_mono_swap_needed(stream_info) ? 2 : 1;
+11
View File
@@ -3,6 +3,7 @@ import logging
from pathlib import Path
import esphome.codegen as cg
from esphome.components.const import CONF_LIBRETINY
import esphome.config_validation as cv
from esphome.const import (
CONF_BOARD,
@@ -304,6 +305,15 @@ FRAMEWORK_SCHEMA = cv.All(
CONFIG_SCHEMA = cv.All(_notify_old_style, cv.require_platformio_toolchain("LibreTiny"))
def _require_ipv4(config: dict) -> dict:
"""Declares that LibreTiny builds need IPv4."""
# Imported here: logger imports this module, so a module level import would load network for every config
from esphome.components import network
return network.require_ipv4(CONF_LIBRETINY)(config)
BASE_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(LTComponent),
@@ -318,6 +328,7 @@ BASE_SCHEMA = cv.Schema(
BASE_SCHEMA.add_extra(_detect_variant)
BASE_SCHEMA.add_extra(cv.require_platformio_toolchain("LibreTiny"))
BASE_SCHEMA.add_extra(_update_core_data)
BASE_SCHEMA.add_extra(_require_ipv4)
def _configure_lwip(config: dict) -> None:
+65 -27
View File
@@ -1,8 +1,10 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import logger, socket
from esphome.components import logger, network, socket
from esphome.components.const import CONF_VERIFY_SSL
from esphome.components.esp32 import (
add_idf_component,
allow_insecure_tls,
idf_version,
include_builtin_idf_component,
request_tls,
@@ -35,6 +37,7 @@ from esphome.const import (
CONF_KEEPALIVE,
CONF_LEVEL,
CONF_LOG_TOPIC,
CONF_MQTT,
CONF_ON_CONNECT,
CONF_ON_DISCONNECT,
CONF_ON_JSON_MESSAGE,
@@ -78,6 +81,7 @@ def AUTO_LOAD():
return ["json"]
CONF_TLS = "tls"
CONF_IDF_SEND_ASYNC = "idf_send_async"
CONF_WAIT_FOR_CONNECTION = "wait_for_connection"
CONF_TRANSPORT = "transport"
@@ -186,26 +190,8 @@ def _validate_transport(value: str) -> str:
def validate_config(value):
transport = value.get(CONF_TRANSPORT)
# A certificate with plain ws is almost certainly a typo for wss
if transport == MQTT_TRANSPORT_WS and (
CONF_CERTIFICATE_AUTHORITY in value or CONF_CLIENT_CERTIFICATE in value
):
raise cv.Invalid(
"transport 'ws' is unencrypted; remove the certificate options "
"or switch to transport 'wss'"
)
# TLS runs only with a CA or over wss, so a client certificate needs one of them
if (
CONF_CLIENT_CERTIFICATE in value
and CONF_CERTIFICATE_AUTHORITY not in value
and transport != MQTT_TRANSPORT_WSS
):
raise cv.Invalid(
"'client_certificate' requires 'certificate_authority' or transport 'wss'"
)
if (
transport not in (MQTT_TRANSPORT_WS, MQTT_TRANSPORT_WSS)
value.get(CONF_TRANSPORT) not in (MQTT_TRANSPORT_WS, MQTT_TRANSPORT_WSS)
and CONF_WS_PATH in value
):
raise cv.Invalid(
@@ -259,6 +245,47 @@ def validate_config(value):
return out
# Keys that only make sense over TLS; any of them switches it on
TLS_IMPLYING_KEYS = frozenset(
{CONF_CERTIFICATE_AUTHORITY, CONF_VERIFY_SSL, CONF_CLIENT_CERTIFICATE}
)
def _derive_tls(config: ConfigType) -> ConfigType:
"""Derive ``tls`` from the certificate options and the transport, reject contradictions, pick the port."""
# skip_cert_cn_check has a default on ESP32, so its value decides, not its presence
if config.get(CONF_VERIFY_SSL) is False and config.get(CONF_SKIP_CERT_CN_CHECK):
raise cv.Invalid(
"'skip_cert_cn_check' has no effect when 'verify_ssl' is false."
)
transport = config.get(CONF_TRANSPORT)
if (
config.keys() & TLS_IMPLYING_KEYS
or config.get(CONF_SKIP_CERT_CN_CHECK)
or transport == MQTT_TRANSPORT_WSS
):
if config.get(CONF_TLS) is False:
raise cv.Invalid(
"'tls' cannot be false when a certificate, 'verify_ssl', "
"'skip_cert_cn_check' or transport 'wss' is set."
)
config[CONF_TLS] = True
# TLS with plain ws is almost certainly a typo for wss
if transport == MQTT_TRANSPORT_WS and config.get(CONF_TLS):
raise cv.Invalid(
"transport 'ws' is unencrypted; remove the TLS options "
"or switch to transport 'wss'"
)
config.setdefault(CONF_PORT, 8883 if config.get(CONF_TLS) else 1883)
return config
_validate_tls = cv.All(
cv.only_when(CONF_CERTIFICATE_AUTHORITY, CONF_VERIFY_SSL, False, negate=True),
_derive_tls,
)
def _consume_mqtt_sockets(config: ConfigType) -> ConfigType:
"""Register socket needs for MQTT component."""
# MQTT needs 1 socket for the broker connection
@@ -282,7 +309,7 @@ CONFIG_SCHEMA = cv.All(
cv.GenerateID(): cv.declare_id(MQTTClientComponent),
cv.Required(CONF_BROKER): cv.string_strict,
cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean,
cv.Optional(CONF_PORT, default=1883): cv.port,
cv.Optional(CONF_PORT): cv.port,
cv.Optional(CONF_USERNAME, default=""): cv.string,
cv.Optional(CONF_PASSWORD, default=""): cv.sensitive(),
cv.Optional(CONF_CLEAN_SESSION, default=False): cv.boolean,
@@ -294,6 +321,8 @@ CONFIG_SCHEMA = cv.All(
cv.SplitDefault(CONF_IDF_SEND_ASYNC, esp32=False): cv.All(
cv.boolean, cv.only_on_esp32
),
cv.Optional(CONF_TLS): cv.All(cv.boolean, cv.only_on_esp32),
cv.Optional(CONF_VERIFY_SSL): cv.All(cv.boolean, cv.only_on_esp32),
cv.Optional(CONF_CERTIFICATE_AUTHORITY): cv.All(
cv.string, cv.only_on_esp32
),
@@ -355,6 +384,7 @@ CONFIG_SCHEMA = cv.All(
}
),
validate_config,
_validate_tls,
cv.only_on(
[
PLATFORM_BK72XX,
@@ -365,6 +395,7 @@ CONFIG_SCHEMA = cv.All(
]
),
_consume_mqtt_sockets,
network.require_ipv4(CONF_MQTT),
)
@@ -495,21 +526,28 @@ async def to_code(config):
# esp-idf only
transport = config.get(CONF_TRANSPORT)
ca = cg.add_optional(var.set_ca_certificate, config.get(CONF_CERTIFICATE_AUTHORITY))
if ca is not None or transport == MQTT_TRANSPORT_WSS:
cg.add(var.set_skip_cert_cn_check(config[CONF_SKIP_CERT_CN_CHECK]))
if config.get(CONF_TLS):
cg.add_define("USE_MQTT_TLS")
if (client_certificate := config.get(CONF_CLIENT_CERTIFICATE)) is not None:
cg.add(var.set_cl_certificate(client_certificate))
cg.add(var.set_cl_key(config[CONF_CLIENT_CERTIFICATE_KEY]))
if config.get(CONF_SKIP_CERT_CN_CHECK):
cg.add(var.set_skip_cert_cn_check(True))
# Trust source: none, the given CA, or the certificate bundle
if config.get(CONF_VERIFY_SSL) is False:
cg.add_define("USE_MQTT_TLS_INSECURE")
allow_insecure_tls()
elif (ca := config.get(CONF_CERTIFICATE_AUTHORITY)) is not None:
cg.add(var.set_ca_certificate(ca))
else:
cg.add_define("USE_MQTT_CERT_BUNDLE")
require_certificate_bundle()
if CORE.is_esp32:
if transport != MQTT_TRANSPORT_TCP:
cg.add(var.set_transport(transport))
if transport in (MQTT_TRANSPORT_WS, MQTT_TRANSPORT_WSS):
cg.add(var.set_ws_path(config.get(CONF_WS_PATH, DEFAULT_WS_PATH)))
# wss without an explicit CA verifies the broker against the bundled roots
if transport == MQTT_TRANSPORT_WSS and ca is None:
require_certificate_bundle()
if CONF_IDF_SEND_ASYNC in config and config[CONF_IDF_SEND_ASYNC]:
cg.add_define("USE_MQTT_IDF_ENQUEUE")
+22 -28
View File
@@ -8,7 +8,7 @@
#include "esphome/core/log.h"
#include "esphome/core/application.h"
#if CONFIG_MBEDTLS_CERTIFICATE_BUNDLE
#ifdef USE_MQTT_CERT_BUNDLE
#include "esp_crt_bundle.h"
#endif
@@ -43,46 +43,36 @@ bool MQTTBackendESP32::initialize_() {
if (!this->client_id_.empty()) {
mqtt_cfg_.credentials.client_id = this->client_id_.c_str();
}
// Plain TCP keeps upgrading to SSL when a CA certificate is configured
bool uses_tls = false;
#ifdef USE_MQTT_TLS
mqtt_cfg_.broker.verification.skip_cert_common_name_check = this->skip_cert_cn_check_;
mqtt_cfg_.broker.verification.certificate = this->ca_certificate_;
#ifdef USE_MQTT_CERT_BUNDLE
mqtt_cfg_.broker.verification.crt_bundle_attach = esp_crt_bundle_attach;
#endif
// Validation only accepts the client certificate together with its key
mqtt_cfg_.credentials.authentication.certificate = this->cl_certificate_;
mqtt_cfg_.credentials.authentication.key = this->cl_key_;
#endif
// Validation keeps TLS off plain ws, so only the TCP transport has two forms
switch (this->transport_) {
case MQTTTransport::MQTT_TRANSPORT_WS:
mqtt_cfg_.broker.address.transport = MQTT_TRANSPORT_OVER_WS;
break;
case MQTTTransport::MQTT_TRANSPORT_WSS:
mqtt_cfg_.broker.address.transport = MQTT_TRANSPORT_OVER_WSS;
uses_tls = true;
break;
case MQTTTransport::MQTT_TRANSPORT_TCP:
default:
if (this->ca_certificate_.has_value()) {
mqtt_cfg_.broker.address.transport = MQTT_TRANSPORT_OVER_SSL;
uses_tls = true;
} else {
mqtt_cfg_.broker.address.transport = MQTT_TRANSPORT_OVER_TCP;
}
#ifdef USE_MQTT_TLS
mqtt_cfg_.broker.address.transport = MQTT_TRANSPORT_OVER_SSL;
#else
mqtt_cfg_.broker.address.transport = MQTT_TRANSPORT_OVER_TCP;
#endif
break;
}
// Only set for the WebSocket transports
mqtt_cfg_.broker.address.path = this->ws_path_;
if (uses_tls) {
if (this->ca_certificate_.has_value()) {
mqtt_cfg_.broker.verification.certificate = this->ca_certificate_.value().c_str();
#if CONFIG_MBEDTLS_CERTIFICATE_BUNDLE
} else {
// wss without a CA verifies the broker against the bundled roots, as http_request does
mqtt_cfg_.broker.verification.crt_bundle_attach = esp_crt_bundle_attach;
#endif
}
mqtt_cfg_.broker.verification.skip_cert_common_name_check = this->skip_cert_cn_check_;
if (this->cl_certificate_.has_value() && this->cl_key_.has_value()) {
mqtt_cfg_.credentials.authentication.certificate = this->cl_certificate_.value().c_str();
mqtt_cfg_.credentials.authentication.key = this->cl_key_.value().c_str();
}
}
auto *mqtt_client = esp_mqtt_client_init(&mqtt_cfg_);
if (mqtt_client) {
handler_.reset(mqtt_client);
@@ -91,7 +81,11 @@ bool MQTTBackendESP32::initialize_() {
#if defined(USE_MQTT_IDF_ENQUEUE)
// Create the task only after MQTT client is initialized successfully
// Use larger stack size when TLS is enabled
size_t stack_size = uses_tls ? TASK_STACK_SIZE_TLS : TASK_STACK_SIZE;
#ifdef USE_MQTT_TLS
constexpr size_t stack_size = TASK_STACK_SIZE_TLS;
#else
constexpr size_t stack_size = TASK_STACK_SIZE;
#endif
xTaskCreate(esphome_mqtt_task, "esphome_mqtt", stack_size, (void *) this, TASK_PRIORITY, &this->task_handle_);
if (this->task_handle_ == nullptr) {
ESP_LOGE(TAG, "Failed to create MQTT task");
+12 -7
View File
@@ -214,10 +214,13 @@ class MQTTBackendESP32 final : public MQTTBackend {
void loop() final;
void set_ca_certificate(const std::string &cert) { ca_certificate_ = cert; }
void set_cl_certificate(const std::string &cert) { cl_certificate_ = cert; }
void set_cl_key(const std::string &key) { cl_key_ = key; }
void set_skip_cert_cn_check(bool skip_check) { skip_cert_cn_check_ = skip_check; }
#ifdef USE_MQTT_TLS
// The certificates are string literals from code generation, so only the pointers are kept
void set_ca_certificate(const char *cert) { this->ca_certificate_ = cert; }
void set_cl_certificate(const char *cert) { this->cl_certificate_ = cert; }
void set_cl_key(const char *key) { this->cl_key_ = key; }
void set_skip_cert_cn_check(bool skip_check) { this->skip_cert_cn_check_ = skip_check; }
#endif
void set_transport(MQTTTransport transport) { this->transport_ = transport; }
void set_ws_path(const char *path) { this->ws_path_ = path; }
MQTTTransport get_transport() const { return this->transport_; }
@@ -256,10 +259,12 @@ class MQTTBackendESP32 final : public MQTTBackend {
std::string client_id_;
uint16_t keep_alive_;
bool clean_session_;
optional<std::string> ca_certificate_;
optional<std::string> cl_certificate_;
optional<std::string> cl_key_;
#ifdef USE_MQTT_TLS
const char *ca_certificate_{nullptr};
const char *cl_certificate_{nullptr};
const char *cl_key_{nullptr};
bool skip_cert_cn_check_{false};
#endif
MQTTTransport transport_{MQTTTransport::MQTT_TRANSPORT_TCP};
const char *ws_path_{nullptr};
#if defined(USE_MQTT_IDF_ENQUEUE)
+7
View File
@@ -222,6 +222,13 @@ void MQTTClientComponent::dump_config() {
transport == MQTTTransport::MQTT_TRANSPORT_WSS ? LOG_STR_LITERAL("wss") : LOG_STR_LITERAL("ws"),
this->mqtt_backend_.get_ws_path());
}
#endif
#if defined(USE_MQTT_CERT_BUNDLE)
ESP_LOGCONFIG(TAG, " TLS: certificate bundle");
#elif defined(USE_MQTT_TLS_INSECURE)
ESP_LOGCONFIG(TAG, " TLS: no verification");
#elif defined(USE_MQTT_TLS)
ESP_LOGCONFIG(TAG, " TLS: CA certificate");
#endif
if (this->is_discovery_ip_enabled()) {
ESP_LOGCONFIG(TAG, " Discovery IP enabled");
+3 -1
View File
@@ -147,11 +147,13 @@ class MQTTClientComponent final : public Component {
bool is_discovery_enabled() const;
bool is_discovery_ip_enabled() const;
#ifdef USE_ESP32
#if defined(USE_ESP32) && defined(USE_MQTT_TLS)
void set_ca_certificate(const char *cert) { this->mqtt_backend_.set_ca_certificate(cert); }
void set_cl_certificate(const char *cert) { this->mqtt_backend_.set_cl_certificate(cert); }
void set_cl_key(const char *key) { this->mqtt_backend_.set_cl_key(key); }
void set_skip_cert_cn_check(bool skip_check) { this->mqtt_backend_.set_skip_cert_cn_check(skip_check); }
#endif
#ifdef USE_ESP32
void set_transport(MQTTTransport transport) { this->mqtt_backend_.set_transport(transport); }
void set_ws_path(const char *path) { this->mqtt_backend_.set_ws_path(path); }
#endif
+122 -50
View File
@@ -1,3 +1,5 @@
from collections.abc import Callable
from dataclasses import dataclass, field
import ipaddress
import logging
from typing import Any
@@ -51,6 +53,16 @@ TCPIP_RECVMBOX_OPTIMIZED = 64
TCP_SEND_BUFFER_MIN = 2880
TCP_SEND_BUFFER_MAX = 65535
CONF_ENABLE_IPV4 = "enable_ipv4"
@dataclass
class NetworkData:
require_ipv4: set[str] = field(default_factory=set)
require_ipv6: set[str] = field(default_factory=set)
request_ipv4_off: set[str] = field(default_factory=set)
# Network priority tracking infrastructure
# Components can query this to determine their relative setup priority.
# CORE.data[KEY_NETWORK_PRIORITY] is a list of dicts of the form
@@ -264,11 +276,35 @@ def has_high_performance_networking() -> bool:
return CORE.data.get(KEY_HIGH_PERFORMANCE_NETWORKING, False)
def validate_ipv6(value: bool) -> bool:
if CORE.is_nrf52 and not value:
raise cv.Invalid("On nRF52, enable_ipv6 must be true")
def _get_data() -> NetworkData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = NetworkData()
return CORE.data[DOMAIN]
return value
def _ip_request(
update: Callable[[NetworkData], None],
) -> Callable[[ConfigType], ConfigType]:
def validator(config: ConfigType) -> ConfigType:
update(_get_data())
return config
return validator
def require_ipv4(name: str) -> Callable[[ConfigType], ConfigType]:
"""Return a validator declaring that component `name` needs IPv4."""
return _ip_request(lambda data: data.require_ipv4.add(name))
def require_ipv6(name: str) -> Callable[[ConfigType], ConfigType]:
"""Return a validator declaring that component `name` needs IPv6."""
return _ip_request(lambda data: data.require_ipv6.add(name))
def request_ipv4_off(name: str) -> Callable[[ConfigType], ConfigType]:
"""Return a validator asking for IPv4 to default to off, on behalf of `name`."""
return _ip_request(lambda data: data.request_ipv4_off.add(name))
def get_network_priority(iface: str) -> float | None:
@@ -348,33 +384,30 @@ def _validate_priority_list(value: Any) -> list[dict[str, str]]:
return entries
# Shared so _final_validate()'s auto-flip to True enforces the same version floor as
# the schema does for a user-typed 'enable_ipv6: true'.
_ENABLE_IPV6_FRAMEWORK_VERSION_VALIDATOR = cv.require_framework_version(
bk72xx_arduino=cv.Version(1, 7, 0),
esp_idf=cv.Version(0, 0, 0),
esp32_arduino=cv.Version(0, 0, 0),
esp8266_arduino=cv.Version(0, 0, 0),
host=cv.Version(0, 0, 0),
rp2_arduino=cv.Version(0, 0, 0),
nrf52_zephyr=cv.Version(0, 0, 0),
)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(NetworkComponent),
cv.SplitDefault(
CONF_ENABLE_IPV6,
bk72xx=False,
esp32=False,
esp8266=False,
host=False,
rp2=False,
nrf52=True,
): cv.All(
cv.Optional(CONF_ENABLE_IPV4): cv.boolean,
cv.Optional(CONF_ENABLE_IPV6): cv.All(
cv.boolean,
cv.Any(
cv.require_framework_version(
bk72xx_arduino=cv.Version(1, 7, 0),
esp_idf=cv.Version(0, 0, 0),
esp32_arduino=cv.Version(0, 0, 0),
esp8266_arduino=cv.Version(0, 0, 0),
host=cv.Version(0, 0, 0),
rp2_arduino=cv.Version(0, 0, 0),
nrf52_zephyr=cv.Version(0, 0, 0),
),
_ENABLE_IPV6_FRAMEWORK_VERSION_VALIDATOR,
cv.boolean_false,
),
validate_ipv6,
),
cv.Optional(CONF_MIN_IPV6_ADDR_COUNT, default=0): cv.positive_int,
cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All(
@@ -392,13 +425,50 @@ CONFIG_SCHEMA = cv.All(
)
def _resolve_ip_family(
config: ConfigType, key: str, family: str, requirers: set[str], default: bool
) -> bool:
"""Resolve `key` to a bool; return True when a component requires the family."""
if not requirers:
config.setdefault(key, default)
return False
if config.get(key) is False:
raise cv.Invalid(
f"{family} is required by {', '.join(sorted(requirers))}, but "
f"'network: {key}: false' explicitly disables it"
)
config[key] = True
return True
def _final_validate(config: ConfigType) -> None:
"""Check that every interface named in 'priority' has a corresponding component block."""
full = fv.full_config.get()
full_config = fv.full_config.get()
data = _get_data()
if _resolve_ip_family(
config, CONF_ENABLE_IPV6, "IPv6", data.require_ipv6, default=False
):
# Turning IPv6 on must not bypass the version gate cv.Any() enforces.
_ENABLE_IPV6_FRAMEWORK_VERSION_VALIDATOR(True)
_resolve_ip_family(
config,
CONF_ENABLE_IPV4,
"IPv4",
data.require_ipv4,
default=not data.request_ipv4_off,
)
if not config[CONF_ENABLE_IPV4] and not config[CONF_ENABLE_IPV6]:
raise cv.Invalid(
"'enable_ipv4: false' requires 'enable_ipv6: true' -- disabling both "
"leaves no IP stack"
)
# Check that every interface named in 'priority' has a corresponding component block.
priority_list = config.get(CONF_PRIORITY, [])
for entry in priority_list:
iface = entry["interface"]
if iface not in full:
if iface not in full_config:
raise cv.Invalid(
f"'{iface}' is listed in 'network: priority:' but no '{iface}:' "
f"component is configured",
@@ -430,7 +500,8 @@ FINAL_VALIDATE_SCHEMA = _final_validate
@coroutine_with_priority(CoroPriority.NETWORK)
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_NETWORK")
# ESP32 with Arduino uses ESP-IDF network APIs directly, no Arduino Network library needed
enable_ipv6 = config[CONF_ENABLE_IPV6]
enable_ipv4 = config[CONF_ENABLE_IPV4]
# Store the user-declared network priority list in CORE.data so that ethernet,
# wifi and other network components can query it via get_network_priority()
@@ -557,7 +628,8 @@ async def to_code(config: ConfigType) -> None:
if CORE.is_nrf52:
zephyr_add_prj_conf("NETWORKING", True)
zephyr_add_prj_conf("NET_IPV6", True)
zephyr_add_prj_conf("NET_IPV4", enable_ipv4)
zephyr_add_prj_conf("NET_IPV6", enable_ipv6)
zephyr_add_prj_conf("NET_TCP", True)
zephyr_add_prj_conf("NET_UDP", True)
# The nRF Connect SDK replaces mbedTLS with PSA/Oberon crypto and does not provide the
@@ -582,28 +654,28 @@ async def to_code(config: ConfigType) -> None:
zephyr_add_prj_conf("NET_TCP_MAX_RECV_WINDOW_SIZE", 2280)
zephyr_add_prj_conf("NET_TCP_MAX_SEND_WINDOW_SIZE", 2280)
if (enable_ipv6 := config.get(CONF_ENABLE_IPV6, None)) is not None:
cg.add_define("USE_NETWORK_IPV6", enable_ipv6)
if enable_ipv6:
cg.add_define(
"USE_NETWORK_MIN_IPV6_ADDR_COUNT", config[CONF_MIN_IPV6_ADDR_COUNT]
)
if CORE.is_esp32:
if CORE.using_arduino:
add_idf_sdkconfig_option("CONFIG_LWIP_IPV6", True)
add_idf_sdkconfig_option("CONFIG_LWIP_IPV6_AUTOCONFIG", True)
else:
add_idf_sdkconfig_option("CONFIG_LWIP_IPV6", enable_ipv6)
add_idf_sdkconfig_option("CONFIG_LWIP_IPV6_AUTOCONFIG", enable_ipv6)
elif enable_ipv6:
cg.add_build_flag("-DCONFIG_LWIP_IPV6")
cg.add_build_flag("-DCONFIG_LWIP_IPV6_AUTOCONFIG")
if CORE.is_bk72xx:
cg.add_build_flag("-DCONFIG_IPV6")
if CORE.is_esp8266:
cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY")
if CORE.is_rp2:
cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_IPV6")
cg.add_define("USE_NETWORK_IPV6", enable_ipv6)
if enable_ipv6:
cg.add_define(
"USE_NETWORK_MIN_IPV6_ADDR_COUNT", config[CONF_MIN_IPV6_ADDR_COUNT]
)
if CORE.is_esp32:
add_idf_sdkconfig_option("CONFIG_LWIP_IPV6", enable_ipv6)
add_idf_sdkconfig_option("CONFIG_LWIP_IPV6_AUTOCONFIG", enable_ipv6)
elif enable_ipv6:
cg.add_build_flag("-DCONFIG_LWIP_IPV6")
cg.add_build_flag("-DCONFIG_LWIP_IPV6_AUTOCONFIG")
if CORE.is_bk72xx:
cg.add_build_flag("-DCONFIG_IPV6")
if CORE.is_esp8266:
cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY")
if CORE.is_rp2:
cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_IPV6")
cg.add_define("USE_NETWORK_IPV4", enable_ipv4)
if CORE.is_esp32:
add_idf_sdkconfig_option("CONFIG_LWIP_IPV4", enable_ipv4)
# Pvariable creation lives in a separate coroutine at NETWORK_SERVICES so it
# emits after wifi/ethernet at COMMUNICATION. This keeps compile-time config
# (above) separate from C++ object lifecycle and allows wiring in interface
+106 -29
View File
@@ -15,14 +15,14 @@
#if USE_ARDUINO
#include <Arduino.h>
#include <IPAddress.h>
#endif /* USE_ADRDUINO */
#endif /* USE_ARDUINO */
#ifdef USE_HOST
#include <arpa/inet.h>
#if USE_NETWORK_IPV6
using ip4_addr_t = struct in_addr;
using ip6_addr_t = struct in6_addr;
struct ip_addr_t {
struct ip_addr_t { // NOLINT(readability-identifier-naming) mirrors the lwIP name the other platforms use
union {
struct in6_addr ip6;
struct in_addr ip4;
@@ -55,9 +55,23 @@ using ip4_addr_t = in_addr;
#include <zephyr/net/net_ip.h>
#include <zephyr/net/socket.h>
#include <zephyr/posix/arpa/inet.h>
using ip_addr_t = struct in6_addr;
static inline int ipaddr_aton(const char *cp, ip_addr_t *addr) { return inet_pton(AF_INET6, cp, addr) == 1 ? 1 : 0; }
#ifndef CONFIG_NET_IPV6
#error "Zephyr builds need CONFIG_NET_IPV6"
#endif
using ip_addr_t = struct in6_addr;
static inline int ipaddr_aton(const char *cp, ip_addr_t *addr) {
#if defined(CONFIG_NET_IPV4)
// Dual-stack: a plain IPv4 literal must still parse -- store it as a v4-mapped
// IPv6 address (::ffff:a.b.c.d), matching the 4-octet constructor below.
struct in_addr addr4;
if (inet_pton(AF_INET, cp, &addr4) == 1) {
net_ipv6_addr_create_v4_mapped(&addr4, addr);
return 1;
}
#endif /* CONFIG_NET_IPV4 */
return inet_pton(AF_INET6, cp, addr) == 1 ? 1 : 0;
}
#endif /* USE_ZEPHYR */
#if USE_ESP32_FRAMEWORK_ARDUINO
#define arduino_ns Arduino_h
@@ -65,16 +79,24 @@ static inline int ipaddr_aton(const char *cp, ip_addr_t *addr) { return inet_pto
#define arduino_ns arduino
#elif USE_ARDUINO
#define arduino_ns
#endif
#endif /* USE_ESP32_FRAMEWORK_ARDUINO */
#ifdef USE_ESP32
#include <esp_netif.h>
#endif
#endif /* USE_ESP32 */
namespace esphome::network {
/// Buffer size for IP address string (IPv6 max: 39 chars + null)
static constexpr size_t IP_ADDRESS_BUFFER_SIZE = 40;
static constexpr size_t IP_ADDRESS_BUFFER_SIZE =
#if defined(USE_ZEPHYR)
// Mainline Zephyr's inet_ntop() (net_addr_ntop() in subsys/net/ip/utils.c) never checks
// its size argument, so an undersized buffer overflows instead of failing cleanly. Size
// for the worst case: an IPv4-mapped IPv6 address needs up to INET6_ADDRSTRLEN (46).
46;
#else
40;
#endif
/// Lowercase hex digits in IP address string (A-F -> a-f for IPv6 per RFC 5952)
inline void lowercase_ip_str(char *buf) {
@@ -86,19 +108,58 @@ inline void lowercase_ip_str(char *buf) {
struct IPAddress {
public:
#ifdef USE_ZEPHYR
#if defined(USE_ZEPHYR)
// With CONFIG_NET_IPV4 (dual stack), IPv4 addresses are stored v4-mapped (::ffff:a.b.c.d).
IPAddress() { memset(&ip_addr_, 0, sizeof(ip_addr_)); }
#if defined(CONFIG_NET_IPV4)
IPAddress(uint8_t first, uint8_t second, uint8_t third, uint8_t fourth) {
struct in_addr addr4{
.s_addr = htonl(((uint32_t) first << 24) | ((uint32_t) second << 16) | ((uint32_t) third << 8) | fourth)};
net_ipv6_addr_create_v4_mapped(&addr4, &ip_addr_);
}
#endif /* CONFIG_NET_IPV4 */
IPAddress(const std::string &in_address) : ip_addr_{} { ipaddr_aton(in_address.c_str(), &ip_addr_); }
IPAddress(const struct in6_addr *other_ip) { ip_addr_ = *other_ip; }
IPAddress(const struct sockaddr_in6 *addr) { ip_addr_ = addr->sin6_addr; }
operator struct in6_addr() const { return ip_addr_; }
bool is_set() const { return !net_ipv6_is_addr_unspecified(&ip_addr_); }
bool is_set() const {
#if defined(CONFIG_NET_IPV4)
if (net_ipv6_addr_is_v4_mapped(&ip_addr_))
return ip_addr_.s6_addr32[3] != 0;
#endif /* CONFIG_NET_IPV4 */
return !net_ipv6_is_addr_unspecified(&ip_addr_);
}
#if defined(CONFIG_NET_IPV4)
// V4-mapped (::ffff:a.b.c.d) addresses represent IPv4 traffic carried over the AF_INET6 socket
// Zephyr always uses when CONFIG_NET_IPV6 is on; anything else is real IPv6.
bool is_ip4() const { return this->is_set() && net_ipv6_addr_is_v4_mapped(&ip_addr_); }
bool is_ip6() const { return this->is_set() && !net_ipv6_addr_is_v4_mapped(&ip_addr_); }
#else
bool is_ip4() const { return false; }
bool is_ip6() const { return this->is_set(); }
bool is_multicast() const { return net_ipv6_is_addr_mcast(&ip_addr_); }
#endif /* CONFIG_NET_IPV4 */
bool is_multicast() const {
#if defined(CONFIG_NET_IPV4)
if (net_ipv6_addr_is_v4_mapped(&ip_addr_)) {
struct in_addr addr4{};
addr4.s_addr = ip_addr_.s6_addr32[3];
return net_ipv4_is_addr_mcast(&addr4);
}
#endif /* CONFIG_NET_IPV4 */
return net_ipv6_is_addr_mcast(&ip_addr_);
}
char *str_to(char *buf) const {
#if defined(CONFIG_NET_IPV4)
if (this->is_ip4()) {
struct in_addr addr4{};
addr4.s_addr = ip_addr_.s6_addr32[3];
if (inet_ntop(AF_INET, &addr4, buf, IP_ADDRESS_BUFFER_SIZE) == nullptr)
buf[0] = '\0';
return buf;
}
#endif /* CONFIG_NET_IPV4 */
if (inet_ntop(AF_INET6, &ip_addr_, buf, IP_ADDRESS_BUFFER_SIZE) == nullptr)
buf[0] = '\0';
return buf;
@@ -134,8 +195,8 @@ struct IPAddress {
operator ip_addr_t() const { return this->ip_addr_; }
bool is_set() const {
if (this->ip_addr_.type == IPADDR_TYPE_V6) {
static constexpr uint8_t zero[sizeof(struct in6_addr)] = {};
return memcmp(this->ip_addr_.u_addr.ip6.s6_addr, zero, sizeof(zero)) != 0;
static constexpr uint8_t ZERO[sizeof(struct in6_addr)] = {};
return memcmp(this->ip_addr_.u_addr.ip6.s6_addr, ZERO, sizeof(ZERO)) != 0;
}
return this->ip_addr_.u_addr.ip4.s_addr != 0;
}
@@ -187,59 +248,72 @@ struct IPAddress {
return buf; // IPv4 only, no hex letters to lowercase
}
#endif // USE_NETWORK_IPV6
#else
#else // LWIP
IPAddress() { ip_addr_set_zero(&ip_addr_); }
#if LWIP_IPV4
IPAddress(uint8_t first, uint8_t second, uint8_t third, uint8_t fourth) {
IP_ADDR4(&ip_addr_, first, second, third, fourth);
}
#endif /* LWIP_IPV4 */
IPAddress(const ip_addr_t *other_ip) { ip_addr_copy(ip_addr_, *other_ip); }
IPAddress(const char *in_address) { ipaddr_aton(in_address, &ip_addr_); }
IPAddress(const std::string &in_address) { ipaddr_aton(in_address.c_str(), &ip_addr_); }
#if LWIP_IPV4
IPAddress(ip4_addr_t *other_ip) {
memcpy((void *) &ip_addr_, (void *) other_ip, sizeof(ip4_addr_t));
#if LWIP_IPV6
ip_addr_.type = IPADDR_TYPE_V4;
#endif
#endif /* LWIP_IPV6 */
}
#endif /* LWIP_IPV4 */
#if USE_ARDUINO
IPAddress(const arduino_ns::IPAddress &other_ip) { ip_addr_set_ip4_u32(&ip_addr_, other_ip); }
#endif
#endif /* USE_ARDUINO */
#if LWIP_IPV6
IPAddress(ip6_addr_t *other_ip) {
memcpy((void *) &ip_addr_, (void *) other_ip, sizeof(ip6_addr_t));
ip_addr_.type = IPADDR_TYPE_V6;
IP_SET_TYPE_VAL(ip_addr_, IPADDR_TYPE_V6);
}
#endif /* LWIP_IPV6 */
#ifdef USE_ESP32
#if LWIP_IPV6
IPAddress(esp_ip6_addr_t *other_ip) {
memcpy((void *) &ip_addr_.u_addr.ip6, (void *) other_ip, sizeof(esp_ip6_addr_t));
ip_addr_.type = IPADDR_TYPE_V6;
memcpy((void *) ip_2_ip6(&ip_addr_), (void *) other_ip, sizeof(esp_ip6_addr_t));
IP_SET_TYPE_VAL(ip_addr_, IPADDR_TYPE_V6);
}
#endif /* LWIP_IPV6 */
#if LWIP_IPV4
IPAddress(esp_ip4_addr_t *other_ip) {
memcpy((void *) &ip_addr_, (void *) other_ip, sizeof(esp_ip4_addr_t));
#if LWIP_IPV6
ip_addr_.type = IPADDR_TYPE_V4;
#endif
#endif /* LWIP_IPV6 */
}
#endif /* LWIP_IPV4 */
IPAddress(esp_ip_addr_t *other_ip) {
#if LWIP_IPV6
memcpy((void *) &ip_addr_, (void *) other_ip, sizeof(ip_addr_));
#else
#elif LWIP_IPV4
memcpy((void *) &ip_addr_, (void *) &other_ip->u_addr.ip4, sizeof(ip_addr_));
#endif
#endif /* LWIP_IPV6 */
}
operator esp_ip_addr_t() const {
esp_ip_addr_t tmp;
esp_ip_addr_t tmp{};
#if LWIP_IPV6
memcpy((void *) &tmp, (void *) &ip_addr_, sizeof(ip_addr_));
#else
#if !LWIP_IPV4
tmp.type = IPADDR_TYPE_V6;
#endif /* !LWIP_IPV4 */
#elif LWIP_IPV4
memcpy((void *) &tmp.u_addr.ip4, (void *) &ip_addr_, sizeof(ip_addr_));
tmp.type = IPADDR_TYPE_V4;
#else
#error "Neither LWIP_IPV4 nor LWIP_IPV6 is enabled"
#endif /* LWIP_IPV6 */
return tmp;
}
#if LWIP_IPV4
operator esp_ip4_addr_t() const {
esp_ip4_addr_t tmp;
#if LWIP_IPV6
@@ -249,16 +323,17 @@ struct IPAddress {
#endif /* LWIP_IPV6 */
return tmp;
}
#endif /* LWIP_IPV4 */
#endif /* USE_ESP32 */
operator ip_addr_t() const { return ip_addr_; }
#if LWIP_IPV6
#if LWIP_IPV6 && LWIP_IPV4
operator ip4_addr_t() const { return *ip_2_ip4(&ip_addr_); }
#endif /* LWIP_IPV6 */
#endif /* LWIP_IPV6 && LWIP_IPV4 */
#if USE_ARDUINO
operator arduino_ns::IPAddress() const { return ip_addr_get_ip4_u32(&ip_addr_); }
#endif
#endif /* USE_ARDUINO */
bool is_set() const { return !ip_addr_isany(&ip_addr_); } // NOLINT(readability-simplify-boolean-expr)
bool is_ip4() const { return IP_IS_V4(&ip_addr_); }
@@ -273,17 +348,19 @@ struct IPAddress {
}
bool operator==(const IPAddress &other) const { return ip_addr_cmp(&ip_addr_, &other.ip_addr_); }
bool operator!=(const IPAddress &other) const { return !ip_addr_cmp(&ip_addr_, &other.ip_addr_); }
#if LWIP_IPV4
IPAddress &operator+=(uint8_t increase) {
if (IP_IS_V4(&ip_addr_)) {
#if LWIP_IPV6
(((u8_t *) (&ip_addr_.u_addr.ip4))[3]) += increase;
#else
(((u8_t *) (&ip_addr_.addr))[3]) += increase;
#endif /* LWIP_IPV6 */
#endif /* LWIP_IPV6 */
}
return *this;
}
#endif
#endif /* LWIP_IPV4 */
#endif // LWIP
protected:
ip_addr_t ip_addr_;
@@ -292,4 +369,4 @@ struct IPAddress {
using IPAddresses = std::array<IPAddress, 5>;
} // namespace esphome::network
#endif
#endif /* NETWORK */
+3
View File
@@ -11,6 +11,7 @@ from esphome import pins
from esphome.build_helpers import pch
from esphome.build_helpers.ccache import resolve_ccache_path
import esphome.codegen as cg
from esphome.components import network
from esphome.components.zephyr import (
Section,
add_extra_script,
@@ -322,6 +323,8 @@ CONFIG_SCHEMA = cv.All(
_default_self_reset_pin,
_resolve_toolchain,
set_framework,
network.require_ipv6(PLATFORM_NRF52),
network.request_ipv4_off(PLATFORM_NRF52),
)
+4 -8
View File
@@ -31,7 +31,7 @@ from esphome.components.esp32 import (
require_vfs_select,
)
from esphome.components.mdns import MDNSComponent, enable_mdns_storage
from esphome.components.network import DOMAIN as NETWORK_DOMAIN, add_use_address
from esphome.components.network import add_use_address, request_ipv4_off, require_ipv6
from esphome.components.nrf52.framework import include_west_project
from esphome.components.zephyr import zephyr_add_prj_conf
from esphome.config_helpers import filter_source_files_from_platform
@@ -40,7 +40,6 @@ from esphome.const import (
CONF_AP,
CONF_BAUD_RATE,
CONF_CHANNEL,
CONF_ENABLE_IPV6,
CONF_ENABLE_ON_BOOT,
CONF_FRAMEWORK,
CONF_HARDWARE_UART,
@@ -50,6 +49,7 @@ from esphome.const import (
CONF_LOGGER,
CONF_NETWORKS,
CONF_NUMBER,
CONF_OPENTHREAD,
CONF_OUTPUT_POWER,
CONF_RESET_PIN,
CONF_RX_PIN,
@@ -448,17 +448,13 @@ CONFIG_SCHEMA = cv.All(
_validate_platform,
_validate,
_require_vfs,
require_ipv6(CONF_OPENTHREAD),
request_ipv4_off(CONF_OPENTHREAD),
)
def _final_validate(config: ConfigType) -> ConfigType:
full_config = fv.full_config.get()
network_config = full_config.get(NETWORK_DOMAIN, {})
if not network_config.get(CONF_ENABLE_IPV6, False):
raise cv.Invalid(
"OpenThread requires IPv6 to be enabled in the network component. "
"Please set `enable_ipv6: true` in the `network` configuration."
)
wifi_config = full_config.get(CONF_WIFI)
if CONF_BORDER_ROUTER not in config:
+2
View File
@@ -7,6 +7,7 @@ import subprocess
from typing import Any
import esphome.codegen as cg
from esphome.components import network
import esphome.config_validation as cv
from esphome.const import (
CONF_BOARD,
@@ -318,6 +319,7 @@ CONFIG_SCHEMA = cv.All(
_detect_variant,
cv.require_platformio_toolchain("RP2"),
set_core_data,
network.require_ipv4(PLATFORM_RP2),
)
+7 -1
View File
@@ -211,9 +211,15 @@ def require_tcp_listener() -> None:
def consume_role_sockets(component: str) -> Callable[[ConfigType], ConfigType]:
"""Socket accounting for a role keyed client or server schema."""
"""Socket accounting for a role keyed client or server schema.
Also requires IPv4, since the TCP link and listener helpers are IPv4 only.
"""
def validator(config: ConfigType) -> ConfigType:
from esphome.components import network
network.require_ipv4(component)(config)
if config[CONF_ROLE] == "server":
consume_sockets(1, component, SocketType.TCP_LISTEN)(config)
return consume_sockets(1, component)(config)
+5
View File
@@ -204,6 +204,11 @@ using socklen_t = uint32_t;
#if defined(USE_SOCKET_IMPL_LWIP_TCP) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS)
// Build tests compile socket without network, which normally defines this
#ifndef USE_NETWORK_IPV4
#define USE_NETWORK_IPV4 1
#endif
namespace esphome::socket {
// Maximum length for formatted socket address string (IP address without port)
+41 -21
View File
@@ -42,54 +42,55 @@ bool socket_ready_fd(int /*fd*/, bool /*loop_monitored*/) { return true; }
#endif
// Platform-specific inet_ntop wrappers
#if USE_NETWORK_IPV4
#if defined(USE_SOCKET_IMPL_LWIP_TCP)
// LWIP raw TCP (ESP8266) uses inet_ntoa_r which takes struct by value
static inline const char *esphome_inet_ntop4(const void *addr, char *buf, size_t size) {
inet_ntoa_r(*reinterpret_cast<const struct in_addr *>(addr), buf, size);
return buf;
}
#if USE_NETWORK_IPV6
static inline const char *esphome_inet_ntop6(const void *addr, char *buf, size_t size) {
inet6_ntoa_r(*reinterpret_cast<const ip6_addr_t *>(addr), buf, size);
return buf;
}
#endif
#elif defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
// LWIP sockets (LibreTiny, ESP32 Arduino)
static inline const char *esphome_inet_ntop4(const void *addr, char *buf, size_t size) {
return lwip_inet_ntop(AF_INET, addr, buf, size);
}
#if USE_NETWORK_IPV6
static inline const char *esphome_inet_ntop6(const void *addr, char *buf, size_t size) {
return lwip_inet_ntop(AF_INET6, addr, buf, size);
}
#endif
#elif defined(USE_ZEPHYR)
// Zephyr BSD sockets — use Zephyr native address formatting via POSIX-subset wrappers.
// <zephyr/net/socket.h> is already included transitively through <sys/socket.h>.
static inline const char *esphome_inet_ntop4(const void *addr, char *buf, size_t size) {
return zsock_inet_ntop(AF_INET, addr, buf, size);
}
// IPv6 is always enabled on nRF52 (config validation enforces enable_ipv6=True),
// but the guard is retained for consistency with other platform blocks.
#if USE_NETWORK_IPV6
static inline const char *esphome_inet_ntop6(const void *addr, char *buf, size_t size) {
return zsock_inet_ntop(AF_INET6, addr, buf, size);
}
#endif
#else
// BSD sockets (host, ESP32-IDF)
static inline const char *esphome_inet_ntop4(const void *addr, char *buf, size_t size) {
return inet_ntop(AF_INET, addr, buf, size);
}
#endif
#endif /* USE_NETWORK_IPV4 */
#if USE_NETWORK_IPV6
#if defined(USE_SOCKET_IMPL_LWIP_TCP)
static inline const char *esphome_inet_ntop6(const void *addr, char *buf, size_t size) {
inet6_ntoa_r(*reinterpret_cast<const ip6_addr_t *>(addr), buf, size);
return buf;
}
#elif defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
static inline const char *esphome_inet_ntop6(const void *addr, char *buf, size_t size) {
return lwip_inet_ntop(AF_INET6, addr, buf, size);
}
#elif defined(USE_ZEPHYR)
static inline const char *esphome_inet_ntop6(const void *addr, char *buf, size_t size) {
return zsock_inet_ntop(AF_INET6, addr, buf, size);
}
#else
static inline const char *esphome_inet_ntop6(const void *addr, char *buf, size_t size) {
return inet_ntop(AF_INET6, addr, buf, size);
}
#endif
#endif
#endif /* USE_NETWORK_IPV6 */
bool sockaddr_to_ipv4(const struct sockaddr *addr, uint32_t *out) {
#if USE_NETWORK_IPV4
if (addr->sa_family == AF_INET) {
*out = reinterpret_cast<const struct sockaddr_in *>(addr)->sin_addr.s_addr;
return true;
@@ -105,23 +106,31 @@ bool sockaddr_to_ipv4(const struct sockaddr *addr, uint32_t *out) {
}
}
#endif
#else
(void) addr;
(void) out;
#endif /* USE_NETWORK_IPV4 */
return false;
}
// Format sockaddr into caller-provided buffer, returns length written (excluding null)
size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::span<char, SOCKADDR_STR_LEN> buf) {
#if USE_NETWORK_IPV4
if (addr_ptr->sa_family == AF_INET && len >= sizeof(const struct sockaddr_in)) {
const auto *addr = reinterpret_cast<const struct sockaddr_in *>(addr_ptr);
if (esphome_inet_ntop4(&addr->sin_addr, buf.data(), buf.size()) != nullptr)
return strlen(buf.data());
}
#endif /* USE_NETWORK_IPV4 */
#if USE_NETWORK_IPV6
else if (addr_ptr->sa_family == AF_INET6 && len >= sizeof(sockaddr_in6)) {
if (addr_ptr->sa_family == AF_INET6 && len >= sizeof(sockaddr_in6)) {
const auto *addr = reinterpret_cast<const struct sockaddr_in6 *>(addr_ptr);
#if USE_NETWORK_IPV4
uint32_t v4;
// Format a v4 mapped peer as plain IPv4.
if (sockaddr_to_ipv4(addr_ptr, &v4) && esphome_inet_ntop4(&v4, buf.data(), buf.size()) != nullptr)
return strlen(buf.data());
#endif /* USE_NETWORK_IPV4 */
if (esphome_inet_ntop6(&addr->sin6_addr, buf.data(), buf.size()) != nullptr)
return strlen(buf.data());
}
@@ -235,6 +244,10 @@ template<typename F> static bool foreach_eligible_ipv6_if(F &&callback) {
bool join_multicast_group(UDPSocket *sock, const char *ip_address, uint32_t *if_index_out) {
if (strchr(ip_address, ':') == nullptr) {
#if !USE_NETWORK_IPV4
errno = EAFNOSUPPORT;
return false;
#else
struct in_addr ipv4mc{};
#ifdef USE_ZEPHYR
if (zsock_inet_pton(AF_INET, ip_address, &ipv4mc) != 1) {
@@ -262,6 +275,7 @@ bool join_multicast_group(UDPSocket *sock, const char *ip_address, uint32_t *if_
*if_index_out = 0;
}
return true;
#endif // USE_NETWORK_IPV4
}
#ifdef USE_SOCKET_IPV6
struct ipv6_mreq imreq6{};
@@ -373,7 +387,7 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_
#else
// Use LWIP-specific functions
ip6_addr_t ip6;
if (inet6_aton(ip_address, &ip6) == 0) {
if (inet6_aton(ip_address, &ip6) != 1) {
errno = EINVAL;
return 0;
}
@@ -382,6 +396,7 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_
return sizeof(sockaddr_in6);
}
#endif /* USE_NETWORK_IPV6 */
#if USE_NETWORK_IPV4
if (addrlen < sizeof(sockaddr_in)) {
errno = EINVAL;
return 0;
@@ -404,6 +419,11 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_
#endif
server->sin_port = htons(port);
return sizeof(sockaddr_in);
#else
memset(addr, 0, addrlen);
errno = EAFNOSUPPORT;
return 0;
#endif /* USE_NETWORK_IPV4 */
}
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
@@ -48,8 +48,8 @@ enum EventGroupBits : uint32_t {
};
AudioPipeline::AudioPipeline(speaker::Speaker *speaker, size_t buffer_size, bool task_stack_in_psram,
std::string base_name, UBaseType_t priority)
: base_name_(std::move(base_name)),
const char *base_name, UBaseType_t priority)
: base_name_(base_name),
priority_(priority),
task_stack_in_psram_(task_stack_in_psram),
speaker_(speaker),
@@ -269,18 +269,20 @@ esp_err_t AudioPipeline::allocate_communications_() {
}
esp_err_t AudioPipeline::start_tasks_() {
char task_name[configMAX_TASK_NAME_LEN];
if (!this->read_task_.is_created()) {
// Reader task uses the AudioReader class which uses esp_http_client. This crashes on IDF 5.4 if the task stack is
// in PSRAM. As a workaround, always allocate the read task in internal memory.
if (!this->read_task_.create(read_task, (this->base_name_ + "_read").c_str(), READ_TASK_STACK_SIZE, (void *) this,
this->priority_, false)) {
snprintf(task_name, sizeof(task_name), "%s_read", this->base_name_);
if (!this->read_task_.create(read_task, task_name, READ_TASK_STACK_SIZE, (void *) this, this->priority_, false)) {
return ESP_ERR_NO_MEM;
}
}
if (!this->decode_task_.is_created()) {
if (!this->decode_task_.create(decode_task, (this->base_name_ + "_decode").c_str(), DECODE_TASK_STACK_SIZE,
(void *) this, this->priority_, this->task_stack_in_psram_)) {
snprintf(task_name, sizeof(task_name), "%s_decode", this->base_name_);
if (!this->decode_task_.create(decode_task, task_name, DECODE_TASK_STACK_SIZE, (void *) this, this->priority_,
this->task_stack_in_psram_)) {
return ESP_ERR_NO_MEM;
}
}
@@ -63,7 +63,7 @@ class AudioPipeline final {
/// @param task_stack_in_psram True if the task stack should be allocated in PSRAM, false otherwise
/// @param task_name FreeRTOS task base name
/// @param priority FreeRTOS task priority
AudioPipeline(speaker::Speaker *speaker, size_t buffer_size, bool task_stack_in_psram, std::string base_name,
AudioPipeline(speaker::Speaker *speaker, size_t buffer_size, bool task_stack_in_psram, const char *base_name,
UBaseType_t priority);
/// @brief Starts an audio pipeline given a media url
@@ -103,7 +103,7 @@ class AudioPipeline final {
/// @return ESP_OK if successful or an appropriate error if not
esp_err_t start_tasks_();
std::string base_name_;
const char *base_name_;
UBaseType_t priority_;
uint32_t playback_ms_{0};
+2 -1
View File
@@ -1,5 +1,5 @@
import esphome.codegen as cg
from esphome.components import binary_sensor, sensor, socket
from esphome.components import binary_sensor, network, sensor, socket
from esphome.components.const import CONF_HOST
import esphome.config_validation as cv
from esphome.const import (
@@ -48,6 +48,7 @@ CONFIG_SCHEMA = cv.All(
),
}
).extend(cv.polling_component_schema("10s")),
network.require_ipv4("statsd"),
socket.consume_sockets(1, "statsd", socket.SocketType.UDP),
)
+10 -14
View File
@@ -4,8 +4,8 @@ from typing import Any
from esphome import automation
from esphome.automation import Trigger
import esphome.codegen as cg
from esphome.components import network
from esphome.components.const import CONF_ON_RECEIVE
from esphome.components.network import DOMAIN as NETWORK_DOMAIN
from esphome.components.packet_transport import (
CONF_BINARY_SENSORS,
CONF_ENCRYPTION,
@@ -15,13 +15,7 @@ from esphome.components.packet_transport import (
CONF_SENSORS,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_DATA,
CONF_ENABLE_IPV6,
CONF_ID,
CONF_PORT,
CONF_TRIGGER_ID,
)
from esphome.const import CONF_DATA, CONF_ID, CONF_PORT, CONF_TRIGGER_ID
from esphome.core import ID
from esphome.cpp_generator import MockObj, TemplateArgsType
import esphome.final_validate as fv
@@ -106,7 +100,6 @@ def _final_validate(config: ConfigType) -> None:
from esphome.components import socket
full_config = fv.full_config.get()
ipv6_enabled = full_config.get(NETWORK_DOMAIN, {}).get(CONF_ENABLE_IPV6, False)
raw_lwip = (
full_config.get(socket.DOMAIN, {}).get(socket.CONF_IMPLEMENTATION)
== socket.IMPLEMENTATION_LWIP_TCP
@@ -122,11 +115,6 @@ def _final_validate(config: ConfigType) -> None:
raise cv.Invalid(
f"IPv6 address '{addr}' is not supported on this platform", [key]
)
if not ipv6_enabled:
raise cv.Invalid(
f"IPv6 address '{addr}' requires 'enable_ipv6: true' in the 'network' component",
[key],
)
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -136,6 +124,12 @@ def _ipv6_addresses(config: ConfigType) -> list[IPv6Address]:
return [addr for addr in config[CONF_ADDRESSES] if addr.version == 6]
def _require_ipv6_for_ipv6_addresses(config: ConfigType) -> ConfigType:
if _ipv6_addresses(config) or config[CONF_LISTEN_ADDRESS].version == 6:
network.require_ipv6("udp")(config)
return config
def _has_ipv4_address(config: ConfigType) -> bool:
return any(addr.version == 4 for addr in config[CONF_ADDRESSES])
@@ -178,6 +172,8 @@ CONFIG_SCHEMA = cv.All(
}
).extend(RELOCATED),
_consume_udp_sockets,
network.require_ipv4("udp"),
_require_ipv6_for_ipv6_addresses,
)
@@ -1,6 +1,12 @@
from esphome import automation, core
import esphome.codegen as cg
from esphome.components import media_player, micro_wake_word, microphone, speaker
from esphome.components import (
media_player,
micro_wake_word,
microphone,
network,
speaker,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
@@ -169,6 +175,7 @@ CONFIG_SCHEMA = cv.All(
}
).extend(cv.COMPONENT_SCHEMA),
cv.key_requires((CONF_ON_TTS_STREAM_START, CONF_ON_TTS_STREAM_END), CONF_SPEAKER),
network.require_ipv4("voice_assistant"),
)
FINAL_VALIDATE_SCHEMA = cv.All(
+2 -1
View File
@@ -1,5 +1,5 @@
import esphome.codegen as cg
from esphome.components import button, socket
from esphome.components import button, network, socket
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
@@ -22,6 +22,7 @@ CONFIG_SCHEMA = cv.All(
cv.Required(CONF_TARGET_MAC_ADDRESS): cv.mac_address,
}
),
network.require_ipv4("wake_on_lan"),
socket.consume_sockets(1, "wake_on_lan", socket.SocketType.UDP),
)
@@ -4,4 +4,5 @@ DOMAIN = "waveshare_epaper"
DEPRECATED_COMPONENT = """
The 'waveshare_epaper' component is deprecated and no new models will be added to it.
New model PRs should target the newer and more performant 'epaper_spi' component.
It will be removed in ESPHome 2027.5.0.
"""
@@ -1,3 +1,5 @@
import logging
from esphome import core, pins
import esphome.codegen as cg
from esphome.components import display, spi
@@ -17,6 +19,8 @@ from esphome.types import ConfigType
DEPENDENCIES = ["spi"]
LOGGER = logging.getLogger(__name__)
waveshare_epaper_ns = cg.esphome_ns.namespace("waveshare_epaper")
WaveshareEPaperBase = waveshare_epaper_ns.class_(
"WaveshareEPaperBase", cg.PollingComponent, spi.SPIDevice, display.DisplayBuffer
@@ -229,6 +233,11 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema(
async def to_code(config: ConfigType) -> None:
# Remove before 2027.5.0
LOGGER.warning(
"The 'waveshare_epaper' component is deprecated and will be removed in ESPHome 2027.5.0, "
"migrate to 'epaper_spi' instead."
)
model_type, model = MODELS[config[CONF_MODEL]]
if model_type == "a":
rhs = WaveshareEPaperTypeA.new(model)
+2
View File
@@ -20,6 +20,7 @@ from esphome.components.network import (
has_high_performance_wifi,
ip_address_literal,
require_high_performance_wifi,
require_ipv4,
)
from esphome.components.psram import is_guaranteed as psram_is_guaranteed
from esphome.config_helpers import filter_source_files_from_platform
@@ -702,6 +703,7 @@ CONFIG_SCHEMA = cv.All(
_apply_min_auth_mode_default,
_validate,
_report_provisioning_credentials,
require_ipv4(DOMAIN),
)
+2 -1
View File
@@ -3,7 +3,7 @@ import re
from esphome import automation
import esphome.codegen as cg
from esphome.components import time
from esphome.components import network, time
from esphome.components.esp32 import CORE, add_idf_sdkconfig_option
import esphome.config_validation as cv
from esphome.const import (
@@ -92,6 +92,7 @@ CONFIG_SCHEMA = cv.All(
PLATFORM_LN882X,
]
),
network.require_ipv4("wireguard"),
)
+10
View File
@@ -241,6 +241,13 @@
#define NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT 1
#define USE_NETWORK
#define USE_NETWORK_DEFAULT_ROUTE
#define USE_NETWORK_IPV4 true
// lwIP is built without IPv6 on ESP8266, RP2 and LibreTiny
#if defined(USE_ESP32) || defined(USE_HOST)
#define USE_NETWORK_IPV6 true
#else
#define USE_NETWORK_IPV6 false
#endif
#define USE_NETWORK_PRIMARY_INTERFACE_WIFI
#define USE_NEXTION_COMMAND_SPACING
#define USE_NEXTION_CONF_START_UP_PAGE
@@ -427,6 +434,9 @@
#define USE_ESP32_CRASH_HANDLER
#define USE_ESP32_VASPRINTF_STUB
#define USE_MQTT_IDF_ENQUEUE
#define USE_MQTT_TLS
#define USE_MQTT_CERT_BUNDLE
#define USE_MQTT_TLS_INSECURE
#define USE_ESPHOME_TASK_LOG_BUFFER
#define ESPHOME_TASK_LOG_BUFFER_SIZE 768
#define USE_OTA_ROLLBACK
+18 -11
View File
@@ -97,11 +97,12 @@ def prepare(
# Match the firmware's transport; imported here to keep component code off the CLI start-up path
from esphome.components.mqtt import (
CONF_TLS,
CONF_TRANSPORT,
CONF_VERIFY_SSL,
CONF_WS_PATH,
DEFAULT_WS_PATH,
MQTT_TRANSPORT_TCP,
MQTT_TRANSPORT_WSS,
)
transport = config[CONF_MQTT].get(CONF_TRANSPORT, MQTT_TRANSPORT_TCP)
@@ -122,15 +123,21 @@ def prepare(
elif username:
client.username_pw_set(username, password)
# TLS with the CA, or the system CAs over wss
certificate_authority = config[CONF_MQTT].get(CONF_CERTIFICATE_AUTHORITY)
if certificate_authority or transport == MQTT_TRANSPORT_WSS:
context = ssl.create_default_context(cadata=certificate_authority)
if config[CONF_MQTT].get(CONF_SKIP_CERT_CN_CHECK):
mqtt_conf = config[CONF_MQTT]
# Validation sets tls whenever a certificate option or the wss transport implies it
if mqtt_conf.get(CONF_TLS):
if mqtt_conf.get(CONF_VERIFY_SSL) is False:
context = ssl.create_default_context()
context.check_hostname = False
if config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE) and config[CONF_MQTT].get(
CONF_CLIENT_CERTIFICATE_KEY
):
context.verify_mode = ssl.CERT_NONE
else:
# Without a CA the system trust store stands in for the firmware's bundle
context = ssl.create_default_context(
cadata=mqtt_conf.get(CONF_CERTIFICATE_AUTHORITY)
)
if mqtt_conf.get(CONF_SKIP_CERT_CN_CHECK):
context.check_hostname = False
if mqtt_conf.get(CONF_CLIENT_CERTIFICATE):
with (
tempfile.NamedTemporaryFile(
encoding="utf-8", mode="w+", delete=False
@@ -140,8 +147,8 @@ def prepare(
) as key_file,
):
try:
cert_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE))
key_file.write(config[CONF_MQTT].get(CONF_CLIENT_CERTIFICATE_KEY))
cert_file.write(mqtt_conf[CONF_CLIENT_CERTIFICATE])
key_file.write(mqtt_conf[CONF_CLIENT_CERTIFICATE_KEY])
cert_file.close()
key_file.close()
context.load_cert_chain(cert_file.name, key_file.name)
+2 -2
View File
@@ -31,9 +31,9 @@ smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64"
smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64"
requests==2.34.2
py7zr==1.1.4
platformdirs==4.13.3 # native esp-idf toolchain global cache dir
platformdirs==4.13.4 # native esp-idf toolchain global cache dir
ninja==1.13.2 # native host and esp8266 arduino toolchain build driver
filelock==4.2.1 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
filelock==4.2.2 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
# esp-idf >= 5.0 requires this
pyparsing >= 3.3.3
+23 -4
View File
@@ -51,6 +51,11 @@ def _ninja_logs(skip_bootloader: bool) -> list[str]:
BOOTLOADER_BYPRODUCT = re.compile(r"(^|/build/)bootloader/")
# The build step of any CMake ExternalProject. One with BUILD_ALWAYS (ESP-IDF's
# ulp_embed_binary) re-runs on every build, and ninja re-records all of that
# edge's byproducts with the new end time even though the files are untouched.
# Real work on them still changes the hashed firmware.
EXTERNAL_PROJECT_BUILD_STAMP = re.compile(r"-stamp/[^/]+-build$")
MACRO_CHANGED = (
"IDF changed __build_process_project_includes; update "
"IDF_BOOTLOADER_OVERRIDE in esphome/build_gen/espidf.py"
@@ -96,10 +101,24 @@ def _ninja_mtimes(build_path: Path, logs: list[str]) -> dict[tuple[str, str], st
for name in logs:
log = build_path / name
lines = log.read_text(encoding="utf-8").splitlines() if log.is_file() else []
for fields in (line.split("\t") for line in lines if not line.startswith("#")):
if len(fields) >= 4 and (
name != TOP_NINJA_LOG or not BOOTLOADER_BYPRODUCT.search(fields[3])
):
rows = [
fields
for fields in (
line.split("\t") for line in lines if not line.startswith("#")
)
if len(fields) >= 5
]
# Every output of an edge shares its command hash, so the stamp names
# the whole external project group.
external = {
fields[4]
for fields in rows
if EXTERNAL_PROJECT_BUILD_STAMP.search(fields[3])
}
for fields in rows:
if fields[4] in external:
continue
if name != TOP_NINJA_LOG or not BOOTLOADER_BYPRODUCT.search(fields[3]):
mtimes[name, fields[3]] = fields[2]
return mtimes
@@ -6,6 +6,7 @@ from pathlib import Path
import pytest
from esphome.components import network
from esphome.components.api import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
import esphome.config_validation as cv
@@ -134,22 +135,20 @@ def test_outgoing_connection_folds_v4_mapped_host(
assert FINAL_VALIDATE_SCHEMA(config) is not None
def test_outgoing_connection_ipv6_host_requires_ipv6(
set_core_config: SetCoreConfigCallable,
@pytest.mark.parametrize(
("host", "required", "other"),
[
("192.168.1.2", "require_ipv4", "require_ipv6"),
("::ffff:192.168.1.2", "require_ipv4", "require_ipv6"),
("fd00::1", "require_ipv6", "require_ipv4"),
],
)
def test_outgoing_connection_host_requires_its_address_family(
set_core_config: SetCoreConfigCallable, host: str, required: str, other: str
) -> None:
"""The host's family is registered with network, which resolves enable_ipv4/6 later."""
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
config = CONFIG_SCHEMA(_api_config({"host": "fd00::1"}))
with pytest.raises(cv.Invalid, match="IPv6 is not"):
FINAL_VALIDATE_SCHEMA(config)
def test_outgoing_connection_ipv6_host_passes_with_ipv6_enabled(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data=ESP32_PLATFORM_DATA,
full_config={"network": {"enable_ipv6": True}},
)
config = CONFIG_SCHEMA(_api_config({"host": "fd00::1"}))
assert FINAL_VALIDATE_SCHEMA(config) is not None
CONFIG_SCHEMA(_api_config({"host": host}))
data = network._get_data()
assert "api.outgoing_connection" in getattr(data, required)
assert "api.outgoing_connection" not in getattr(data, other)
+1
View File
@@ -55,6 +55,7 @@ def reset_core() -> Generator[None]:
"""Reset CORE after each test."""
yield
CORE.reset()
CORE.testing_mode = False # reset() leaves it, so a test enabling it would leak
@pytest.fixture(autouse=True)
@@ -10,6 +10,7 @@ from esphome.components.mqtt import (
MQTT_TRANSPORT_TCP,
MQTT_TRANSPORT_WS,
MQTT_TRANSPORT_WSS,
_validate_tls,
_validate_transport,
validate_config,
)
@@ -86,22 +87,11 @@ def test_plaintext_ws_with_certificate_rejected(
) -> None:
"""Test that pairing a CA or client certificate with unencrypted ws is rejected."""
with pytest.raises(cv.Invalid, match="unencrypted"):
validate_config(
{
CONF_TRANSPORT: MQTT_TRANSPORT_WS,
CONF_TOPIC_PREFIX: "test",
**certificate_options,
}
)
_validate_tls({CONF_TRANSPORT: MQTT_TRANSPORT_WS, **certificate_options})
def test_client_certificate_requires_tls() -> None:
"""Test that a client certificate without a CA is only accepted over wss."""
certificate = {
CONF_CLIENT_CERTIFICATE: "-----BEGIN CERTIFICATE-----",
CONF_CLIENT_CERTIFICATE_KEY: "-----BEGIN PRIVATE KEY-----",
CONF_TOPIC_PREFIX: "test",
}
with pytest.raises(cv.Invalid, match="requires 'certificate_authority'"):
validate_config({CONF_TRANSPORT: MQTT_TRANSPORT_TCP, **certificate})
validate_config({CONF_TRANSPORT: MQTT_TRANSPORT_WSS, **certificate})
def test_wss_switches_tls_on() -> None:
"""Test that wss implies tls and cannot be combined with tls false."""
assert _validate_tls({CONF_TRANSPORT: MQTT_TRANSPORT_WSS})["tls"] is True
with pytest.raises(cv.Invalid, match="'tls' cannot be false"):
_validate_tls({CONF_TRANSPORT: MQTT_TRANSPORT_WSS, "tls": False})
@@ -0,0 +1,72 @@
"""Tests for the mqtt TLS validation rules and the port default."""
import pytest
from esphome import config_validation as cv
from esphome.components.const import CONF_VERIFY_SSL
from esphome.components.mqtt import CONF_TLS, _validate_tls
from esphome.const import (
CONF_BROKER,
CONF_CERTIFICATE_AUTHORITY,
CONF_CLIENT_CERTIFICATE,
CONF_PORT,
CONF_SKIP_CERT_CN_CHECK,
CONF_TOPIC_PREFIX,
)
from esphome.types import ConfigType
BASE: ConfigType = {CONF_BROKER: "broker.local", CONF_TOPIC_PREFIX: "node"}
def _validate(**extra: object) -> ConfigType:
return _validate_tls({**BASE, **extra})
def test_plain_defaults_to_port_1883() -> None:
out = _validate()
assert out[CONF_PORT] == 1883
assert CONF_TLS not in out
@pytest.mark.parametrize(
"extra",
[
{CONF_TLS: True},
{CONF_CERTIFICATE_AUTHORITY: "cert"},
{CONF_VERIFY_SSL: False},
{CONF_CLIENT_CERTIFICATE: "cert"},
{CONF_SKIP_CERT_CN_CHECK: True},
],
)
def test_tls_defaults_to_port_8883(extra: ConfigType) -> None:
out = _validate(**extra)
assert out[CONF_TLS] is True
assert out[CONF_PORT] == 8883
def test_explicit_port_is_kept() -> None:
assert _validate(**{CONF_TLS: True, CONF_PORT: 1234})[CONF_PORT] == 1234
def test_certificate_authority_with_verify_ssl_false_rejected() -> None:
with pytest.raises(cv.Invalid, match="only valid when 'verify_ssl' is not false"):
_validate(**{CONF_CERTIFICATE_AUTHORITY: "cert", CONF_VERIFY_SSL: False})
def test_skip_cert_cn_check_with_verify_ssl_false_rejected() -> None:
with pytest.raises(cv.Invalid, match="'skip_cert_cn_check' has no effect"):
_validate(**{CONF_SKIP_CERT_CN_CHECK: True, CONF_VERIFY_SSL: False})
@pytest.mark.parametrize(
"extra",
[
{CONF_CERTIFICATE_AUTHORITY: "cert"},
{CONF_VERIFY_SSL: True},
{CONF_CLIENT_CERTIFICATE: "cert"},
{CONF_SKIP_CERT_CN_CHECK: True},
],
)
def test_tls_false_with_certificate_options_rejected(extra: ConfigType) -> None:
with pytest.raises(cv.Invalid, match="'tls' cannot be false"):
_validate(**{CONF_TLS: False, **extra})
@@ -0,0 +1,5 @@
esphome:
name: platformtest
bk72xx:
board: generic-bk7252
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: arduino
network:
enable_ipv6: true
enable_ipv4: false
@@ -0,0 +1,12 @@
esphome:
name: ardtest
esp32:
board: esp32dev
framework:
type: arduino
network:
enable_ipv6: true
api:
@@ -0,0 +1,7 @@
esphome:
name: platformtest
esp32:
board: esp32dev
framework:
type: arduino
@@ -0,0 +1,5 @@
esphome:
name: platformtest
esp32:
board: esp32dev
@@ -0,0 +1,9 @@
esphome:
name: test
esp8266:
board: esp01_1m
network:
enable_ipv6: true
enable_ipv4: false
@@ -0,0 +1,5 @@
esphome:
name: platformtest
esp8266:
board: esp01_1m
@@ -0,0 +1,9 @@
esphome:
name: hosttest
host:
network:
enable_ipv6: true
api:
@@ -0,0 +1,12 @@
esphome:
name: test
esp32:
variant: esp32c6
framework:
type: esp-idf
udp:
openthread:
tlv: 0e080000000000010000000300001035060004001fffe00208e227ac6a7f24052f0708fdb753eb517cb4d3051062b2442a928d9ea3b947a1618fc4085a030f4f70656e5468726561642d393837330102987304105330d857354330133c05e1fd7ae81a910c0402a0f7f8
@@ -0,0 +1,14 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
network:
enable_ipv6: true
wifi:
ssid: "test_ssid"
password: "test_password"
@@ -0,0 +1,10 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
network:
enable_ipv6: false
@@ -0,0 +1,10 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
network:
enable_ipv6: true
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
network:
enable_ipv6: true
enable_ipv4: false
@@ -0,0 +1,10 @@
esphome:
name: nrf52test
nrf52:
board: adafruit_itsybitsy_nrf52840
bootloader: adafruit_nrf52_sd140_v6
network:
api:
@@ -0,0 +1,6 @@
esphome:
name: platformtest
nrf52:
board: adafruit_itsybitsy_nrf52840
bootloader: adafruit_nrf52_sd140_v6
@@ -0,0 +1,13 @@
esphome:
name: test
esp32:
variant: esp32c6
framework:
type: esp-idf
network:
enable_ipv4: true
openthread:
tlv: 0e080000000000010000000300001035060004001fffe00208e227ac6a7f24052f0708fdb753eb517cb4d3051062b2442a928d9ea3b947a1618fc4085a030f4f70656e5468726561642d393837330102987304105330d857354330133c05e1fd7ae81a910c0402a0f7f8
@@ -0,0 +1,10 @@
esphome:
name: test
esp32:
variant: esp32c6
framework:
type: esp-idf
openthread:
tlv: 0e080000000000010000000300001035060004001fffe00208e227ac6a7f24052f0708fdb753eb517cb4d3051062b2442a928d9ea3b947a1618fc4085a030f4f70656e5468726561642d393837330102987304105330d857354330133c05e1fd7ae81a910c0402a0f7f8
@@ -0,0 +1,15 @@
esphome:
name: test
esp32:
variant: esp32c6
framework:
type: esp-idf
openthread:
tlv: 0e080000000000010000000300001035060004001fffe00208e227ac6a7f24052f0708fdb753eb517cb4d3051062b2442a928d9ea3b947a1618fc4085a030f4f70656e5468726561642d393837330102987304105330d857354330133c05e1fd7ae81a910c0402a0f7f8
button:
- platform: wake_on_lan
name: Wake PC
target_mac_address: AA:BB:CC:DD:EE:FF
@@ -0,0 +1,5 @@
esphome:
name: platformtest
rp2:
board: rpipicow
@@ -0,0 +1,18 @@
esphome:
name: test
esp32:
variant: esp32c6
framework:
type: esp-idf
openthread:
tlv: 0e080000000000010000000300001035060004001fffe00208e227ac6a7f24052f0708fdb753eb517cb4d3051062b2442a928d9ea3b947a1618fc4085a030f4f70656e5468726561642d393837330102987304105330d857354330133c05e1fd7ae81a910c0402a0f7f8
network:
enable_ipv4: false
tcp_uart:
- id: tcp_uart_server
role: server
port: 5020
@@ -0,0 +1,25 @@
esphome:
name: test
esp32:
variant: esp32c6
framework:
type: esp-idf
openthread:
tlv: 0e080000000000010000000300001035060004001fffe00208e227ac6a7f24052f0708fdb753eb517cb4d3051062b2442a928d9ea3b947a1618fc4085a030f4f70656e5468726561642d393837330102987304105330d857354330133c05e1fd7ae81a910c0402a0f7f8
network:
enable_ipv4: false
uart:
id: uart_bus
tx_pin: 4
rx_pin: 5
baud_rate: 115200
uart_tcp:
- id: bridge
uart_id: uart_bus
role: server
port: 502
@@ -0,0 +1,16 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
network:
enable_ipv6: true
enable_ipv4: false
udp:
id: my_udp
listen_address: 239.0.60.53
addresses: ["239.0.60.53"]
@@ -0,0 +1,18 @@
esphome:
name: test
esp32:
variant: esp32c6
framework:
type: esp-idf
openthread:
tlv: 0e080000000000010000000300001035060004001fffe00208e227ac6a7f24052f0708fdb753eb517cb4d3051062b2442a928d9ea3b947a1618fc4085a030f4f70656e5468726561642d393837330102987304105330d857354330133c05e1fd7ae81a910c0402a0f7f8
network:
enable_ipv4: false
button:
- platform: wake_on_lan
name: Wake PC
target_mac_address: AA:BB:CC:DD:EE:FF
@@ -0,0 +1,15 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
network:
enable_ipv6: true
enable_ipv4: false
wifi:
ssid: "test_ssid"
password: "test_password"
@@ -0,0 +1,191 @@
"""Tests for network component."""
from collections.abc import Callable
from pathlib import Path
import pytest
from voluptuous import Invalid
from esphome.components.network import (
CONF_ENABLE_IPV4,
_final_validate,
_get_data,
request_ipv4_off,
require_ipv4,
require_ipv6,
)
from esphome.config import load_config
from esphome.const import CONF_ENABLE_IPV6, KEY_FRAMEWORK_VERSION, PlatformFramework
from esphome.core import CORE, Version
from tests.component_tests.types import SetCoreConfigCallable
def _define_value(name: str) -> str | None:
define = next((d for d in CORE.defines if d.name == name), None)
return None if define is None else str(define.value)
def test_require_ipv4_records_name_and_passes_config_through() -> None:
"""require_ipv4() returns the config unchanged and records the caller once."""
config = {"foo": "bar"}
validator = require_ipv4("foo")
assert validator(config) is config
validator({})
assert _get_data().require_ipv4 == {"foo"}
def test_require_ipv6_and_request_ipv4_off_record_state() -> None:
"""require_ipv6() and request_ipv4_off() record their callers."""
require_ipv6("bar")({})
request_ipv4_off("bar")({})
data = _get_data()
assert data.require_ipv6 == {"bar"}
assert data.request_ipv4_off == {"bar"}
@pytest.mark.parametrize(
("config_file", "ipv4", "ipv6"),
[
("ipv6_only_ipv4_explicit_disable.yaml", "false", "true"),
("ipv6_only.yaml", "true", "true"),
("ipv4_required_with_ipv6.yaml", "true", "true"),
("wifi_only.yaml", "true", "false"),
("openthread_no_explicit_network.yaml", "false", "true"),
("openthread_ipv4_explicitly_on.yaml", "true", "true"),
("ipv6_off_nothing_required.yaml", "true", "false"),
("ipv4_and_ipv6_both_required.yaml", "true", "true"),
("openthread_with_wake_on_lan.yaml", "true", "true"),
("esp32_arduino_ipv6_no_ipv4_requirement.yaml", "true", "true"),
("host_ipv6_no_ipv4_requirement.yaml", "true", "true"),
("nrf52_network_no_openthread.yaml", "false", "true"),
],
)
def test_ip_family_defines(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
ipv4: str,
ipv6: str,
) -> None:
"""Each config resolves USE_NETWORK_IPV4 and USE_NETWORK_IPV6 as expected."""
generate_main(component_config_path(config_file))
assert _define_value("USE_NETWORK_IPV4") == ipv4
assert _define_value("USE_NETWORK_IPV6") == ipv6
@pytest.mark.parametrize(
"config_file",
[
"wifi_ipv4_disabled_rejected.yaml",
"wake_on_lan_ipv4_disabled_rejected.yaml",
"udp_ipv4_disabled_rejected.yaml",
"tcp_uart_ipv4_disabled_rejected.yaml",
"uart_tcp_ipv4_disabled_rejected.yaml",
"esp8266_ipv4_disabled_rejected.yaml",
"esp32_arduino_ipv4_disabled_rejected.yaml",
],
)
def test_require_ipv4_registration_rejects_explicit_disable(
component_config_path: Callable[[str], Path],
config_file: str,
) -> None:
"""A component that requires IPv4 rejects an explicit 'enable_ipv4: false'."""
CORE.config_path = component_config_path(config_file)
res = load_config({})
assert any("explicitly disables it" in str(err) for err in res.errors)
@pytest.mark.parametrize(
("config_file", "component"),
[
("tcp_uart_ipv4_disabled_rejected.yaml", "tcp_uart"),
("uart_tcp_ipv4_disabled_rejected.yaml", "uart_tcp"),
],
)
def test_tcp_link_users_named_in_ipv4_error(
component_config_path: Callable[[str], Path],
config_file: str,
component: str,
) -> None:
"""The IPv4 requirement registered by the socket helper names the component."""
CORE.config_path = component_config_path(config_file)
res = load_config({})
assert any(f"IPv4 is required by {component}" in str(err) for err in res.errors)
def test_final_validate_rejects_explicit_ipv6_disable_when_required() -> None:
"""An explicit 'enable_ipv6: false' conflicting with require_ipv6() is rejected."""
require_ipv6("test")({})
with pytest.raises(Invalid, match="explicitly disables it"):
_final_validate({CONF_ENABLE_IPV6: False})
def test_final_validate_accepts_explicit_ipv6_disable_when_not_required() -> None:
"""An explicit 'enable_ipv6: false' is accepted when nothing requires IPv6."""
_final_validate({CONF_ENABLE_IPV6: False})
def test_final_validate_resolves_ipv6_default_when_required(
set_core_config: SetCoreConfigCallable,
) -> None:
"""require_ipv6() turns an absent 'enable_ipv6' on."""
set_core_config(
PlatformFramework.HOST_NATIVE,
core_data={KEY_FRAMEWORK_VERSION: Version(0, 0, 0)},
)
require_ipv6("test")({})
config: dict[str, bool] = {}
_final_validate(config)
assert config[CONF_ENABLE_IPV6] is True
def test_final_validate_rejects_explicit_ipv4_disable_when_required() -> None:
"""An explicit 'enable_ipv4: false' conflicting with require_ipv4() is rejected."""
require_ipv4("test")({})
with pytest.raises(Invalid, match="explicitly disables it"):
_final_validate({CONF_ENABLE_IPV4: False})
def test_final_validate_accepts_explicit_ipv4_disable_when_not_required() -> None:
"""An explicit 'enable_ipv4: false' is accepted when IPv6 is on."""
_final_validate({CONF_ENABLE_IPV4: False, CONF_ENABLE_IPV6: True})
def test_final_validate_accepts_ipv4_default_when_required() -> None:
"""An explicit 'enable_ipv4: true' never conflicts with require_ipv4()."""
require_ipv4("test")({})
_final_validate({CONF_ENABLE_IPV4: True})
@pytest.mark.parametrize(
"config",
[{CONF_ENABLE_IPV4: False}, {CONF_ENABLE_IPV4: False, CONF_ENABLE_IPV6: False}],
)
def test_final_validate_rejects_both_address_families_disabled(
config: dict[str, bool],
) -> None:
"""Disabling IPv4 with IPv6 off leaves no IP stack and is rejected."""
with pytest.raises(Invalid, match="leaves no IP stack"):
_final_validate(config)
@pytest.mark.parametrize(
"config_file",
[
"esp8266_no_network.yaml",
"rp2_no_network.yaml",
"bk72xx_no_network.yaml",
"esp32_idf_no_network.yaml",
"esp32_arduino_no_network.yaml",
"nrf52_no_network.yaml",
],
)
def test_no_ip_defines_without_network_component(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
) -> None:
"""Platform IP requests emit no IP family defines when network is not loaded."""
generate_main(component_config_path(config_file))
assert _define_value("USE_NETWORK_IPV4") is None
assert _define_value("USE_NETWORK_IPV6") is None
@@ -182,10 +182,6 @@ def test_border_router_rcp_codegen(
lambda config, _: config[CONF_WIFI].update({CONF_AP: {}}),
"does not support Wi-Fi AP",
),
(
lambda config, _: config[CONF_NETWORK].update({CONF_ENABLE_IPV6: False}),
"requires IPv6 to be enabled",
),
],
)
def test_border_router_rejects_incompatible_config(
@@ -4,12 +4,14 @@ from ipaddress import ip_address
import pytest
from esphome.components import network
from esphome.components.udp import (
CONF_ADDRESSES,
CONF_LISTEN_ADDRESS,
_address,
_final_validate,
_listen_address,
_require_ipv6_for_ipv6_addresses,
)
import esphome.config_validation as cv
from esphome.const import CONF_ENABLE_IPV6, PlatformFramework
@@ -61,29 +63,12 @@ _RAW_LWIP_IPV6 = {
"network": {CONF_ENABLE_IPV6: True},
"socket": {"implementation": "lwip_tcp"},
}
_NO_IPV6 = "requires 'enable_ipv6: true'"
_RAW_LWIP = "not supported on this platform"
@pytest.mark.parametrize(
("addresses", "listen_address", "platform", "full_config", "message"),
[
pytest.param(
["ff12::cafe"],
"255.255.255.255",
PlatformFramework.ESP32_IDF,
{},
_NO_IPV6,
id="ipv6_destination_without_ipv6",
),
pytest.param(
["255.255.255.255"],
"ff12::cafe",
PlatformFramework.ESP32_IDF,
{},
_NO_IPV6,
id="ipv6_listen_without_ipv6",
),
pytest.param(
["ff12::cafe"],
"255.255.255.255",
@@ -120,6 +105,31 @@ def test_final_validate_rejects_unsupported_ipv6(
)
@pytest.mark.parametrize(
("addresses", "listen_address", "required"),
[
(["ff12::cafe"], "255.255.255.255", True),
(["255.255.255.255"], "ff12::cafe", True),
(["255.255.255.255"], "255.255.255.255", False),
],
)
def test_ipv6_address_requires_ipv6_from_network(
set_core_config: SetCoreConfigCallable,
addresses: list[str],
listen_address: str,
required: bool,
) -> None:
"""An IPv6 destination or listen address registers udp with network.require_ipv6()."""
set_core_config(PlatformFramework.ESP32_IDF)
_require_ipv6_for_ipv6_addresses(
{
CONF_ADDRESSES: [ip_address(a) for a in addresses],
CONF_LISTEN_ADDRESS: ip_address(listen_address),
}
)
assert ("udp" in network._get_data().require_ipv6) is required
def test_final_validate_accepts_ipv6_when_enabled(
set_core_config: SetCoreConfigCallable,
) -> None:
@@ -0,0 +1,12 @@
packages:
common: !include common.yaml
# OpenThread turns IPv4 off, so the dial path builds without any IPv4 code
openthread:
tlv: 0e080000000000010000000300001035060004001fffe00208e227ac6a7f24052f0708fdb753eb517cb4d3051062b2442a928d9ea3b947a1618fc4085a030f4f70656e5468726561642d393837330102987304105330d857354330133c05e1fd7ae81a910c0402a0f7f8
api:
outgoing_connection:
host: fd53:145f:ed22:ad81::1
port: 6054
delay: 60s
@@ -1,3 +1,6 @@
packages:
common: !include common.yaml
update: !include common-update.yaml
mqtt:
tls: true
@@ -0,0 +1,10 @@
packages:
common: !include common.yaml
mqtt:
broker: test.mosquitto.org
skip_cert_cn_check: true
certificate_authority: |
-----BEGIN CERTIFICATE-----
MIIBszCCAVoCCQDAAAAAAAAAADAKBggqhkjOPQQDAjAfMR0wGwYDVQQDDBRtcXR0
-----END CERTIFICATE-----
@@ -0,0 +1,6 @@
packages:
common: !include common.yaml
mqtt:
broker: test.mosquitto.org
verify_ssl: false
@@ -0,0 +1,5 @@
substitutions:
network_enable_ipv6: "true"
packages:
network: !include common.yaml
@@ -0,0 +1,12 @@
esp32:
framework:
log_level: DEBUG
network:
enable_ipv6: true
enable_ipv4: false
api:
openthread:
tlv: 0e080000000000010000000300001035060004001fffe00208e227ac6a7f24052f0708fdb753eb517cb4d3051062b2442a928d9ea3b947a1618fc4085a030f4f70656e5468726561642d393837330102987304105330d857354330133c05e1fd7ae81a910c0402a0f7f8
@@ -1,5 +1,6 @@
network:
enable_ipv6: true
enable_ipv4: false
openthread:
tlv: 0E080000000000010000
@@ -1,5 +1,6 @@
network:
enable_ipv6: true
enable_ipv4: true
openthread:
tlv: 0E080000000000010000
+4 -6
View File
@@ -45,7 +45,7 @@ The `InitialStateHelper` class solves a common problem in integration tests: whe
- Tracks all entities (except stateless ones like buttons)
- Swallows the first state broadcast for each entity
- Forwards all subsequent state changes to your test callback
- Provides `wait_for_initial_states()` to synchronize before test actions
- Provides `wait_for_initial_states()` to synchronize before test actions; it fails the test on timeout, naming the entities still without a state
**When to use it:**
- Any test that triggers entity state changes and needs to verify them
@@ -232,11 +232,9 @@ initial_state_helper = InitialStateHelper(entities)
# Subscribe with the wrapper that filters initial states
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
# Wait for all initial states to be broadcast
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
# Wait for all initial states to be broadcast; on timeout this fails the test
# and lists the entities that never sent one
await initial_state_helper.wait_for_initial_states()
# Now perform your test actions - on_state will only receive new changes
# ... trigger state changes ...
@@ -139,7 +139,7 @@ button:
on_press:
- lambda: |-
// Start polling now that the test has subscribed.
id(modbus_controller_1).set_update_interval(1000);
id(modbus_controller_1).set_update_interval(300);
id(modbus_controller_1).start_poller();
// Broadcast (address 0) write single register: reg 0x10 = 777 on every server.
// PDU is function code + data (no address/CRC); the hub prepends address 0 and appends CRC.
@@ -46,7 +46,7 @@ modbus:
role: client
turnaround_time: 10ms
# Short wait so the no-reply cases (address 2 below) time out well within the test window.
send_wait_time: 500ms
send_wait_time: 200ms
modbus_server:
- address: 1
@@ -75,7 +75,7 @@ modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
update_interval: 500ms
modbus_server:
- address: 1
+44 -18
View File
@@ -19,6 +19,8 @@ from aioesphomeapi import (
UserService,
)
from .wait_utils import wait_or_fail
_LOGGER = logging.getLogger(__name__)
S = TypeVar("S", bound=EntityState)
@@ -28,6 +30,7 @@ async def wait_for_state(
client: APIClient,
predicate: Callable[[EntityState], bool],
timeout: float = 5.0,
msg: str = "Timed out waiting for a matching state",
) -> EntityState:
"""Subscribe to states and wait for one matching ``predicate``.
@@ -42,21 +45,24 @@ async def wait_for_state(
predicate: Callable invoked for every received state; the first
state for which it returns ``True`` is returned.
timeout: Maximum time to wait in seconds.
msg: Failure message if no matching state arrives in time; the
states received are listed after it.
Returns:
The first state matching ``predicate``.
Raises:
asyncio.TimeoutError: If no matching state arrives within ``timeout``.
"""
future: asyncio.Future[EntityState] = asyncio.get_running_loop().create_future()
received: list[EntityState] = []
def on_state(state: EntityState) -> None:
if not future.done() and predicate(state):
if future.done():
return
received.append(state)
if predicate(state):
future.set_result(state)
client.subscribe_states(on_state)
return await asyncio.wait_for(future, timeout=timeout)
return await wait_or_fail(future, timeout, msg, received=received)
class StateWaiter:
@@ -377,15 +383,23 @@ class InitialStateHelper:
return wrapper
async def wait_for_initial_states(self, timeout: float = 5.0) -> None:
"""Wait for all initial states to be received.
"""Wait for all initial states to be received, failing the test on timeout.
Args:
timeout: Maximum time to wait in seconds
Raises:
asyncio.TimeoutError: If initial states aren't received within timeout
"""
await asyncio.wait_for(self._initial_states_received, timeout=timeout)
await wait_or_fail(
self._initial_states_received,
timeout,
"Timed out waiting for initial states",
still_waiting=lambda: sorted(
f"{type(entity).__name__} {entity.object_id}"
for entity in (
self._entities_by_id[entity_id]
for entity_id in self._wait_initial_states
)
),
)
class SensorStateCollector:
@@ -471,12 +485,29 @@ class SensorStateCollector:
)
async def wait_for_all(self, timeout: float = 3.0) -> None:
"""Wait until all sensors and binary sensors have at least one value."""
"""Wait until every tracked sensor has a value, failing the test on timeout."""
if self._all_have_values():
return
future: asyncio.Future[bool] = asyncio.get_running_loop().create_future()
self._waiters.append((self._all_have_values, future))
await asyncio.wait_for(future, timeout=timeout)
tracked = (self.sensor_states, self.binary_states, self.text_sensor_states)
await wait_or_fail(
future,
timeout,
"Timed out waiting for every sensor to report",
still_waiting=lambda: [
name
for states in tracked
for name, values in states.items()
if not values
],
received=lambda: {
name: values
for states in tracked
for name, values in states.items()
if values
},
)
def add_waiter(self, condition: Callable[[], bool]) -> asyncio.Future[bool]:
"""Add a custom waiter that resolves when condition returns True.
@@ -613,12 +644,7 @@ class SensorTracker:
)
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(self.on_state))
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
import pytest
pytest.fail("Timeout waiting for initial states")
await initial_state_helper.wait_for_initial_states()
if match_initial_states:
for state in initial_state_helper.initial_states.values():
self.on_state(state, first_pending_only=True)
+21 -3
View File
@@ -13,10 +13,15 @@ from __future__ import annotations
import asyncio
import re
from aioesphomeapi import HomeassistantServiceCall
from aioesphomeapi import HomeassistantServiceCall, SensorInfo, SensorState
import pytest
from .state_utils import assert_device_name, require_service
from .state_utils import (
assert_device_name,
require_entity,
require_service,
wait_for_state,
)
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -224,11 +229,24 @@ async def test_api_homeassistant(
client.send_home_assistant_state(entity, "step", "0.5")
# List entities and services
_, services = await client.list_entities_services()
entities, services = await client.list_entities_services()
# Find the trigger service
trigger_service = require_service(services, "trigger_all_tests")
# The complex lambda service reads the template sensor, whose first update lands at
# a random point in its interval; until then its state is NAN, which the int cast
# turns into INT_MIN. Wait for the first reading before firing the tests.
test_sensor = require_entity(entities, "test_sensor", SensorInfo, exact=True)
await wait_for_state(
client,
lambda s: (
isinstance(s, SensorState)
and s.key == test_sensor.key
and s.state == 42.0
),
)
# Execute all tests
await client.execute_service(trigger_service, {})
@@ -13,6 +13,7 @@ import asyncio
from aioesphomeapi import ButtonInfo, HomeassistantServiceCall
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name, require_only_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -25,26 +26,9 @@ async def test_api_homeassistant_action_no_subscriber(
) -> None:
"""Undeliverable actions warn in the log; actions after subscribing arrive."""
loop = asyncio.get_running_loop()
boot_warning_future = loop.create_future()
connected_warning_future = loop.create_future()
waiter = LineWaiter()
button_action_future = loop.create_future()
def check_output(line: str) -> None:
if (
not boot_warning_future.done()
and "Home Assistant action 'test.boot_action' dropped; no client connected"
in line
):
boot_warning_future.set_result(True)
if (
not connected_warning_future.done()
and "Home Assistant action 'test.connected_action' dropped; "
"client has not subscribed to actions (yet)"
in line
):
connected_warning_future.set_result(True)
service_calls: list[HomeassistantServiceCall] = []
def on_service_call(service_call: HomeassistantServiceCall) -> None:
@@ -55,16 +39,23 @@ async def test_api_homeassistant_action_no_subscriber(
):
button_action_future.set_result(service_call)
async with run_compiled(yaml_config, line_callback=check_output):
async with run_compiled(yaml_config, line_callback=waiter.callback):
# The on_boot action fires with no client connected at all.
await asyncio.wait_for(boot_warning_future, timeout=10.0)
await waiter.wait_for(
"Home Assistant action 'test.boot_action' dropped; no client connected",
timeout=10.0,
)
async with api_client_connected() as client:
await assert_device_name(client, "test-ha-action-no-subscriber")
# on_client_connected fired at authentication, before this client
# subscribed to Home Assistant actions.
await asyncio.wait_for(connected_warning_future, timeout=5.0)
await waiter.wait_for(
"Home Assistant action 'test.connected_action' dropped; "
"client has not subscribed to actions (yet)",
timeout=5.0,
)
# After subscribing, actions must be delivered normally (and the
# dropped ones must not suddenly show up).
@@ -18,6 +18,7 @@ import asyncio
from aioesphomeapi import api_pb2
import pytest
from .log_utils import LineWaiter
from .raw_api_client import MESSAGE_TYPE_OF, RawApiClient
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -71,15 +72,10 @@ async def test_api_list_entities_backpressure(
config = yaml_config.replace("# GENERATED_ACTIONS", _generated_actions())
config = config.replace("SERVER_SNDBUF", str(SERVER_SNDBUF))
pin_applied = asyncio.Event()
def _on_log_line(line: str) -> None:
if SNDBUF_PIN_LOG in line:
pin_applied.set()
async with run_compiled(config, line_callback=_on_log_line):
waiter = LineWaiter()
async with run_compiled(config, line_callback=waiter.callback):
# Fails loudly if the pin never applied
await asyncio.wait_for(pin_applied.wait(), 10)
await waiter.wait_for(SNDBUF_PIN_LOG, timeout=10)
async with RawApiClient(
unused_tcp_port, recv_buffer_size=RECV_BUFFER_SIZE
@@ -58,10 +58,7 @@ async def test_binary_sensor_autorepeat_filter(
initial_state_helper.on_state_wrapper(collector.on_state)
)
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
await initial_state_helper.wait_for_initial_states()
autorepeat_states = collector.binary_states["autorepeat_sensor"]
@@ -73,10 +73,7 @@ async def test_binary_sensor_invalidate_state(
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
# Wait for initial states
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
await initial_state_helper.wait_for_initial_states()
# Check initial state - should be missing (unknown)
initial_state = initial_state_helper.initial_states.get(binary_sensor.key)
@@ -55,10 +55,7 @@ async def test_climate_custom_fan_modes_and_presets(
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
# Wait for all initial states to be broadcast
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
await initial_state_helper.wait_for_initial_states()
# Verify enum presets are exposed (from preset: config map)
assert ClimatePreset.AWAY in test_climate.supported_presets, (
+16 -59
View File
@@ -2,13 +2,11 @@
from __future__ import annotations
import asyncio
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -18,71 +16,30 @@ async def test_crc8_helper(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test the CRC8 helper function through integration testing."""
# Track test completion with asyncio.Event
test_complete = asyncio.Event()
waiter = LineWaiter()
# Track test results
test_results = {
"dallas_maxim": False,
"sensirion": False,
"pec": False,
"parameter_equivalence": False,
"edge_cases": False,
"component_compatibility": False,
"setup_started": False,
}
def on_log_line(line):
"""Process log lines to track test progress and results."""
# Track test start
if "CRC8 Helper Function Integration Test Starting" in line:
test_results["setup_started"] = True
# Track test completion
elif "CRC8 Integration Test Complete" in line:
test_complete.set()
# Track individual test results
elif "ALL TESTS PASSED" in line:
if "Dallas/Maxim CRC8" in line:
test_results["dallas_maxim"] = True
elif "Sensirion CRC8" in line:
test_results["sensirion"] = True
elif "PEC CRC8" in line:
test_results["pec"] = True
elif "Parameter equivalence" in line:
test_results["parameter_equivalence"] = True
elif "Edge cases" in line:
test_results["edge_cases"] = True
elif "Component compatibility" in line:
test_results["component_compatibility"] = True
# Log failures for debugging
elif "TEST FAILED:" in line or "SUBTEST FAILED:" in line:
print(f"CRC8 Test Failure: {line}")
def passed(name: str) -> bool:
return any("ALL TESTS PASSED" in line and name in line for line in waiter.lines)
# Compile and run the test
async with (
run_compiled(yaml_config, line_callback=on_log_line),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Verify device info
await assert_device_name(client, "crc8-helper-test")
# Wait for tests to complete with timeout
await wait_or_fail(
test_complete, 5.0, "CRC8 integration test timed out after 5 seconds"
)
await waiter.wait_for("CRC8 Integration Test Complete", timeout=5.0)
# Verify all tests passed
assert test_results["setup_started"], "CRC8 test setup never started"
assert test_results["dallas_maxim"], "Dallas/Maxim CRC8 test failed"
assert test_results["sensirion"], "Sensirion CRC8 test failed"
assert test_results["pec"], "PEC CRC8 test failed"
assert test_results["parameter_equivalence"], (
"Parameter equivalence test failed"
)
assert test_results["edge_cases"], "Edge cases test failed"
assert test_results["component_compatibility"], (
"Component compatibility test failed"
)
assert any(
"CRC8 Helper Function Integration Test Starting" in line
for line in waiter.lines
), "CRC8 test setup never started"
assert passed("Dallas/Maxim CRC8"), "Dallas/Maxim CRC8 test failed"
assert passed("Sensirion CRC8"), "Sensirion CRC8 test failed"
assert passed("PEC CRC8"), "PEC CRC8 test failed"
assert passed("Parameter equivalence"), "Parameter equivalence test failed"
assert passed("Edge cases"), "Edge cases test failed"
assert passed("Component compatibility"), "Component compatibility test failed"
@@ -79,10 +79,7 @@ async def test_entity_missing_state(
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
await initial_state_helper.wait_for_initial_states()
# Nothing has published yet, so all of them must report unknown
for info in stateful:
@@ -45,7 +45,8 @@ async def test_host_mode_climate_basic_state(
and state.target_humidity == 20.0
)
try:
await wait_for_state(client, is_default_state)
except TimeoutError:
pytest.fail("Climate did not converge to expected default state")
await wait_for_state(
client,
is_default_state,
msg="Climate did not converge to expected default state",
)
@@ -46,10 +46,7 @@ async def test_host_mode_climate_control(
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
# Wait for all initial states to be broadcast
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
await initial_state_helper.wait_for_initial_states()
# Adjust setpoints
client.climate_command(
@@ -79,10 +79,7 @@ async def test_light_constant_brightness(
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
await initial_state_helper.wait_for_initial_states()
async def send_and_wait(
light_key: int, timeout: float = 5.0, **kwargs: Any
+5 -18
View File
@@ -3,13 +3,11 @@
Tests that on_lock and on_unlock triggers work correctly.
"""
import asyncio
import pytest
from .log_utils import LineWaiter
from .state_utils import require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -19,21 +17,10 @@ async def test_lock_automations(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test lock on_lock and on_unlock triggers."""
loop = asyncio.get_running_loop()
# Futures for log line detection
on_lock_future: asyncio.Future[bool] = loop.create_future()
on_unlock_future: asyncio.Future[bool] = loop.create_future()
def check_output(line: str) -> None:
"""Check log output for trigger messages."""
if "TRIGGER: on_lock fired" in line and not on_lock_future.done():
on_lock_future.set_result(True)
elif "TRIGGER: on_unlock fired" in line and not on_unlock_future.done():
on_unlock_future.set_result(True)
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=check_output),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# Import here to avoid import errors when aioesphomeapi is not installed
@@ -46,9 +33,9 @@ async def test_lock_automations(
# Test 1: Lock - should trigger on_lock
client.lock_command(key=lock.key, command=LockCommand.LOCK)
await wait_or_fail(on_lock_future, 5.0, "on_lock trigger did not fire")
await waiter.wait_for("TRIGGER: on_lock fired", timeout=5.0)
# Test 2: Unlock - should trigger on_unlock
client.lock_command(key=lock.key, command=LockCommand.UNLOCK)
await wait_or_fail(on_unlock_future, 5.0, "on_unlock trigger did not fire")
await waiter.wait_for("TRIGGER: on_unlock fired", timeout=5.0)
+10 -14
View File
@@ -2,11 +2,9 @@
from __future__ import annotations
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@@ -20,24 +18,22 @@ async def test_micros_to_millis(
) -> None:
"""Test that micros_to_millis matches reference uint64 division."""
all_passed = asyncio.Event()
failures: list[str] = []
waiter = LineWaiter()
def on_log_line(line: str) -> None:
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
if "ALL_PASSED" in clean_line:
all_passed.set()
elif "FAILED" in clean_line and "[MTM" in clean_line:
failures.append(clean_line)
def failures() -> list[str]:
return [line for line in waiter.lines if "FAILED" in line and "[MTM" in line]
async with (
run_compiled(yaml_config, line_callback=on_log_line),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
await assert_device_name(client, "micros-to-millis-test")
await wait_or_fail(
all_passed, 2.0, "micros_to_millis test timed out", failures=failures
waiter.wait_for("ALL_PASSED"),
2.0,
"micros_to_millis test timed out",
failures=failures,
)
assert not failures, f"micros_to_millis failures: {failures}"
assert not failures(), f"micros_to_millis failures: {failures()}"
@@ -7,6 +7,7 @@ import struct
import pytest
from .log_utils import LineWaiter
from .online_image_utils import BMP_IMAGE
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -72,24 +73,7 @@ async def test_online_image_stalled_download(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Esphome should report an error when a download can never complete."""
loop = asyncio.get_running_loop()
expected_logs = {
"truncated_reason": "Download ended with 1 bytes the decoder could not use",
"truncated_error": "download error: truncated",
"large_header_reason": "Image does not fit in the 256 byte download buffer",
"large_header_error": "download error: large_header",
"empty_reason": "Server sent an empty response",
"empty_error": "download error: empty",
}
log_futures = {key: loop.create_future() for key in expected_logs}
finished_lines: list[str] = []
def check_output(line: str) -> None:
for key, text in expected_logs.items():
if text in line and not log_futures[key].done():
log_futures[key].set_result(True)
if "download finished" in line:
finished_lines.append(line)
waiter = LineWaiter()
server = await asyncio.start_server(handle_http, "127.0.0.1", 0)
http_server_port = server.sockets[0].getsockname()[1]
@@ -97,7 +81,7 @@ async def test_online_image_stalled_download(
async with (
server,
run_compiled(config, line_callback=check_output),
run_compiled(config, line_callback=waiter.callback),
api_client_connected() as client,
):
_, services = await client.list_entities_services()
@@ -105,17 +89,23 @@ async def test_online_image_stalled_download(
await client.execute_service(actions["fetch_truncated"], {})
async with asyncio.timeout(2.0):
await log_futures["truncated_reason"]
await log_futures["truncated_error"]
await waiter.wait_for_each(
"Download ended with 1 bytes the decoder could not use",
"download error: truncated",
)
await client.execute_service(actions["fetch_large_header"], {})
async with asyncio.timeout(2.0):
await log_futures["large_header_reason"]
await log_futures["large_header_error"]
await waiter.wait_for_each(
"Image does not fit in the 256 byte download buffer",
"download error: large_header",
)
await client.execute_service(actions["fetch_empty"], {})
async with asyncio.timeout(2.0):
await log_futures["empty_reason"]
await log_futures["empty_error"]
await waiter.wait_for_each(
"Server sent an empty response", "download error: empty"
)
finished_lines = [line for line in waiter.lines if "download finished" in line]
assert not finished_lines, f"Unexpected successful download: {finished_lines}"
@@ -6,11 +6,9 @@ components) are in completely separate matching namespaces, even when
the underlying uint32_t values are identical and on the same component.
"""
import asyncio
import re
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@@ -23,103 +21,59 @@ async def test_scheduler_internal_id_no_collision(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that internal and numeric IDs with same value don't collide."""
# Test 1: Both types fire independently with same ID
internal_timeout_0_fired = asyncio.Event()
numeric_timeout_0_fired = asyncio.Event()
waiter = LineWaiter()
# Test 2: Cancelling numeric doesn't cancel internal
internal_timeout_1_survived = asyncio.Event()
numeric_timeout_1_error = asyncio.Event()
# Test 3: Cancelling internal doesn't cancel numeric
numeric_timeout_2_survived = asyncio.Event()
internal_timeout_2_error = asyncio.Event()
# Test 4: Both interval types fire independently
internal_interval_3_done = asyncio.Event()
numeric_interval_3_done = asyncio.Event()
# Test 5: String name doesn't collide with internal ID
string_timeout_fired = asyncio.Event()
internal_timeout_10_fired = asyncio.Event()
# Completion
all_tests_complete = asyncio.Event()
def on_log_line(line: str) -> None:
clean_line = re.sub(r"\x1b\[[0-9;]*m", "", line)
if "Internal timeout 0 fired" in clean_line:
internal_timeout_0_fired.set()
elif "Numeric timeout 0 fired" in clean_line:
numeric_timeout_0_fired.set()
elif "Internal timeout 1 survived cancel" in clean_line:
internal_timeout_1_survived.set()
elif "ERROR: Numeric timeout 1 should have been cancelled" in clean_line:
numeric_timeout_1_error.set()
elif "Numeric timeout 2 survived cancel" in clean_line:
numeric_timeout_2_survived.set()
elif "ERROR: Internal timeout 2 should have been cancelled" in clean_line:
internal_timeout_2_error.set()
elif "Internal interval 3 fired twice" in clean_line:
internal_interval_3_done.set()
elif "Numeric interval 3 fired twice" in clean_line:
numeric_interval_3_done.set()
elif "String timeout collision_test fired" in clean_line:
string_timeout_fired.set()
elif "Internal timeout 10 fired" in clean_line:
internal_timeout_10_fired.set()
elif "All collision tests complete" in clean_line:
all_tests_complete.set()
def seen(text: str) -> bool:
return any(text in line for line in waiter.lines)
async with (
run_compiled(yaml_config, line_callback=on_log_line),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
await assert_device_name(client, "scheduler-internal-id-test")
await wait_or_fail(
all_tests_complete,
waiter.wait_for("All collision tests complete"),
5.0,
"Not all collision tests completed within 5 seconds",
)
# Test 1: Both timeout types with same ID 0 must fire
assert internal_timeout_0_fired.is_set(), (
assert seen("Internal timeout 0 fired"), (
"Internal timeout with ID 0 should have fired"
)
assert numeric_timeout_0_fired.is_set(), (
assert seen("Numeric timeout 0 fired"), (
"Numeric timeout with ID 0 should have fired"
)
# Test 2: Cancelling numeric ID must NOT cancel internal ID
assert internal_timeout_1_survived.is_set(), (
assert seen("Internal timeout 1 survived cancel"), (
"Internal timeout 1 should survive cancellation of numeric timeout 1"
)
assert not numeric_timeout_1_error.is_set(), (
assert not seen("ERROR: Numeric timeout 1 should have been cancelled"), (
"Numeric timeout 1 should have been cancelled"
)
# Test 3: Cancelling internal ID must NOT cancel numeric ID
assert numeric_timeout_2_survived.is_set(), (
assert seen("Numeric timeout 2 survived cancel"), (
"Numeric timeout 2 should survive cancellation of internal timeout 2"
)
assert not internal_timeout_2_error.is_set(), (
assert not seen("ERROR: Internal timeout 2 should have been cancelled"), (
"Internal timeout 2 should have been cancelled"
)
# Test 4: Both interval types with same ID must fire independently
assert internal_interval_3_done.is_set(), (
assert seen("Internal interval 3 fired twice"), (
"Internal interval 3 should have fired at least twice"
)
assert numeric_interval_3_done.is_set(), (
assert seen("Numeric interval 3 fired twice"), (
"Numeric interval 3 should have fired at least twice"
)
# Test 5: String name and internal ID don't collide
assert string_timeout_fired.is_set(), (
assert seen("String timeout collision_test fired"), (
"String timeout 'collision_test' should have fired"
)
assert internal_timeout_10_fired.is_set(), (
assert seen("Internal timeout 10 fired"), (
"Internal timeout 10 should have fired alongside string timeout"
)
@@ -13,13 +13,11 @@ and the device stays responsive to API calls.
from __future__ import annotations
import asyncio
import pytest
from .log_utils import LineWaiter
from .state_utils import assert_device_name
from .types import APIClientConnectedFactory, RunCompiledFunction
from .wait_utils import wait_or_fail
@pytest.mark.asyncio
@@ -29,18 +27,10 @@ async def test_scheduler_interval_zero_coerced(
api_client_connected: APIClientConnectedFactory,
) -> None:
"""interval=0ms must be coerced to 1ms and not starve the main loop."""
loop = asyncio.get_running_loop()
reached_50: asyncio.Future[None] = loop.create_future()
coerce_warning: asyncio.Future[None] = loop.create_future()
def on_log_line(line: str) -> None:
if "ZERO_INTERVAL_50_FIRES_REACHED" in line and not reached_50.done():
reached_50.set_result(None)
if "would spin main loop" in line and not coerce_warning.done():
coerce_warning.set_result(None)
waiter = LineWaiter()
async with (
run_compiled(yaml_config, line_callback=on_log_line),
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
# The API-client connection itself is evidence that the main loop
@@ -49,19 +39,10 @@ async def test_scheduler_interval_zero_coerced(
await assert_device_name(client, "sched-interval-zero")
# Coerce warning must fire at registration
await wait_or_fail(
coerce_warning,
5.0,
"Expected coerce warning 'would spin main loop' not seen",
)
await waiter.wait_for("would spin main loop", timeout=5.0)
# The coerced 1ms interval should fire 50 times quickly — this
# confirms the callback actually runs (not just registered) and the
# scheduler yields back to the main loop each time.
await wait_or_fail(
reached_50,
5.0,
"Coerced interval=0→1ms did not reach 50 fires within 5s, "
"which would indicate either the coercion failed or the "
"main loop is still being starved.",
)
# A timeout means the coercion failed or the main loop is still starved.
await waiter.wait_for("ZERO_INTERVAL_50_FIRES_REACHED", timeout=5.0)

Some files were not shown because too many files have changed in this diff Show More