mirror of
https://github.com/esphome/esphome.git
synced 2026-06-29 12:06:13 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cbbd64577 | |||
| a618ee11b4 | |||
| 6251c26cc6 | |||
| 4fbe0d87ec | |||
| 24d8e99c50 | |||
| 14b6a0ede1 | |||
| 1793ca5eac | |||
| 62e19bcb27 | |||
| 84d1c34c28 | |||
| f78cbf9200 | |||
| eb711381d3 | |||
| 9a1daa5247 | |||
| f3d61ca3e1 | |||
| 29dfd820c6 | |||
| 8bc5b97298 | |||
| 7a64163c4f | |||
| dfe14f9c3a | |||
| 26cf373ae7 | |||
| 94ccddf176 | |||
| 2ec24505d0 | |||
| 4f7faa7712 | |||
| b3dcaac262 | |||
| ee118d384a | |||
| 8d36167e11 | |||
| 6d559a32df | |||
| bf0d31b3ab | |||
| d8ffb732b7 |
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.6.2
|
||||
PROJECT_NUMBER = 2026.6.3
|
||||
|
||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
||||
# for a project that appears at the top of each page and should give viewer a
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ RUN \
|
||||
-r /requirements.txt
|
||||
|
||||
# Install the ESPHome Device Builder dashboard.
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.0.12
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.0.21
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
+22
-5
@@ -1470,12 +1470,29 @@ _LEGACY_REDACTION_REMOVAL = "2026.12.0"
|
||||
|
||||
def _redact_with_legacy_fallback(output: str) -> str:
|
||||
unmarked: set[str] = set()
|
||||
# Track the top-level ``substitutions:`` block. Its keys are arbitrary
|
||||
# user-chosen names with no schema validator, so the ``cv.sensitive(...)``
|
||||
# migration named in the warning can't be applied to them. Their values are
|
||||
# still redacted, but emitting the (unactionable) deprecation warning would
|
||||
# only confuse users.
|
||||
in_substitutions = False
|
||||
|
||||
def _replace(m: re.Match[str]) -> str:
|
||||
unmarked.add(m.group("key"))
|
||||
return f"{m.group('key')}: \\033[8m{m.group('val')}\\033[28m"
|
||||
|
||||
output = _LEGACY_REDACTION_RE.sub(_replace, output)
|
||||
lines = output.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
# A non-indented, non-blank line is a top-level key that opens or
|
||||
# closes the substitutions block.
|
||||
if line and not line[0].isspace():
|
||||
in_substitutions = line.startswith(f"{CONF_SUBSTITUTIONS}:")
|
||||
m = _LEGACY_REDACTION_RE.search(line)
|
||||
if m is None:
|
||||
continue
|
||||
if not in_substitutions:
|
||||
unmarked.add(m.group("key"))
|
||||
lines[i] = (
|
||||
f"{line[: m.start()]}{m.group('key')}: "
|
||||
f"\\033[8m{m.group('val')}\\033[28m{line[m.end() :]}"
|
||||
)
|
||||
output = "\n".join(lines)
|
||||
for key in sorted(unmarked):
|
||||
_LOGGER.warning(
|
||||
"Field '%s' is being redacted by a legacy substring heuristic. "
|
||||
|
||||
@@ -2316,14 +2316,14 @@ async def to_code(config):
|
||||
add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", True)
|
||||
add_idf_sdkconfig_option(
|
||||
"CONFIG_SECURE_BOOT_SIGNING_KEY",
|
||||
str(signed_ota[CONF_SIGNING_KEY].resolve()),
|
||||
signed_ota[CONF_SIGNING_KEY].resolve().as_posix(),
|
||||
)
|
||||
else:
|
||||
# Public key mode — verification only, external signing required
|
||||
add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", False)
|
||||
add_idf_sdkconfig_option(
|
||||
"CONFIG_SECURE_BOOT_VERIFICATION_KEY",
|
||||
str(signed_ota[CONF_VERIFICATION_KEY].resolve()),
|
||||
signed_ota[CONF_VERIFICATION_KEY].resolve().as_posix(),
|
||||
)
|
||||
|
||||
cg.add_define("USE_OTA_SIGNED_VERIFICATION")
|
||||
|
||||
@@ -77,13 +77,15 @@ template<typename... Ts> class BLECharacteristicSetValueAction : public Action<T
|
||||
// Set initial value
|
||||
this->parent_->set_value(this->buffer_.value(x...));
|
||||
// Set the listener for read events
|
||||
this->parent_->on_read([this, x...](uint16_t id) {
|
||||
// ``mutable`` keeps by-copy captures non-const for triggers passing args by reference
|
||||
// (e.g. climate on_control's ClimateCall&). See #17142.
|
||||
this->parent_->on_read([this, x...](uint16_t id) mutable {
|
||||
// Set the value of the characteristic every time it is read
|
||||
this->parent_->set_value(this->buffer_.value(x...));
|
||||
});
|
||||
// Set the listener in the global manager so only one BLECharacteristicSetValueAction is set for each characteristic
|
||||
BLECharacteristicSetValueActionManager::get_instance()->set_listener(
|
||||
this->parent_, [this, x...]() { this->parent_->set_value(this->buffer_.value(x...)); });
|
||||
this->parent_, [this, x...]() mutable { this->parent_->set_value(this->buffer_.value(x...)); });
|
||||
}
|
||||
|
||||
protected:
|
||||
|
||||
@@ -28,9 +28,6 @@ namespace esphome::espnow {
|
||||
|
||||
static constexpr const char *TAG = "espnow";
|
||||
|
||||
static const esp_err_t CONFIG_ESPNOW_WAKE_WINDOW = 50;
|
||||
static const esp_err_t CONFIG_ESPNOW_WAKE_INTERVAL = 100;
|
||||
|
||||
ESPNowComponent *global_esp_now = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
static const LogString *espnow_error_to_str(esp_err_t error) {
|
||||
@@ -204,11 +201,6 @@ void ESPNowComponent::enable_() {
|
||||
|
||||
esp_wifi_get_mac(WIFI_IF_STA, this->own_address_);
|
||||
|
||||
#ifdef USE_DEEP_SLEEP
|
||||
esp_now_set_wake_window(CONFIG_ESPNOW_WAKE_WINDOW);
|
||||
esp_wifi_connectionless_module_set_wake_interval(CONFIG_ESPNOW_WAKE_INTERVAL);
|
||||
#endif
|
||||
|
||||
this->state_ = ESPNOW_STATE_ENABLED;
|
||||
|
||||
for (auto peer : this->peers_) {
|
||||
@@ -311,7 +303,9 @@ void ESPNowComponent::loop() {
|
||||
ESP_LOGV(TAG, ">>> [%s] %s", addr_buf, LOG_STR_ARG(espnow_error_to_str(packet->packet_.sent.status)));
|
||||
#endif
|
||||
if (this->current_send_packet_ != nullptr) {
|
||||
this->current_send_packet_->callback_(packet->packet_.sent.status);
|
||||
if (this->current_send_packet_->callback_ != nullptr) {
|
||||
this->current_send_packet_->callback_(packet->packet_.sent.status);
|
||||
}
|
||||
this->send_packet_pool_.release(this->current_send_packet_);
|
||||
this->current_send_packet_ = nullptr; // Reset current packet after sending
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import light, output
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_OUTPUT_ID, CONF_PIN_A, CONF_PIN_B
|
||||
from esphome.const import CONF_OUTPUT_ID, CONF_PIN_A, CONF_PIN_B, CONF_UPDATE_INTERVAL
|
||||
|
||||
from .. import hbridge_ns
|
||||
|
||||
CODEOWNERS = ["@DotNetDann"]
|
||||
|
||||
HBridgeLightOutput = hbridge_ns.class_(
|
||||
"HBridgeLightOutput", cg.Component, light.LightOutput
|
||||
"HBridgeLightOutput", cg.PollingComponent, light.LightOutput
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend(
|
||||
@@ -16,12 +16,14 @@ CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend(
|
||||
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(HBridgeLightOutput),
|
||||
cv.Required(CONF_PIN_A): cv.use_id(output.FloatOutput),
|
||||
cv.Required(CONF_PIN_B): cv.use_id(output.FloatOutput),
|
||||
cv.Optional(CONF_UPDATE_INTERVAL, default="8ms"): cv.update_interval,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_OUTPUT_ID])
|
||||
cg.add(var.set_update_interval(config.pop(CONF_UPDATE_INTERVAL)))
|
||||
await cg.register_component(var, config)
|
||||
await light.register_light(var, config)
|
||||
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
#include "esphome/components/light/light_output.h"
|
||||
#include "esphome/components/output/float_output.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::hbridge {
|
||||
|
||||
class HBridgeLightOutput : public Component, public light::LightOutput {
|
||||
class HBridgeLightOutput final : public PollingComponent, public light::LightOutput {
|
||||
public:
|
||||
void set_pina_pin(output::FloatOutput *pina_pin) { this->pina_pin_ = pina_pin; }
|
||||
void set_pinb_pin(output::FloatOutput *pinb_pin) { this->pinb_pin_ = pinb_pin; }
|
||||
@@ -20,11 +19,12 @@ class HBridgeLightOutput : public Component, public light::LightOutput {
|
||||
return traits;
|
||||
}
|
||||
|
||||
void setup() override { this->disable_loop(); }
|
||||
void setup() override { this->stop_poller(); }
|
||||
|
||||
void loop() override {
|
||||
// Only called when both channels are active — alternate H-bridge direction
|
||||
// each iteration to multiplex cold and warm white.
|
||||
void update() override {
|
||||
// Flip the H-bridge direction to multiplex cold/warm white. update_interval must stay
|
||||
// slower than the output's PWM period (flipping faster collapses the output onto one
|
||||
// channel) but fast enough to avoid flicker (issue #17030).
|
||||
if (!this->forward_direction_) {
|
||||
this->pina_pin_->set_level(this->pina_duty_);
|
||||
this->pinb_pin_->set_level(0);
|
||||
@@ -46,13 +46,17 @@ class HBridgeLightOutput : public Component, public light::LightOutput {
|
||||
this->pinb_duty_ = new_pinb;
|
||||
|
||||
if (new_pina != 0.0f && new_pinb != 0.0f) {
|
||||
// Both channels active — need loop to alternate H-bridge direction
|
||||
this->high_freq_.start();
|
||||
this->enable_loop();
|
||||
// Both channels active — multiplex the H-bridge direction via the poller.
|
||||
if (!this->multiplexing_) {
|
||||
this->multiplexing_ = true;
|
||||
this->start_poller();
|
||||
}
|
||||
} else {
|
||||
// Zero or one channel active — drive pins directly, no multiplexing needed
|
||||
this->high_freq_.stop();
|
||||
this->disable_loop();
|
||||
// Zero or one channel active — drive pins directly, no multiplexing needed.
|
||||
if (this->multiplexing_) {
|
||||
this->multiplexing_ = false;
|
||||
this->stop_poller();
|
||||
}
|
||||
this->pina_pin_->set_level(new_pina);
|
||||
this->pinb_pin_->set_level(new_pinb);
|
||||
}
|
||||
@@ -64,7 +68,7 @@ class HBridgeLightOutput : public Component, public light::LightOutput {
|
||||
float pina_duty_{0};
|
||||
float pinb_duty_{0};
|
||||
bool forward_direction_{false};
|
||||
HighFrequencyLoopRequester high_freq_;
|
||||
bool multiplexing_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::hbridge
|
||||
|
||||
@@ -120,6 +120,7 @@ CSCON = 0xF0
|
||||
PWCTR6 = 0xF6
|
||||
ADJCTL3 = 0xF7
|
||||
PAGESEL = 0xFE
|
||||
PAGESEL1 = 0xFF
|
||||
|
||||
MADCTL_MY = 0x80 # Bit 7 Bottom to top
|
||||
MADCTL_MX = 0x40 # Bit 6 Right to left
|
||||
@@ -387,6 +388,16 @@ class DriverChip:
|
||||
return {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY}
|
||||
return {CONF_MIRROR_X, CONF_MIRROR_Y}
|
||||
|
||||
def has_hardware_transform(self, config) -> bool:
|
||||
"""
|
||||
Check if the model supports hardware transforms for the given configuration.
|
||||
"""
|
||||
return config.get(CONF_TRANSFORM) != CONF_DISABLED and self.transforms == {
|
||||
CONF_MIRROR_X,
|
||||
CONF_MIRROR_Y,
|
||||
CONF_SWAP_XY,
|
||||
}
|
||||
|
||||
def option(self, name, fallback=False) -> cv.Optional:
|
||||
return cv.Optional(name, default=self.get_default(name, fallback))
|
||||
|
||||
@@ -417,10 +428,15 @@ class DriverChip:
|
||||
:return: A tuple (width, height, offset_width, offset_height, pad_width, pad_height).
|
||||
"""
|
||||
|
||||
transform = self.get_transform(config)
|
||||
if CONF_DIMENSIONS in config:
|
||||
# Explicit dimensions, just use as is
|
||||
dimensions = config[CONF_DIMENSIONS]
|
||||
if isinstance(dimensions, dict):
|
||||
native_width = self.get_default(CONF_NATIVE_WIDTH, 0)
|
||||
native_height = self.get_default(CONF_NATIVE_HEIGHT, 0)
|
||||
if transform.get(CONF_SWAP_XY) is True:
|
||||
native_width, native_height = native_height, native_width
|
||||
width = dimensions[CONF_WIDTH]
|
||||
height = dimensions[CONF_HEIGHT]
|
||||
offset_width = dimensions[CONF_OFFSET_WIDTH]
|
||||
@@ -428,23 +444,19 @@ class DriverChip:
|
||||
if CONF_PAD_WIDTH in dimensions:
|
||||
pad_width = dimensions[CONF_PAD_WIDTH]
|
||||
native_width = width + offset_width + pad_width
|
||||
elif native_width == 0:
|
||||
pad_width = 0
|
||||
native_width = width + offset_width
|
||||
else:
|
||||
native_width = self.get_default(CONF_NATIVE_WIDTH, 0)
|
||||
if native_width == 0:
|
||||
pad_width = 0
|
||||
native_width = width + offset_width
|
||||
else:
|
||||
pad_width = native_width - width - offset_width
|
||||
pad_width = native_width - width - offset_width
|
||||
if CONF_PAD_HEIGHT in dimensions:
|
||||
pad_height = dimensions[CONF_PAD_HEIGHT]
|
||||
native_height = height + offset_height + pad_height
|
||||
elif native_height == 0:
|
||||
pad_height = 0
|
||||
native_height = height + offset_height
|
||||
else:
|
||||
native_height = self.get_default(CONF_NATIVE_HEIGHT, 0)
|
||||
if native_height == 0:
|
||||
pad_height = 0
|
||||
native_height = height + offset_height
|
||||
else:
|
||||
pad_height = native_height - height - offset_height
|
||||
pad_height = native_height - height - offset_height
|
||||
if (
|
||||
pad_width + offset_width >= native_width
|
||||
or pad_height + offset_height >= native_height
|
||||
@@ -460,7 +472,6 @@ class DriverChip:
|
||||
return width, height, 0, 0, 0, 0
|
||||
|
||||
# Default dimensions, use model defaults
|
||||
transform = self.get_transform(config)
|
||||
|
||||
width = self.get_default(CONF_WIDTH)
|
||||
height = self.get_default(CONF_HEIGHT)
|
||||
|
||||
@@ -17,6 +17,8 @@ from esphome.components.mipi import (
|
||||
MADCTL,
|
||||
MODE_BGR,
|
||||
MODE_RGB,
|
||||
PAGESEL,
|
||||
PAGESEL1,
|
||||
PIXFMT,
|
||||
DriverChip,
|
||||
dimension_schema,
|
||||
@@ -172,13 +174,19 @@ def model_schema(config):
|
||||
if bus_mode == TYPE_SINGLE:
|
||||
other_options.append(CONF_SPI_16)
|
||||
# Calculate default SPI mode. Mode3 for octal bus or single bus with no cs pin, mode0 otherwise.
|
||||
spi_mode = model.get_default(CONF_SPI_MODE)
|
||||
spi_mode = (
|
||||
cv.UNDEFINED if CONF_SPI_MODE in config else model.get_default(CONF_SPI_MODE)
|
||||
)
|
||||
if not spi_mode:
|
||||
if bus_mode == TYPE_OCTAL or (
|
||||
bus_mode == TYPE_SINGLE
|
||||
and not config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN))
|
||||
and config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN)) is False
|
||||
):
|
||||
spi_mode = "MODE3"
|
||||
if bus_mode == TYPE_SINGLE:
|
||||
LOGGER.warning(
|
||||
"No SPI mode specified, defaulting to MODE3 due to lack of CS pin. If you experience issues, try setting SPI mode explicitly to MODE0 or MODE3."
|
||||
)
|
||||
else:
|
||||
spi_mode = "MODE0"
|
||||
|
||||
@@ -270,14 +278,16 @@ def customise_schema(config):
|
||||
# Check for invalid combinations of MADCTL config
|
||||
if init_sequence := config.get(CONF_INIT_SEQUENCE):
|
||||
commands = [x[0] for x in init_sequence]
|
||||
if MADCTL in commands and CONF_TRANSFORM in config:
|
||||
raise cv.Invalid(
|
||||
f"transform is not supported when MADCTL ({MADCTL:#X}) is in the init sequence"
|
||||
)
|
||||
if PIXFMT in commands:
|
||||
raise cv.Invalid(
|
||||
f"PIXFMT ({PIXFMT:#X}) should not be in the init sequence, it will be set automatically"
|
||||
)
|
||||
# If there is page swapping, we can't rely on recognising common commands
|
||||
if PAGESEL not in commands and PAGESEL1 not in commands:
|
||||
if MADCTL in commands and CONF_TRANSFORM in config:
|
||||
raise cv.Invalid(
|
||||
f"transform is not supported when MADCTL ({MADCTL:#X}) is in the init sequence"
|
||||
)
|
||||
if PIXFMT in commands:
|
||||
raise cv.Invalid(
|
||||
f"PIXFMT ({PIXFMT:#X}) should not be in the init sequence, it will be set automatically"
|
||||
)
|
||||
|
||||
if bus_mode == TYPE_QUAD and CONF_DC_PIN in config:
|
||||
raise cv.Invalid("DC pin is not supported in quad mode")
|
||||
@@ -285,13 +295,7 @@ def customise_schema(config):
|
||||
raise cv.Invalid(f"DC pin is required in {bus_mode} mode")
|
||||
denominator(config)
|
||||
model = MODELS[config[CONF_MODEL]]
|
||||
has_hardware_transform = config.get(
|
||||
CONF_TRANSFORM
|
||||
) != CONF_DISABLED and model.transforms == {
|
||||
CONF_MIRROR_X,
|
||||
CONF_MIRROR_Y,
|
||||
CONF_SWAP_XY,
|
||||
}
|
||||
has_hardware_transform = model.has_hardware_transform(config)
|
||||
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
|
||||
model.get_dimensions(config, not has_hardware_transform)
|
||||
)
|
||||
@@ -356,13 +360,7 @@ def get_instance(config):
|
||||
:return: type, template arguments
|
||||
"""
|
||||
model = MODELS[config[CONF_MODEL]]
|
||||
has_hardware_transform = config.get(
|
||||
CONF_TRANSFORM
|
||||
) != CONF_DISABLED and model.transforms == {
|
||||
CONF_MIRROR_X,
|
||||
CONF_MIRROR_Y,
|
||||
CONF_SWAP_XY,
|
||||
}
|
||||
has_hardware_transform = model.has_hardware_transform(config)
|
||||
width, height, offset_width, offset_height, pad_width, pad_height = (
|
||||
model.get_dimensions(config, not has_hardware_transform)
|
||||
)
|
||||
|
||||
@@ -176,7 +176,6 @@ class MipiSpi : public display::Display,
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
auto arg_byte = vec[index];
|
||||
switch (cmd) {
|
||||
case SLEEP_OUT: {
|
||||
// are we ready, boots?
|
||||
@@ -187,13 +186,6 @@ class MipiSpi : public display::Display,
|
||||
}
|
||||
} break;
|
||||
|
||||
case INVERT_ON:
|
||||
this->invert_colors_ = true;
|
||||
break;
|
||||
case BRIGHTNESS:
|
||||
this->brightness_ = arg_byte;
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ struct IPAddress {
|
||||
IPAddress(const std::string &in_address) { ipaddr_aton(in_address.c_str(), &ip_addr_); }
|
||||
IPAddress(ip4_addr_t *other_ip) {
|
||||
memcpy((void *) &ip_addr_, (void *) other_ip, sizeof(ip4_addr_t));
|
||||
#if USE_ESP32 && LWIP_IPV6
|
||||
#if LWIP_IPV6
|
||||
ip_addr_.type = IPADDR_TYPE_V4;
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -7,9 +7,13 @@ static const char *const TAG = "opentherm.output";
|
||||
|
||||
void opentherm::OpenthermOutput::write_state(float state) {
|
||||
ESP_LOGD(TAG, "Received state: %.2f. Min value: %.2f, max value: %.2f", state, min_value_, max_value_);
|
||||
this->state = state < 0.003 && this->zero_means_zero_
|
||||
? 0.0
|
||||
: clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_);
|
||||
#ifdef USE_OUTPUT_FLOAT_POWER_SCALING
|
||||
bool zero_means_zero = this->zero_means_zero_;
|
||||
#else
|
||||
bool zero_means_zero = false;
|
||||
#endif
|
||||
this->state =
|
||||
state < 0.003 && zero_means_zero ? 0.0 : clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_);
|
||||
this->has_state_ = true;
|
||||
ESP_LOGD(TAG, "Output %s set to %.2f", this->id_, this->state);
|
||||
}
|
||||
|
||||
@@ -218,9 +218,18 @@ network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() {
|
||||
return {};
|
||||
network::IPAddresses addresses;
|
||||
uint8_t index = 0;
|
||||
// addrList enumerates all lwIP netifs, including the SoftAP / fallback hotspot. Filter out
|
||||
// the AP address so the STA address is reported as the device IP (see issue #17181).
|
||||
struct ip_info ap_ip {};
|
||||
wifi_get_ip_info(SOFTAP_IF, &ap_ip);
|
||||
network::IPAddress ap_address(&ap_ip.ip);
|
||||
bool filter_ap = ap_address.is_set();
|
||||
for (auto &addr : addrList) {
|
||||
network::IPAddress ip(addr.ipFromNetifNum());
|
||||
if (filter_ap && ip == ap_address)
|
||||
continue;
|
||||
assert(index < addresses.size());
|
||||
addresses[index++] = addr.ipFromNetifNum();
|
||||
addresses[index++] = ip;
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
|
||||
@@ -179,12 +179,53 @@ void WiFiComponent::wifi_lazy_init_() {
|
||||
// nor re-register the default WiFi handlers.
|
||||
if (s_sta_netif == nullptr)
|
||||
s_sta_netif = esp_netif_create_default_wifi_sta();
|
||||
if (s_sta_netif == nullptr) {
|
||||
// Allocation failed; leave wifi_initialized_ false so a later enable() retries.
|
||||
ESP_LOGE(TAG, "esp_netif_create_default_wifi_sta failed");
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef USE_WIFI_AP
|
||||
if (s_ap_netif == nullptr)
|
||||
s_ap_netif = esp_netif_create_default_wifi_ap();
|
||||
#endif // USE_WIFI_AP
|
||||
|
||||
// The WiFi driver was started (e.g. by ESP-NOW with the wifi component disabled at
|
||||
// boot) before our STA netif existed. The default WIFI_EVENT_STA_START handler
|
||||
// therefore ran with no netif and never called esp_wifi_register_if_rxcb() -- the
|
||||
// only thing that points the driver's RX path at a netif (it sets
|
||||
// s_wifi_netifs[WIFI_IF_STA]). A bare esp_netif_action_start() would stop the
|
||||
// immediate crash (#17232) but leaves RX unbound, so the first association
|
||||
// associates at L2 yet never receives DHCP replies and times out (#17239). Restart
|
||||
// the driver now that the netif exists so STA_START re-runs the default handler and
|
||||
// wires RX correctly. ESP-NOW survives the stop/start (its peer state persists).
|
||||
// This also matches a self-retry: if esp_wifi_set_storage() below failed on a
|
||||
// previous wifi_lazy_init_() it returned without setting wifi_initialized_, and
|
||||
// esp_wifi_init() has since run, so esp_wifi_get_mode() now succeeds here too.
|
||||
wifi_mode_t mode;
|
||||
if (esp_wifi_get_mode(&mode) == ESP_OK) {
|
||||
ESP_LOGD(TAG, "WiFi driver already started without STA netif; restarting to bind it");
|
||||
esp_err_t err = esp_wifi_stop();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "esp_wifi_stop failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
// Re-apply RAM storage; the normal init path does this, but it is skipped on
|
||||
// the self-retry case above, which would otherwise let the driver persist
|
||||
// credentials to NVS for the rest of the boot.
|
||||
err = esp_wifi_set_storage(WIFI_STORAGE_RAM);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "esp_wifi_set_storage failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
err = esp_wifi_start();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_wifi_start failed: %s", esp_err_to_name(err));
|
||||
return;
|
||||
}
|
||||
s_wifi_started = true;
|
||||
this->wifi_initialized_ = true;
|
||||
return;
|
||||
}
|
||||
|
||||
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||
if (global_preferences->nvs_handle == 0) {
|
||||
ESP_LOGW(TAG, "starting wifi without nvs");
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
|
||||
from esphome.enum import StrEnum
|
||||
|
||||
__version__ = "2026.6.2"
|
||||
__version__ = "2026.6.3"
|
||||
|
||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||
|
||||
+11
-1
@@ -407,6 +407,17 @@ def preload_core_config(config, result) -> str:
|
||||
|
||||
CORE.name = conf[CONF_NAME]
|
||||
CORE.friendly_name = conf.get(CONF_FRIENDLY_NAME)
|
||||
# Record the node's area name now (substitutions are already resolved at this
|
||||
# point). storage.json is written before to_code() runs, so deferring this to
|
||||
# to_code() left the area as null in storage.json. The value here is the raw
|
||||
# post-substitution form (a plain string or a {name: ...} mapping). Assign
|
||||
# unconditionally (like friendly_name) so a config without an area never
|
||||
# inherits a stale value from a previous load in a long-running process, and
|
||||
# use .get() so a malformed mapping surfaces later as a proper validation
|
||||
# error rather than a KeyError here. to_code() sets it again from the
|
||||
# validated config, which yields the same name.
|
||||
area = conf.get(CONF_AREA)
|
||||
CORE.area = area.get(CONF_NAME) if isinstance(area, dict) else area
|
||||
CORE.data[KEY_CORE] = {}
|
||||
|
||||
if CONF_BUILD_PATH not in conf:
|
||||
@@ -760,7 +771,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
# Process areas
|
||||
all_areas: list[dict[str, str | core.ID]] = []
|
||||
if CONF_AREA in config:
|
||||
CORE.area = config[CONF_AREA][CONF_NAME]
|
||||
all_areas.append(config[CONF_AREA])
|
||||
all_areas.extend(config[CONF_AREAS])
|
||||
|
||||
|
||||
+15
-10
@@ -337,16 +337,19 @@ print(".".join([str(x) for x in sys.version_info]))
|
||||
|
||||
|
||||
_GITHUB_SHORTHAND_RE = re.compile(
|
||||
r"^github://([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+?)(?:@([a-zA-Z0-9\-_.\./]+))?$"
|
||||
r"^github://([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+?)(?:[@#]([a-zA-Z0-9\-_.\./]+))?$"
|
||||
)
|
||||
_GITHUB_HTTPS_RE = re.compile(
|
||||
r"^(https://github\.com/[a-zA-Z0-9\-]+/[a-zA-Z0-9\-\._]+?\.git)(?:@([a-zA-Z0-9\-_.\./]+))?$"
|
||||
r"^(https://github\.com/[a-zA-Z0-9\-]+/[a-zA-Z0-9\-\._]+?\.git)(?:[@#]([a-zA-Z0-9\-_.\./]+))?$"
|
||||
)
|
||||
|
||||
|
||||
def _parse_git_source(source_url: str) -> tuple[str, str | None] | None:
|
||||
"""Return ``(url, ref)`` for ``github://owner/repo[@ref]`` or
|
||||
``https://github.com/owner/repo.git[@ref]``, else ``None``."""
|
||||
``https://github.com/owner/repo.git[@ref]``, else ``None``.
|
||||
|
||||
The ref may be separated with ``@`` or ``#``; ``#`` matches the PlatformIO
|
||||
convention used for ``platform_version`` URLs."""
|
||||
if m := _GITHUB_SHORTHAND_RE.match(source_url):
|
||||
owner, repo, ref = m.group(1), m.group(2), m.group(3)
|
||||
# Tolerate a trailing ".git" on the shorthand repo so the
|
||||
@@ -609,14 +612,16 @@ def _check_esphome_idf_framework_install(
|
||||
install = True
|
||||
if _check_stamp(env_stamp_file, stamp_info):
|
||||
_LOGGER.info("Checking ESP-IDF %s framework installation ...", version)
|
||||
cmd = [
|
||||
get_system_python_path(),
|
||||
str(idf_tools_path),
|
||||
"--non-interactive",
|
||||
"check",
|
||||
]
|
||||
if run_command_ok(cmd, msg=f"ESP-IDF {version} check", env=env):
|
||||
# Validate via the managed tool-path resolution, not ``idf_tools.py check``:
|
||||
# ``check`` probes tools on the system PATH and aborts if any fail to run (e.g. a
|
||||
# broken Homebrew openocd), which forced a toolchain reinstall on every build.
|
||||
try:
|
||||
_get_idf_tool_paths(framework_path, env)
|
||||
install = False
|
||||
except RuntimeError as err:
|
||||
_LOGGER.debug(
|
||||
"ESP-IDF %s tool resolution failed, reinstalling: %s", version, err
|
||||
)
|
||||
|
||||
# 4. Install framework tools if not installed or needs update
|
||||
if install:
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ esptool==5.3.0
|
||||
click==8.3.3
|
||||
esphome-dashboard==20260425.0
|
||||
aioesphomeapi==45.3.1
|
||||
zeroconf==0.149.16
|
||||
zeroconf==0.150.0
|
||||
puremagic==1.30
|
||||
ruamel.yaml==0.19.1 # dashboard_import
|
||||
ruamel.yaml.clib==0.2.15 # dashboard_import
|
||||
|
||||
@@ -306,6 +306,50 @@ def test_all_predefined_models(
|
||||
run_schema_validation(config)
|
||||
|
||||
|
||||
def test_single_bus_no_cs_no_mode_warns(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A single-bus display with no CS pin and no explicit SPI mode warns about MODE3 default."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
run_schema_validation({"model": "ili9488", "dc_pin": 14})
|
||||
|
||||
assert "defaulting to MODE3 due to lack of CS pin" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config",
|
||||
[
|
||||
pytest.param(
|
||||
{"model": "ili9488", "dc_pin": 14, "cs_pin": 0},
|
||||
id="cs_pin_provided",
|
||||
),
|
||||
pytest.param(
|
||||
{"model": "ili9488", "dc_pin": 14, "spi_mode": "mode0"},
|
||||
id="spi_mode_provided",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_single_bus_no_mode_warning_suppressed(
|
||||
config: ConfigType,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""No MODE3 warning when a CS pin or an explicit SPI mode is provided."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
run_schema_validation(config)
|
||||
|
||||
assert "defaulting to MODE3 due to lack of CS pin" not in caplog.text
|
||||
|
||||
|
||||
def test_native_generation(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_fixture_path: Callable[[str], Path],
|
||||
|
||||
@@ -13,6 +13,16 @@ from esphome.components.esp32 import (
|
||||
VARIANT_ESP32,
|
||||
VARIANT_ESP32S3,
|
||||
)
|
||||
from esphome.components.mipi import (
|
||||
CONF_DIMENSIONS,
|
||||
CONF_HEIGHT,
|
||||
CONF_MIRROR_X,
|
||||
CONF_MIRROR_Y,
|
||||
CONF_OFFSET_HEIGHT,
|
||||
CONF_OFFSET_WIDTH,
|
||||
CONF_SWAP_XY,
|
||||
CONF_WIDTH,
|
||||
)
|
||||
from esphome.components.mipi_spi.display import (
|
||||
CONFIG_SCHEMA,
|
||||
FINAL_VALIDATE_SCHEMA,
|
||||
@@ -20,7 +30,13 @@ from esphome.components.mipi_spi.display import (
|
||||
get_instance,
|
||||
)
|
||||
from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE
|
||||
from esphome.const import CONF_CS_PIN, CONF_DC_PIN, PlatformFramework
|
||||
from esphome.const import (
|
||||
CONF_CS_PIN,
|
||||
CONF_DC_PIN,
|
||||
CONF_DISABLED,
|
||||
CONF_TRANSFORM,
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
@@ -432,3 +448,152 @@ class TestUserConfiguredPadding:
|
||||
assert config["dimensions"]["width"] == 240
|
||||
assert config["dimensions"]["height"] == 240
|
||||
assert config["dimensions"]["pad_height"] == 16
|
||||
|
||||
|
||||
class TestHasHardwareTransform:
|
||||
"""Test DriverChip.has_hardware_transform()."""
|
||||
|
||||
def test_full_transform_model_without_transform_key(self) -> None:
|
||||
"""A model supporting swap_xy uses a hardware transform by default."""
|
||||
model = MODELS["ST7789V"]
|
||||
assert model.has_hardware_transform({}) is True
|
||||
|
||||
def test_full_transform_model_with_transform_dict(self) -> None:
|
||||
"""A configured (non-disabled) transform still uses the hardware path."""
|
||||
model = MODELS["ST7789V"]
|
||||
assert (
|
||||
model.has_hardware_transform({CONF_TRANSFORM: {CONF_SWAP_XY: True}}) is True
|
||||
)
|
||||
|
||||
def test_full_transform_model_with_transform_disabled(self) -> None:
|
||||
"""Disabling the transform falls back to software transforms."""
|
||||
model = MODELS["ST7789V"]
|
||||
assert model.has_hardware_transform({CONF_TRANSFORM: CONF_DISABLED}) is False
|
||||
|
||||
def test_model_without_swap_xy_support(self) -> None:
|
||||
"""Models that cannot swap axes never use a hardware transform."""
|
||||
# AXS15231 only supports mirror_x/mirror_y, not swap_xy.
|
||||
model = MODELS["AXS15231"]
|
||||
assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y}
|
||||
assert model.has_hardware_transform({}) is False
|
||||
|
||||
|
||||
class TestSwapXYNativeDimensions:
|
||||
"""Test that native dimensions are swapped when a swap_xy transform is active.
|
||||
|
||||
When explicit dimensions are given in the swapped (rotated) orientation and the
|
||||
model applies a hardware swap_xy transform, the model's native_width/native_height
|
||||
defaults must be swapped to match, otherwise padding is computed against the wrong
|
||||
axis and validation fails.
|
||||
"""
|
||||
|
||||
def test_explicit_swapped_dimensions_with_swap_xy_transform(
|
||||
self,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""Explicit landscape dimensions on a portrait-native model with swap_xy."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
# ST7789V is natively 240x320 (portrait). Provide landscape dimensions
|
||||
# together with a swap_xy transform.
|
||||
model = MODELS["ST7789V"]
|
||||
assert model.get_default("native_width") == 240
|
||||
assert model.get_default("native_height") == 320
|
||||
|
||||
config = {
|
||||
"model": "ST7789V",
|
||||
CONF_DIMENSIONS: {
|
||||
CONF_WIDTH: 320,
|
||||
CONF_HEIGHT: 240,
|
||||
CONF_OFFSET_WIDTH: 0,
|
||||
CONF_OFFSET_HEIGHT: 0,
|
||||
},
|
||||
CONF_TRANSFORM: {
|
||||
CONF_SWAP_XY: True,
|
||||
CONF_MIRROR_X: False,
|
||||
CONF_MIRROR_Y: False,
|
||||
},
|
||||
}
|
||||
|
||||
# swap=False because the buffer is laid out in the requested orientation.
|
||||
width, height, offset_w, offset_h, pad_w, pad_h = model.get_dimensions(
|
||||
config, swap=False
|
||||
)
|
||||
# Native dims are swapped to 320x240, so padding works out to zero rather
|
||||
# than going negative (which previously raised "Invalid offsets").
|
||||
assert (width, height) == (320, 240)
|
||||
assert (offset_w, offset_h) == (0, 0)
|
||||
assert (pad_w, pad_h) == (0, 0)
|
||||
|
||||
def test_explicit_dimensions_without_swap_keeps_native_orientation(
|
||||
self,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""Without swap_xy the native dimensions keep their original orientation."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
model = MODELS["ST7789V"]
|
||||
config = {
|
||||
"model": "ST7789V",
|
||||
CONF_DIMENSIONS: {
|
||||
CONF_WIDTH: 240,
|
||||
CONF_HEIGHT: 320,
|
||||
CONF_OFFSET_WIDTH: 0,
|
||||
CONF_OFFSET_HEIGHT: 0,
|
||||
},
|
||||
CONF_TRANSFORM: {
|
||||
CONF_SWAP_XY: False,
|
||||
CONF_MIRROR_X: False,
|
||||
CONF_MIRROR_Y: False,
|
||||
},
|
||||
}
|
||||
|
||||
width, height, offset_w, offset_h, pad_w, pad_h = model.get_dimensions(
|
||||
config, swap=False
|
||||
)
|
||||
assert (width, height) == (240, 320)
|
||||
assert (offset_w, offset_h) == (0, 0)
|
||||
assert (pad_w, pad_h) == (0, 0)
|
||||
|
||||
def test_swapped_native_dimensions_compute_padding(
|
||||
self,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""Padding is derived from the swapped native size when swap_xy is active."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
# ILI9341 is natively 240x320. Request a 300x240 area in landscape; the
|
||||
# swapped native size is 320x240, leaving 20px of horizontal padding.
|
||||
model = MODELS["ILI9341"]
|
||||
assert model.get_default("native_width") == 240
|
||||
assert model.get_default("native_height") == 320
|
||||
|
||||
config = {
|
||||
"model": "ILI9341",
|
||||
CONF_DIMENSIONS: {
|
||||
CONF_WIDTH: 300,
|
||||
CONF_HEIGHT: 240,
|
||||
CONF_OFFSET_WIDTH: 0,
|
||||
CONF_OFFSET_HEIGHT: 0,
|
||||
},
|
||||
CONF_TRANSFORM: {
|
||||
CONF_SWAP_XY: True,
|
||||
CONF_MIRROR_X: False,
|
||||
CONF_MIRROR_Y: False,
|
||||
},
|
||||
}
|
||||
|
||||
width, height, _, _, pad_w, pad_h = model.get_dimensions(config, swap=False)
|
||||
assert (width, height) == (300, 240)
|
||||
# native_width swapped to 320 -> pad_width = 320 - 300 - 0 = 20
|
||||
assert pad_w == 20
|
||||
assert pad_h == 0
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Combined tests for PAGESEL/PAGESEL1 behaviour with MADCTL/PIXFMT.
|
||||
|
||||
Covers both the suppression behaviour (when PAGESEL or PAGESEL1 are present)
|
||||
and the error behaviour when neither page-selection command is present.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
|
||||
from esphome.components.mipi import MADCTL, PAGESEL, PAGESEL1, PIXFMT
|
||||
from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import PlatformFramework
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
|
||||
def validated_config(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Run schema + final validation and return the validated config."""
|
||||
cfg = CONFIG_SCHEMA(config)
|
||||
FINAL_VALIDATE_SCHEMA(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
def test_madctl_error_suppressed_when_pagesel_present(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""If PAGESEL is present in init_sequence, MADCTL presence must not raise an error."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
cfg = {
|
||||
"model": "custom",
|
||||
"dc_pin": 18,
|
||||
"dimensions": {"width": 320, "height": 240},
|
||||
"transform": {"mirror_x": True, "mirror_y": True, "swap_xy": False},
|
||||
"init_sequence": [[PAGESEL, 0x00], [MADCTL, 0x01]],
|
||||
}
|
||||
|
||||
# Should not raise
|
||||
validated = validated_config(cfg)
|
||||
assert validated is not None
|
||||
|
||||
|
||||
def test_pixfmt_error_suppressed_when_pagesel1_present(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""If PAGESEL1 is present in init_sequence, PIXFMT presence must not raise an error."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
cfg = {
|
||||
"model": "custom",
|
||||
"dc_pin": 18,
|
||||
"dimensions": {"width": 320, "height": 240},
|
||||
"init_sequence": [[PAGESEL1, 0x00], [PIXFMT, 0x01]],
|
||||
}
|
||||
|
||||
# Should not raise
|
||||
validated = validated_config(cfg)
|
||||
assert validated is not None
|
||||
|
||||
|
||||
def test_madctl_raises_without_pagesel(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""MADCTL in the init_sequence should raise when a transform is configured and
|
||||
no PAGESEL/PAGESEL1 is present.
|
||||
"""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
cfg: dict[str, Any] = {
|
||||
"model": "custom",
|
||||
"dc_pin": 18,
|
||||
"dimensions": {"width": 320, "height": 240},
|
||||
"transform": {"mirror_x": True, "mirror_y": True, "swap_xy": False},
|
||||
"init_sequence": [[MADCTL, 0x01]],
|
||||
}
|
||||
|
||||
with pytest.raises(cv.Invalid, match=r"MADCTL .* in the init sequence"):
|
||||
CONFIG_SCHEMA(cfg)
|
||||
|
||||
|
||||
def test_pixfmt_raises_without_pagesel1(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""PIXFMT in the init_sequence should raise when no PAGESEL/PAGESEL1 is present."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
cfg: dict[str, Any] = {
|
||||
"model": "custom",
|
||||
"dc_pin": 18,
|
||||
"dimensions": {"width": 320, "height": 240},
|
||||
"init_sequence": [[PIXFMT, 0x01]],
|
||||
}
|
||||
|
||||
with pytest.raises(
|
||||
cv.Invalid, match=r"PIXFMT .* should not be in the init sequence"
|
||||
):
|
||||
CONFIG_SCHEMA(cfg)
|
||||
@@ -77,3 +77,34 @@ esp32_ble_server:
|
||||
id: test_change_descriptor
|
||||
value:
|
||||
data: [0x01, 0x02, 0x03]
|
||||
|
||||
# Regression test for #17142: the set_value action used from a trigger that passes
|
||||
# its argument by reference (climate on_control supplies ClimateCall&) previously
|
||||
# failed to compile.
|
||||
sensor:
|
||||
- platform: template
|
||||
id: ble_test_temp
|
||||
lambda: "return 20.0;"
|
||||
|
||||
output:
|
||||
- platform: template
|
||||
id: ble_test_output
|
||||
type: float
|
||||
write_action:
|
||||
- logger.log: "out"
|
||||
|
||||
climate:
|
||||
- platform: pid
|
||||
name: "BLE Test Climate"
|
||||
id: ble_test_climate
|
||||
sensor: ble_test_temp
|
||||
default_target_temperature: 20
|
||||
heat_output: ble_test_output
|
||||
control_parameters:
|
||||
kp: 0.1
|
||||
ki: 0.001
|
||||
kd: 0.1
|
||||
on_control:
|
||||
- ble_server.characteristic.set_value:
|
||||
id: test_notify_characteristic
|
||||
value: !lambda "return std::vector<uint8_t>{0, 1, 2};"
|
||||
|
||||
@@ -152,15 +152,21 @@ def test_multiple_areas_and_devices(yaml_file: Callable[[str], str]) -> None:
|
||||
("multiple_areas_devices.yaml", "Main Area"),
|
||||
],
|
||||
)
|
||||
async def test_to_code_records_core_area(
|
||||
async def test_core_area_recorded_at_config_load(
|
||||
yaml_file: Callable[[str], Path],
|
||||
fixture: str,
|
||||
expected_area: str,
|
||||
) -> None:
|
||||
"""``to_code`` records the node's area name on CORE for StorageJSON."""
|
||||
"""The node's area name is recorded on CORE for StorageJSON.
|
||||
|
||||
It must be set during config load (preload_core_config), not deferred to
|
||||
to_code(): storage.json is written before to_code() runs, so a late
|
||||
assignment left the area as null in storage.json (regression #17218).
|
||||
"""
|
||||
result = load_config_from_fixture(yaml_file, fixture, FIXTURES_DIR)
|
||||
assert result is not None
|
||||
assert CORE.area is None
|
||||
# Recorded already at config-load time, before any code generation.
|
||||
assert CORE.area == expected_area
|
||||
|
||||
with patch("esphome.core.config.cg") as mock_cg:
|
||||
mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock()
|
||||
@@ -170,6 +176,23 @@ async def test_to_code_records_core_area(
|
||||
assert CORE.area == expected_area
|
||||
|
||||
|
||||
def test_config_load_without_area_clears_stale_core_area(
|
||||
yaml_file: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""A config without an area must not inherit a stale CORE.area.
|
||||
|
||||
preload_core_config assigns CORE.area unconditionally, so the area from a
|
||||
previous load in a long-running process cannot leak into a config that
|
||||
omits it.
|
||||
"""
|
||||
CORE.area = "Stale Area From Previous Load"
|
||||
result = load_config_from_fixture(
|
||||
yaml_file, "device_without_area.yaml", FIXTURES_DIR
|
||||
)
|
||||
assert result is not None
|
||||
assert CORE.area is None
|
||||
|
||||
|
||||
def test_legacy_string_area(
|
||||
yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
|
||||
@@ -64,6 +64,19 @@ from esphome.framework_helpers import _tar_extract_all, get_python_env_executabl
|
||||
"https://github.com/espressif/esp-idf.git@v6.0.1",
|
||||
("https://github.com/espressif/esp-idf.git", "v6.0.1"),
|
||||
),
|
||||
# '#' ref separator (PlatformIO/git-web convention) works on both forms
|
||||
(
|
||||
"https://github.com/espressif/esp-idf.git#release/v6.1",
|
||||
("https://github.com/espressif/esp-idf.git", "release/v6.1"),
|
||||
),
|
||||
(
|
||||
"github://espressif/esp-idf#release/v6.1",
|
||||
("https://github.com/espressif/esp-idf.git", "release/v6.1"),
|
||||
),
|
||||
(
|
||||
"github://espressif/esp-idf.git#master",
|
||||
("https://github.com/espressif/esp-idf.git", "master"),
|
||||
),
|
||||
# Tolerate a trailing ".git" on the shorthand so the user doesn't
|
||||
# silently end up with a doubled "...esp-idf.git.git" URL.
|
||||
(
|
||||
@@ -298,6 +311,9 @@ def espidf_mocks(setup_core: Path):
|
||||
patch("esphome.espidf.framework.archive_extract_all") as extract,
|
||||
patch("esphome.espidf.framework.create_venv") as venv,
|
||||
patch("esphome.espidf.framework.run_command_ok", return_value=True) as run_ok,
|
||||
patch(
|
||||
"esphome.espidf.framework._get_idf_tool_paths", return_value=([], {})
|
||||
) as tool_paths,
|
||||
patch("esphome.espidf.framework._clone_idf_with_submodules") as clone,
|
||||
patch("esphome.espidf.framework._write_idf_version_txt"),
|
||||
patch("esphome.espidf.framework._patch_tools_json_for_linux_arm64"),
|
||||
@@ -308,7 +324,12 @@ def espidf_mocks(setup_core: Path):
|
||||
patch("esphome.espidf.framework.get_system_python_path", return_value="python"),
|
||||
):
|
||||
yield SimpleNamespace(
|
||||
download=download, extract=extract, venv=venv, run_ok=run_ok, clone=clone
|
||||
download=download,
|
||||
extract=extract,
|
||||
venv=venv,
|
||||
run_ok=run_ok,
|
||||
tool_paths=tool_paths,
|
||||
clone=clone,
|
||||
)
|
||||
|
||||
|
||||
@@ -403,10 +424,10 @@ def test_check_esp_idf_install_stamp_mismatch_reinstalls(
|
||||
def test_check_esp_idf_install_check_command_failure_reinstalls(
|
||||
espidf_mocks: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A failing idf_tools check reinstalls tools (marker present, no re-extract)."""
|
||||
"""A failing tool-path resolution reinstalls tools (marker present, no re-extract)."""
|
||||
_mark_installed()
|
||||
# idf_tools check fails -> install stays True; the later installs succeed.
|
||||
espidf_mocks.run_ok.side_effect = [False, True, True, True]
|
||||
# Managed tool resolution fails -> install stays True; the later installs succeed.
|
||||
espidf_mocks.tool_paths.side_effect = RuntimeError("missing ESP-IDF tool")
|
||||
check_esp_idf_install(_IDF_VERSION, features=["fb"])
|
||||
|
||||
espidf_mocks.extract.assert_not_called()
|
||||
|
||||
@@ -437,6 +437,36 @@ def test_redact_with_legacy_fallback__does_not_match_fragment_as_suffix(
|
||||
assert not any("legacy substring" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_redact_with_legacy_fallback__substitutions_redacted_without_warning(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Substitution keys have no schema validator, so their values are still
|
||||
redacted but the unactionable cv.sensitive migration warning is suppressed
|
||||
(see issue #17225)."""
|
||||
text = "substitutions:\n ota_password: apolloautomation\nesphome:\n name: x\n"
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
|
||||
out = _redact_with_legacy_fallback(text)
|
||||
assert "ota_password: \\033[8mapolloautomation\\033[28m" in out
|
||||
assert not any("legacy substring" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_redact_with_legacy_fallback__warns_after_substitutions_block(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The suppression ends at the next top-level key; a sensitive-shaped field
|
||||
in a later block (a real schema field) still warns, while the substitution
|
||||
above it does not."""
|
||||
text = (
|
||||
"substitutions:\n ota_password: apolloautomation\nwifi:\n password: hunter2\n"
|
||||
)
|
||||
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
|
||||
out = _redact_with_legacy_fallback(text)
|
||||
assert "ota_password: \\033[8mapolloautomation\\033[28m" in out
|
||||
assert "password: \\033[8mhunter2\\033[28m" in out
|
||||
assert any("'password'" in rec.message for rec in caplog.records)
|
||||
assert not any("ota_password" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_command_config__invokes_legacy_fallback_when_redacting(
|
||||
tmp_path: Path, capfd: CaptureFixture[str]
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user