mirror of
https://github.com/esphome/esphome.git
synced 2026-10-22 05:46:12 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92f9a97869 | ||
|
|
90ecc02630 | ||
|
|
73b08d732c | ||
|
|
63b610bf17 | ||
|
|
62c9e05541 | ||
|
|
671ac3e0bc | ||
|
|
6671ded245 | ||
|
|
9010c4bb2e | ||
|
|
e0a48ff683 | ||
|
|
e3d7b957a7 | ||
|
|
9ab230f98d | ||
|
|
35883270fe | ||
|
|
da37e89b9d | ||
|
|
32bbf55f2a | ||
|
|
16759c848f | ||
|
|
244e419f82 | ||
|
|
04f53f0ab4 | ||
|
|
9eef601f99 | ||
|
|
454dbe59db |
+1
-1
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user