mirror of
https://github.com/esphome/esphome.git
synced 2026-07-01 04:56:09 +00:00
Compare commits
66 Commits
2026.6.0b3
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fc73731a8 | |||
| e47feace11 | |||
| 4472d3b61b | |||
| 06c5bcbc66 | |||
| 6c44775bf5 | |||
| b127363fa0 | |||
| 782b58bbeb | |||
| 5de508ad8c | |||
| 054c8ba485 | |||
| 3b2be021b2 | |||
| 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 | |||
| 9ab2a573ab | |||
| 99d1c4eb69 | |||
| b079be756f | |||
| 039a1f063e | |||
| 2354165e41 | |||
| f5697b0ae5 | |||
| fe794a26e8 | |||
| 8d77051b9a | |||
| 9534ab2a19 | |||
| 1b1c8d767d | |||
| e3d68deef9 | |||
| 20cd6a1771 | |||
| d27229a1c7 | |||
| 129aebe8f4 | |||
| a84ad7b1f8 | |||
| 86096b96f5 | |||
| ac5a28301a | |||
| e2157a3d26 | |||
| d934fb3910 | |||
| c4076ec8a9 | |||
| 9ac22f9244 | |||
| 9e7b3e0330 | |||
| 2abe272867 | |||
| db6b9166f4 | |||
| 7ab95ddcb1 | |||
| cdd2bfbc60 | |||
| 41f7f8cccb | |||
| 045de436ba | |||
| 24e276c3f9 |
+1
-1
@@ -1 +1 @@
|
||||
34f6ce4a4775acf8c7201778f114b191f78269f232b67f01fed920f0cdf73686
|
||||
72f02816e288b68ff4ef4b3d6fb66432c893b187a80ad3ebaa29afa443ff9ea6
|
||||
|
||||
@@ -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.0b3
|
||||
PROJECT_NUMBER = 2026.6.4
|
||||
|
||||
# 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.1
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.0.23
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
@@ -49,7 +49,21 @@ if bashio::fs.directory_exists '/config/esphome/.esphome'; then
|
||||
rm -rf /config/esphome/.esphome
|
||||
fi
|
||||
|
||||
# Only signal device-builder to expose the public LAN port when the operator
|
||||
# mapped port 6052, matching the legacy dashboard where nginx listened on the
|
||||
# fixed port 6052 only when it was configured. We use the mapping purely as a
|
||||
# presence check and don't forward the published value; device-builder binds
|
||||
# its default port 6052 (the fixed container port, as the legacy
|
||||
# "listen 6052" did). --ha-addon-allow-public is inert on its own: the no-auth
|
||||
# gate is the DISABLE_HA_AUTHENTICATION env var set above, so both opt-ins are
|
||||
# required to bind 6052 unauthenticated; either alone stays ingress-only.
|
||||
set --
|
||||
if bashio::var.has_value "$(bashio::addon.port 6052)"; then
|
||||
set -- --ha-addon-allow-public
|
||||
fi
|
||||
|
||||
bashio::log.info "Starting ESPHome Device Builder..."
|
||||
exec esphome-device-builder /config/esphome \
|
||||
--ha-addon \
|
||||
--ingress-port "$(bashio::addon.ingress_port)"
|
||||
--ingress-port "$(bashio::addon.ingress_port)" \
|
||||
"$@"
|
||||
|
||||
+43
-5
@@ -504,6 +504,12 @@ def has_resolvable_address() -> bool:
|
||||
if has_ip_address():
|
||||
return True
|
||||
|
||||
# The dashboard pre-resolves the device and passes the IPs via
|
||||
# --mdns-address-cache/--dns-address-cache; honor a cached address even when the
|
||||
# device has mDNS disabled (e.g. a .local host found via ping).
|
||||
if CORE.address_cache and CORE.address_cache.get_addresses(CORE.address):
|
||||
return True
|
||||
|
||||
if has_mdns():
|
||||
return True
|
||||
|
||||
@@ -1464,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. "
|
||||
@@ -1765,6 +1788,21 @@ def command_update_all(args: ArgsProtocol) -> int | None:
|
||||
def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
import json
|
||||
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
# Native ESP-IDF derives idedata from the build's compile_commands.json,
|
||||
# so the configuration must already be compiled.
|
||||
from esphome.espidf import toolchain as espidf_toolchain
|
||||
|
||||
idedata = espidf_toolchain.get_idedata()
|
||||
if idedata is None:
|
||||
_LOGGER.error(
|
||||
"No idedata available; compile the configuration first",
|
||||
)
|
||||
return 1
|
||||
|
||||
print(json.dumps(idedata, indent=2) + "\n")
|
||||
return 0
|
||||
|
||||
if not CORE.using_toolchain_platformio:
|
||||
_LOGGER.error(
|
||||
"The idedata command is not compatible with %s toolchain",
|
||||
|
||||
@@ -66,15 +66,18 @@ float ADCSensor::sample() {
|
||||
}
|
||||
|
||||
uint8_t pin = this->pin_->get_pin();
|
||||
#ifdef CYW43_USES_VSYS_PIN
|
||||
#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI)
|
||||
if (pin == PICO_VSYS_PIN) {
|
||||
// Measuring VSYS on Raspberry Pico W needs to be wrapped with
|
||||
// `cyw43_thread_enter()`/`cyw43_thread_exit()` as discussed in
|
||||
// https://github.com/raspberrypi/pico-sdk/issues/1222, since Wifi chip and
|
||||
// VSYS ADC both share GPIO29
|
||||
// VSYS ADC both share GPIO29.
|
||||
// The USE_WIFI guard is required because CYW43_USES_VSYS_PIN can be defined
|
||||
// transitively (e.g. via lwip_wrap.h) even on non-WiFi boards where the CYW43
|
||||
// driver is never initialized; calling cyw43_thread_enter() there hard-faults.
|
||||
cyw43_thread_enter();
|
||||
}
|
||||
#endif // CYW43_USES_VSYS_PIN
|
||||
#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI)
|
||||
|
||||
adc_gpio_init(pin);
|
||||
adc_select_input(pin - 26);
|
||||
@@ -84,11 +87,11 @@ float ADCSensor::sample() {
|
||||
aggr.add_sample(raw);
|
||||
}
|
||||
|
||||
#ifdef CYW43_USES_VSYS_PIN
|
||||
#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI)
|
||||
if (pin == PICO_VSYS_PIN) {
|
||||
cyw43_thread_exit();
|
||||
}
|
||||
#endif // CYW43_USES_VSYS_PIN
|
||||
#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI)
|
||||
|
||||
if (this->output_raw_) {
|
||||
return aggr.aggregate();
|
||||
|
||||
@@ -68,11 +68,15 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
|
||||
void loop() override;
|
||||
esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override;
|
||||
|
||||
void register_connection(BluetoothConnection *connection) {
|
||||
// maybe_unused: in a passive proxy (active: false) MAX is 0, the body below is removed, and connection is unused.
|
||||
void register_connection([[maybe_unused]] BluetoothConnection *connection) {
|
||||
// Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0.
|
||||
#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0
|
||||
if (this->connection_count_ < BLUETOOTH_PROXY_MAX_CONNECTIONS) {
|
||||
this->connections_[this->connection_count_++] = connection;
|
||||
connection->proxy_ = this;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void bluetooth_device_request(const api::BluetoothDeviceRequest &msg);
|
||||
|
||||
@@ -173,8 +173,14 @@ bool ES8388::set_mute_state_(bool mute_state) {
|
||||
ES8388_ERROR_CHECK(this->read_byte(ES8388_DACCONTROL3, &value));
|
||||
ESP_LOGV(TAG, "Read ES8388_DACCONTROL3: 0x%02X", value);
|
||||
|
||||
// Only toggle the DACMute bit; the other bits of this register hold unrelated
|
||||
// DAC settings that must be preserved. Previously muting overwrote the whole
|
||||
// register with 0x3C and unmuting never cleared the bit, so once muted the DAC
|
||||
// could not be unmuted again.
|
||||
if (mute_state) {
|
||||
value = 0x3C;
|
||||
value |= ES8388_DACCONTROL3_DAC_MUTE;
|
||||
} else {
|
||||
value &= ~ES8388_DACCONTROL3_DAC_MUTE;
|
||||
}
|
||||
|
||||
ESP_LOGV(TAG, "Setting ES8388_DACCONTROL3 to 0x%02X (muted: %s)", value, YESNO(mute_state));
|
||||
|
||||
@@ -38,6 +38,7 @@ static const uint8_t ES8388_ADCCONTROL14 = 0x16;
|
||||
static const uint8_t ES8388_DACCONTROL1 = 0x17;
|
||||
static const uint8_t ES8388_DACCONTROL2 = 0x18;
|
||||
static const uint8_t ES8388_DACCONTROL3 = 0x19;
|
||||
static const uint8_t ES8388_DACCONTROL3_DAC_MUTE = 0x04; // DACMute, bit 2 of DACCONTROL3
|
||||
static const uint8_t ES8388_DACCONTROL4 = 0x1a;
|
||||
static const uint8_t ES8388_DACCONTROL5 = 0x1b;
|
||||
static const uint8_t ES8388_DACCONTROL6 = 0x1c;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -224,6 +224,17 @@ def merge_factory_bin(source, target, env):
|
||||
flash_size = env.BoardConfig().get("upload.flash_size", "4MB")
|
||||
chip = env.BoardConfig().get("build.mcu", "esp32")
|
||||
|
||||
# PlatformIO's esp-idf builder already creates a correct firmware.factory.bin (right
|
||||
# artifact names and partition offsets, including custom partition tables). The merge
|
||||
# below is only a fallback and cannot honor custom layouts, so don't overwrite an image
|
||||
# PlatformIO already produced. Post-build actions only run when firmware.bin is rebuilt,
|
||||
# and PlatformIO's combined-image builder runs before us in that batch, so an existing
|
||||
# file here is current.
|
||||
output_path = firmware_path.with_suffix(".factory.bin")
|
||||
if output_path.exists():
|
||||
print(f"{output_path.name} already created by PlatformIO - skipping merge")
|
||||
return
|
||||
|
||||
sections = []
|
||||
flasher_args_path = build_dir / "flasher_args.json"
|
||||
|
||||
@@ -291,7 +302,6 @@ def merge_factory_bin(source, target, env):
|
||||
print("No valid flash sections found — skipping .factory.bin creation.")
|
||||
return
|
||||
|
||||
output_path = firmware_path.with_suffix(".factory.bin")
|
||||
python_exe = f'"{env.subst("$PYTHONEXE")}"'
|
||||
cmd = [
|
||||
python_exe,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -257,7 +257,7 @@ async def to_code(config):
|
||||
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1")
|
||||
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2")
|
||||
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.8")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.9")
|
||||
else:
|
||||
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0")
|
||||
esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0")
|
||||
|
||||
@@ -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) {
|
||||
@@ -97,6 +94,15 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status)
|
||||
}
|
||||
|
||||
void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) {
|
||||
// Drop oversized frames before copying. ESP-NOW v2 peers (IDF >= 5.4 builds a
|
||||
// v2 stack with no opt-out) can send up to ESP_NOW_MAX_DATA_LEN_V2 (1470 B),
|
||||
// but our receive buffer is ESP_NOW_MAX_DATA_LEN (250 B); copying a larger
|
||||
// frame would overflow packet_.receive.data.
|
||||
if (size < 0 || size > ESP_NOW_MAX_DATA_LEN) {
|
||||
global_esp_now->receive_packet_queue_.increment_dropped_count();
|
||||
return;
|
||||
}
|
||||
|
||||
// Allocate an event from the pool
|
||||
ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate();
|
||||
if (packet == nullptr) {
|
||||
@@ -204,11 +210,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 +312,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
|
||||
}
|
||||
@@ -333,13 +336,13 @@ void ESPNowComponent::loop() {
|
||||
// Log dropped received packets periodically
|
||||
uint16_t received_dropped = this->receive_packet_queue_.get_and_reset_dropped_count();
|
||||
if (received_dropped > 0) {
|
||||
ESP_LOGW(TAG, "Dropped %u received packets due to buffer overflow", received_dropped);
|
||||
ESP_LOGW(TAG, "Dropped %u received packets (queue full or oversized frame)", received_dropped);
|
||||
}
|
||||
|
||||
// Log dropped send packets periodically
|
||||
uint16_t send_dropped = this->send_packet_queue_.get_and_reset_dropped_count();
|
||||
if (send_dropped > 0) {
|
||||
ESP_LOGW(TAG, "Dropped %u send packets due to buffer overflow", send_dropped);
|
||||
ESP_LOGW(TAG, "Dropped %u send packets (queue full)", send_dropped);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,11 @@ async def new_fastled_light(config):
|
||||
ref="d44c800a9e876a8394caefc2ce4915dd96dac77b",
|
||||
)
|
||||
cg.add_library("SPI", None)
|
||||
# FastLED's RMT5 driver hard-codes intr_priority=3, which conflicts with
|
||||
# esphome's RMT channels (remote_transmitter etc., priority 0): the IDF
|
||||
# driver rejects FastLED's channel and show() then hangs ~3s with no
|
||||
# output. Override to 0 so it shares the interrupt. See #17063.
|
||||
cg.add_build_flag("-DFL_RMT5_INTERRUPT_LEVEL=0")
|
||||
else:
|
||||
cg.add_library("fastled/FastLED", "3.9.16")
|
||||
await light.register_light(var, config)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -175,6 +175,10 @@ void Logger::process_messages_() {
|
||||
#ifdef USE_ESPHOME_TASK_LOG_BUFFER
|
||||
// Process any buffered messages when available
|
||||
if (this->log_buffer_.has_messages()) {
|
||||
// Prevent main-task logs emitted by listener callbacks (e.g. the API send path) from re-entering
|
||||
// and corrupting the shared tx_buffer_ / API shared_write_buffer_ while we are draining here.
|
||||
// Mirrors the guard held by log_message_to_buffer_and_send_ on the synchronous logging path.
|
||||
RecursionGuard guard(this->main_task_recursion_guard_);
|
||||
logger::TaskLogBuffer::LogMessage *message;
|
||||
uint16_t text_length;
|
||||
while (this->log_buffer_.borrow_message_main_loop(message, text_length)) {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -427,6 +425,8 @@ async def to_code(config):
|
||||
dc_pin = await cg.gpio_pin_expression(dc_pin)
|
||||
cg.add(var.set_dc_pin(dc_pin))
|
||||
|
||||
if config.get(CONF_INVERT_COLORS):
|
||||
cg.add(var.set_invert_colors(True))
|
||||
if lamb := config.get(CONF_LAMBDA):
|
||||
lambda_ = await cg.process_lambda(
|
||||
lamb, [(display.DisplayRef, "it")], return_type=cg.void
|
||||
|
||||
@@ -151,6 +151,9 @@ class MipiSpi : public display::Display,
|
||||
this->reset_pin_->digital_write(false);
|
||||
delay(5);
|
||||
this->reset_pin_->digital_write(true);
|
||||
} else {
|
||||
// no reset pin, send software reset command
|
||||
this->write_command_(SW_RESET_CMD);
|
||||
}
|
||||
|
||||
// need to know when the display is ready for SLPOUT command - will be 120ms after reset
|
||||
@@ -176,7 +179,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 +189,6 @@ class MipiSpi : public display::Display,
|
||||
}
|
||||
} break;
|
||||
|
||||
case INVERT_ON:
|
||||
this->invert_colors_ = true;
|
||||
break;
|
||||
case BRIGHTNESS:
|
||||
this->brightness_ = arg_byte;
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -24,13 +24,11 @@ from esphome.components.mipi import (
|
||||
PWSET,
|
||||
PWSETN,
|
||||
SETEXTC,
|
||||
SWRESET,
|
||||
VMCTR,
|
||||
VMCTR1,
|
||||
VMCTR2,
|
||||
VSCRSADD,
|
||||
DriverChip,
|
||||
delay,
|
||||
)
|
||||
from esphome.components.spi import TYPE_OCTAL
|
||||
|
||||
@@ -367,7 +365,6 @@ ST7796 = DriverChip(
|
||||
width=320,
|
||||
height=480,
|
||||
initsequence=(
|
||||
(SWRESET,),
|
||||
(CSCON, 0xC3),
|
||||
(CSCON, 0x96),
|
||||
(VMCTR1, 0x1C),
|
||||
@@ -728,8 +725,6 @@ DriverChip(
|
||||
width=128,
|
||||
height=160,
|
||||
initsequence=(
|
||||
SWRESET,
|
||||
delay(10),
|
||||
(FRMCTR1, 0x01, 0x2C, 0x2D),
|
||||
(FRMCTR2, 0x01, 0x2C, 0x2D),
|
||||
(FRMCTR3, 0x01, 0x2C, 0x2D, 0x01, 0x2C, 0x2D),
|
||||
@@ -786,7 +781,7 @@ ST7796.extend(
|
||||
bus_mode=TYPE_OCTAL,
|
||||
mirror_x=True,
|
||||
reset_pin=4,
|
||||
dc_pin=0,
|
||||
dc_pin={"number": 0, "ignore_strapping_warning": True},
|
||||
invert_colors=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,18 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type)
|
||||
return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION;
|
||||
}
|
||||
|
||||
watchdog::WatchdogManager watchdog(15000);
|
||||
// esp_ota_begin() erases the destination region, which blocks loopTask and
|
||||
// scales with the erase size -- a fixed watchdog overruns on large OTA slots.
|
||||
// An unknown size (0, e.g. web_server uploads) erases the whole partition, so
|
||||
// budget against the bytes actually erased. ~10ms/KiB (conservative
|
||||
// ~100 KiB/s erase) over a 15s floor; panic stays on so a stuck erase still
|
||||
// resets rather than hanging forever.
|
||||
size_t erase_size = image_size;
|
||||
if (erase_size == 0 || erase_size > this->partition_->size) {
|
||||
erase_size = this->partition_->size;
|
||||
}
|
||||
const uint32_t erase_budget_ms = 15000 + (erase_size >> 10) * 10;
|
||||
watchdog::WatchdogManager watchdog(erase_budget_ms);
|
||||
esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_);
|
||||
|
||||
if (err != ESP_OK) {
|
||||
|
||||
@@ -69,7 +69,7 @@ ENCRYPTION_SCHEMA = {
|
||||
cv.Optional(CONF_ENCRYPTION): cv.maybe_simple_value(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_KEY): cv.string,
|
||||
cv.Required(CONF_KEY): cv.sensitive(cv.string),
|
||||
}
|
||||
),
|
||||
key=CONF_KEY,
|
||||
|
||||
@@ -4,7 +4,7 @@ import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_TIME_ID,
|
||||
DEVICE_CLASS_DURATION,
|
||||
DEVICE_CLASS_UPTIME,
|
||||
DEVICE_CLASS_TIMESTAMP,
|
||||
ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
ICON_TIMER,
|
||||
STATE_CLASS_TOTAL_INCREASING,
|
||||
@@ -33,8 +33,9 @@ CONFIG_SCHEMA = cv.typed_schema(
|
||||
).extend(cv.polling_component_schema("60s")),
|
||||
"timestamp": sensor.sensor_schema(
|
||||
UptimeTimestampSensor,
|
||||
icon=ICON_TIMER,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_UPTIME,
|
||||
device_class=DEVICE_CLASS_TIMESTAMP,
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
)
|
||||
.extend(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -1455,9 +1455,7 @@ def ipv6address(value):
|
||||
def ipv4address_multi_broadcast(value):
|
||||
address = ipv4address(value)
|
||||
if not (address.is_multicast or (address == IPv4Address("255.255.255.255"))):
|
||||
raise Invalid(
|
||||
f"{value} is not a multicasst address nor local broadcast address"
|
||||
)
|
||||
raise Invalid(f"{value} is not a multicast address nor local broadcast address")
|
||||
return address
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
|
||||
from esphome.enum import StrEnum
|
||||
|
||||
__version__ = "2026.6.0b3"
|
||||
__version__ = "2026.6.4"
|
||||
|
||||
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])
|
||||
|
||||
|
||||
+22
-12
@@ -81,8 +81,13 @@ def _get_idf_tools_path() -> Path:
|
||||
Path object pointing to the ESP-IDF tools directory
|
||||
"""
|
||||
if "ESPHOME_ESP_IDF_PREFIX" in os.environ:
|
||||
return Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser()
|
||||
return CORE.data_dir / "idf"
|
||||
path = Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser()
|
||||
else:
|
||||
path = CORE.data_dir / "idf"
|
||||
# Resolve so an unnormalized config path (e.g. compiling ``../config/x.yaml``)
|
||||
# doesn't leave ``..`` segments in the IDF_TOOLS_PATH handed to idf.py, which
|
||||
# otherwise warns that the venv interpreter path doesn't match the install.
|
||||
return path.resolve()
|
||||
|
||||
|
||||
# Windows' default MAX_PATH is 260 characters. ESP-IDF toolchains nest deeply
|
||||
@@ -332,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
|
||||
@@ -604,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:
|
||||
|
||||
@@ -472,6 +472,7 @@ def get_idedata() -> dict | None:
|
||||
pass
|
||||
|
||||
data = idedata_from_build(compile_commands)
|
||||
data["prog_path"] = str(get_elf_path())
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
return data
|
||||
|
||||
@@ -38,7 +38,7 @@ dependencies:
|
||||
rules:
|
||||
- if: "target in [esp32h2, esp32p4]"
|
||||
espressif/esp_hosted:
|
||||
version: 2.12.8
|
||||
version: 2.12.9
|
||||
rules:
|
||||
- if: "target in [esp32h2, esp32p4]"
|
||||
zorxx/multipart-parser:
|
||||
|
||||
+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
|
||||
|
||||
+15
-6
@@ -70,12 +70,15 @@ def populate_dependency_config(
|
||||
|
||||
* ``domain.platform`` form (e.g. ``sensor.gpio``) appends
|
||||
``{platform: <name>}`` to ``config[domain]``, creating the list if needed.
|
||||
* Bare components are looked up via ``get_component_fn``. Platform
|
||||
components (``IS_PLATFORM_COMPONENT``) and ``MULTI_CONF`` components are
|
||||
initialised as ``[]`` so the sibling ``domain.platform`` branch can
|
||||
``append`` into them. Everything else is populated by running the
|
||||
component's schema with ``{}`` so defaults exist; if the schema requires
|
||||
explicit input, an empty ``{}`` is used as a fallback.
|
||||
* Bare components are looked up via ``get_component_fn``. Target-platform
|
||||
components (``is_target_platform``, e.g. ``esp32``) are skipped entirely:
|
||||
a host build targets ``host``, so a foreign target platform's sources are
|
||||
guarded out and its schema must not run here (it would mutate global CORE
|
||||
state as a side effect). Platform components (``IS_PLATFORM_COMPONENT``)
|
||||
and ``MULTI_CONF`` components are initialised as ``[]`` so the sibling
|
||||
``domain.platform`` branch can ``append`` into them. Everything else is
|
||||
populated by running the component's schema with ``{}`` so defaults exist;
|
||||
if the schema requires explicit input, an empty ``{}`` is used as a fallback.
|
||||
|
||||
Platform components must always be a list here even when no
|
||||
``domain.platform`` entry follows, because the ``domain.platform`` branch
|
||||
@@ -96,6 +99,12 @@ def populate_dependency_config(
|
||||
component = get_component_fn(component_name)
|
||||
if component is None:
|
||||
continue
|
||||
# Skip target platforms (e.g. esp32): a host build targets `host`, so a
|
||||
# foreign target's sources are guarded out, and running its schema with
|
||||
# {} leaks global CORE state (esp32 pins CORE.toolchain to ESP-IDF),
|
||||
# crashing the host compile. See #17035.
|
||||
if component.is_target_platform:
|
||||
continue
|
||||
if component.multi_conf or component.is_platform_component:
|
||||
config.setdefault(component_name, [])
|
||||
elif component_name not in config:
|
||||
|
||||
@@ -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],
|
||||
@@ -333,6 +377,6 @@ def test_lvgl_generation(
|
||||
"mipi_spi::MipiSpi<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_SINGLE, 128, 160, 0, 0, 0, 0, 0, true>();"
|
||||
in main_cpp
|
||||
)
|
||||
assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp
|
||||
assert "set_init_sequence({177, 3, 1, 44, 45, 178" in main_cpp
|
||||
assert "show_test_card();" not in main_cpp
|
||||
assert "set_auto_clear(false);" in main_cpp
|
||||
|
||||
@@ -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};"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
esphome:
|
||||
name: logger-recursion-test
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: DEBUG
|
||||
on_message:
|
||||
# Fires on the main loop for every message delivered to listeners, including
|
||||
# messages drained from the task log buffer (i.e. logged from a non-main thread).
|
||||
# The lambda logs again on the main task. Without a recursion guard on the buffered
|
||||
# drain path this re-entrant log reuses the shared tx_buffer_ and clobbers the
|
||||
# buffered message that is still being delivered, corrupting its console output.
|
||||
- level: VERY_VERBOSE
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGD("reentry", "REENTRANT_CLOBBER_MARKER");
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Race Test"
|
||||
id: start_test_button
|
||||
on_press:
|
||||
- lambda: |-
|
||||
// Keep the count well under the host task-log-buffer slot count so every
|
||||
// message goes through the ring buffer (buffered drain path) instead of the
|
||||
// emergency console fallback. The main loop is blocked in pthread_join while
|
||||
// the thread logs, so all messages are drained together once it returns.
|
||||
static const int NUM_MESSAGES = 30;
|
||||
|
||||
struct ThreadTest {
|
||||
static void *thread_func(void *arg) {
|
||||
char thread_name[16];
|
||||
snprintf(thread_name, sizeof(thread_name), "LogThread");
|
||||
#ifdef __APPLE__
|
||||
pthread_setname_np(thread_name);
|
||||
#else
|
||||
pthread_setname_np(pthread_self(), thread_name);
|
||||
#endif
|
||||
|
||||
for (int i = 0; i < NUM_MESSAGES; i++) {
|
||||
// Verifiable payload: data is a deterministic function of the message
|
||||
// index, so a clobbered buffer shows up as a missing or mismatched line.
|
||||
ESP_LOGD("thread_test", "THREADMSG%03d_DATA_%08X", i, i * 12345);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
// RACE_TEST_START / RACE_TEST_COMPLETE are logged from the main task (the
|
||||
// synchronous path, which already holds the recursion guard) so the test can
|
||||
// always detect completion even when the buffered path is corrupted.
|
||||
ESP_LOGI("thread_test", "RACE_TEST_START: logging %d messages from a thread", NUM_MESSAGES);
|
||||
|
||||
pthread_t thread;
|
||||
if (pthread_create(&thread, nullptr, ThreadTest::thread_func, nullptr) != 0) {
|
||||
ESP_LOGE("thread_test", "RACE_TEST_ERROR: Failed to create thread");
|
||||
return;
|
||||
}
|
||||
pthread_join(thread, nullptr);
|
||||
|
||||
ESP_LOGI("thread_test", "RACE_TEST_COMPLETE: thread finished, expected %d messages", NUM_MESSAGES);
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Integration test for the recursion guard on the buffered logger drain path.
|
||||
|
||||
Regression test for a crash where a log message drained from the task log buffer
|
||||
(i.e. logged from a non-main thread) re-entered the logger on the main task while it
|
||||
was still being delivered to listeners. The buffered drain in
|
||||
``Logger::process_messages_`` did not hold the main-task recursion guard that the
|
||||
synchronous logging path holds, so a listener callback that logged again on the main
|
||||
task (e.g. the API log-forwarding path, or a ``logger.on_message`` automation) reused
|
||||
the shared ``tx_buffer_`` and clobbered the message mid-delivery. On ESP32 this showed
|
||||
up as a ``StoreProhibited`` panic inside the API send path.
|
||||
|
||||
The fixture logs a small batch of verifiable messages from a non-main thread (kept
|
||||
under the host task-log-buffer slot count so they all take the buffered drain path
|
||||
rather than the emergency console fallback) while an ``on_message`` automation re-logs
|
||||
``REENTRANT_CLOBBER_MARKER`` on the main task for every delivered message.
|
||||
|
||||
Without the guard the re-entrant marker is written into the shared ``tx_buffer_`` while
|
||||
the buffered thread message is still being delivered, so the message the API receives is
|
||||
contaminated (it contains the marker and an embedded newline glued onto the thread
|
||||
payload). With the guard the re-entrant log is dropped during the drain, the marker
|
||||
never appears, and every thread message is delivered clean.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
from aioesphomeapi import LogLevel
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||
# THREADMSGnnn_DATA_xxxxxxxx where data is a deterministic checksum of the index
|
||||
THREAD_MSG_PATTERN = re.compile(r"THREADMSG(\d{3})_DATA_([0-9A-F]{8})")
|
||||
|
||||
NUM_MESSAGES = 30
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logger_buffered_recursion_guard(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Buffered (non-main-thread) log messages survive a re-entrant main-task log."""
|
||||
api_messages: list[str] = []
|
||||
all_drained = asyncio.Event()
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
device_info = await client.device_info()
|
||||
assert device_info is not None
|
||||
assert device_info.name == "logger-recursion-test"
|
||||
|
||||
# Subscribe over the API: this is the exact path that crashed in the field
|
||||
# (the API log callback runs during the buffered drain). The API message field
|
||||
# preserves embedded newlines, so it reliably exposes a clobbered buffer.
|
||||
#
|
||||
# Every buffered thread message is delivered here whether it survives intact or
|
||||
# gets clobbered (a clobbered message still carries its THREADMSG payload), so
|
||||
# counting THREADMSG occurrences is a deterministic "drain complete" signal: no
|
||||
# arbitrary sleep, no dependence on the fix being present.
|
||||
def on_log(msg) -> None:
|
||||
text = msg.message.decode("utf-8", errors="replace")
|
||||
api_messages.append(text)
|
||||
received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages)
|
||||
if received >= NUM_MESSAGES:
|
||||
all_drained.set()
|
||||
|
||||
client.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERY_VERBOSE)
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
buttons = [e for e in entities if e.name == "Start Race Test"]
|
||||
assert buttons, "Could not find Start Race Test button"
|
||||
client.button_command(buttons[0].key)
|
||||
|
||||
# Wait until every buffered thread message has been delivered over the API.
|
||||
try:
|
||||
await asyncio.wait_for(all_drained.wait(), timeout=30.0)
|
||||
except TimeoutError:
|
||||
received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages)
|
||||
pytest.fail(
|
||||
f"Only {received}/{NUM_MESSAGES} thread messages arrived before timeout; "
|
||||
"device likely crashed or hung."
|
||||
)
|
||||
|
||||
intact: set[int] = set()
|
||||
contaminated: list[str] = []
|
||||
for raw in api_messages:
|
||||
text = _ANSI.sub("", raw)
|
||||
if "THREADMSG" not in text:
|
||||
continue
|
||||
# A clean thread message is a single line carrying only its own payload. A
|
||||
# clobbered buffer glues the re-entrant marker (and an embedded newline) onto it.
|
||||
if "REENTRANT" in text or "\n" in text:
|
||||
contaminated.append(repr(raw))
|
||||
continue
|
||||
match = THREAD_MSG_PATTERN.search(text)
|
||||
assert match, f"Unexpected thread message format: {raw!r}"
|
||||
msg_num = int(match.group(1))
|
||||
expected = f"{msg_num * 12345:08X}"
|
||||
if match.group(2) != expected:
|
||||
contaminated.append(repr(raw))
|
||||
continue
|
||||
intact.add(msg_num)
|
||||
|
||||
assert not contaminated, (
|
||||
"Buffered thread messages were clobbered by a re-entrant main-task log "
|
||||
"(missing recursion guard on the buffered drain path):\n"
|
||||
+ "\n".join(contaminated[:10])
|
||||
)
|
||||
assert len(intact) == NUM_MESSAGES, (
|
||||
f"Expected {NUM_MESSAGES} intact buffered thread messages over the API, got "
|
||||
f"{len(intact)}. Missing ids: {sorted(set(range(NUM_MESSAGES)) - intact)}"
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Unit tests for script/build_helpers.py."""
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
# Add the script directory to the path so we can import build_helpers.
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script"))
|
||||
|
||||
import build_helpers # noqa: E402
|
||||
|
||||
from esphome.core import CORE # noqa: E402
|
||||
|
||||
|
||||
class _FakeComponent:
|
||||
def __init__(self, config_schema, *, is_target_platform=False):
|
||||
self.multi_conf = False
|
||||
self.is_platform_component = False
|
||||
self.is_target_platform = is_target_platform
|
||||
self.config_schema = config_schema
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_core_toolchain():
|
||||
"""Keep CORE.toolchain changes from leaking between tests."""
|
||||
saved = CORE.toolchain
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
CORE.toolchain = saved
|
||||
|
||||
|
||||
def test_populate_dependency_config_skips_target_platforms() -> None:
|
||||
"""Target-platform deps must be skipped, not config-populated, in a host build.
|
||||
|
||||
Regression test for #17035: esp32 (a target platform) appears only as a
|
||||
transitive dependency of a host C++ unit test. Running its schema with {}
|
||||
set ``CORE.toolchain = ESP_IDF`` as a side effect before failing validation,
|
||||
which crashed the host compile with KeyError('esp32'). The fix skips
|
||||
target-platform components entirely so their schema never runs.
|
||||
"""
|
||||
CORE.toolchain = None # the state a host build starts from
|
||||
schema_calls = []
|
||||
|
||||
def leaky_schema(value):
|
||||
# If this ever runs for a target platform, the bug is back.
|
||||
schema_calls.append(value)
|
||||
CORE.toolchain = "esp-idf-leak"
|
||||
raise ValueError("no board or variant")
|
||||
|
||||
config: dict = {}
|
||||
build_helpers.populate_dependency_config(
|
||||
config,
|
||||
["esp32"],
|
||||
get_component_fn=lambda name: _FakeComponent(
|
||||
leaky_schema, is_target_platform=True
|
||||
),
|
||||
register_platform_fn=lambda domain: None,
|
||||
)
|
||||
|
||||
assert "esp32" not in config # skipped: no synthesized entry
|
||||
assert schema_calls == [] # schema never run
|
||||
assert CORE.toolchain is None # no global side effect leaked
|
||||
|
||||
|
||||
def test_populate_dependency_config_populates_defaults() -> None:
|
||||
"""A non-target-platform dep still has its schema defaults harvested."""
|
||||
config: dict = {}
|
||||
build_helpers.populate_dependency_config(
|
||||
config,
|
||||
["ok"],
|
||||
get_component_fn=lambda name: _FakeComponent(lambda value: {"default": 1}),
|
||||
register_platform_fn=lambda domain: None,
|
||||
)
|
||||
assert config["ok"] == {"default": 1}
|
||||
@@ -266,11 +266,13 @@ def _make_component_stub(
|
||||
*,
|
||||
multi_conf: bool = False,
|
||||
is_platform_component: bool = False,
|
||||
is_target_platform: bool = False,
|
||||
config_schema=None,
|
||||
) -> MagicMock:
|
||||
stub = MagicMock()
|
||||
stub.multi_conf = multi_conf
|
||||
stub.is_platform_component = is_platform_component
|
||||
stub.is_target_platform = is_target_platform
|
||||
stub.config_schema = config_schema
|
||||
return stub
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -89,8 +89,9 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None:
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_called_once()
|
||||
assert result == {"cxx_path": "g++"}
|
||||
assert json.loads(cache.read_text()) == {"cxx_path": "g++"}
|
||||
prog_path = str(toolchain.get_elf_path())
|
||||
assert result == {"cxx_path": "g++", "prog_path": prog_path}
|
||||
assert json.loads(cache.read_text()) == {"cxx_path": "g++", "prog_path": prog_path}
|
||||
|
||||
|
||||
def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None:
|
||||
@@ -127,7 +128,7 @@ def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_called_once()
|
||||
assert result == {"cxx_path": "fresh"}
|
||||
assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())}
|
||||
|
||||
|
||||
def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
|
||||
@@ -147,7 +148,26 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_called_once()
|
||||
assert result == {"cxx_path": "regen"}
|
||||
assert result == {"cxx_path": "regen", "prog_path": str(toolchain.get_elf_path())}
|
||||
|
||||
|
||||
def test_get_idedata_prog_path_points_at_firmware_elf(setup_core: Path) -> None:
|
||||
"""The idedata exposes prog_path (the ELF) so consumers like build-action
|
||||
can locate firmware.factory.bin / firmware.ota.bin as its siblings."""
|
||||
compile_commands, _ = _setup_build(setup_core)
|
||||
compile_commands.parent.mkdir(parents=True, exist_ok=True)
|
||||
compile_commands.write_text("[]")
|
||||
|
||||
with patch(
|
||||
"esphome.espidf.idedata.idedata_from_build",
|
||||
return_value={"cxx_path": "g++"},
|
||||
):
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
# Use Path semantics so the contract holds on Windows too (backslashes).
|
||||
prog_path = Path(result["prog_path"])
|
||||
assert prog_path.name == "firmware.elf"
|
||||
assert prog_path.parent.name == "build"
|
||||
|
||||
|
||||
def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None:
|
||||
|
||||
@@ -32,6 +32,7 @@ from esphome.__main__ import (
|
||||
command_clean_all,
|
||||
command_config,
|
||||
command_config_hash,
|
||||
command_idedata,
|
||||
command_rename,
|
||||
command_run,
|
||||
command_update_all,
|
||||
@@ -436,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:
|
||||
@@ -689,6 +720,25 @@ def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None:
|
||||
assert result == ["192.168.1.100"]
|
||||
|
||||
|
||||
def test_choose_upload_log_host_ota_mdns_disabled_uses_address_cache() -> None:
|
||||
"""A .local device with mDNS disabled resolves via the dashboard-supplied cache."""
|
||||
setup_core(
|
||||
config={
|
||||
CONF_API: {},
|
||||
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
|
||||
CONF_MDNS: {CONF_DISABLED: True},
|
||||
},
|
||||
address="esp32-a1s.local",
|
||||
)
|
||||
CORE.address_cache = AddressCache(mdns_cache={"esp32-a1s.local": ["192.168.1.50"]})
|
||||
|
||||
for purpose in (Purpose.LOGGING, Purpose.UPLOADING):
|
||||
result = choose_upload_log_host(
|
||||
default="OTA", check_default=None, purpose=purpose
|
||||
)
|
||||
assert result == ["192.168.1.50"]
|
||||
|
||||
|
||||
def test_choose_upload_log_host_with_ota_device_with_api_config() -> None:
|
||||
"""Test OTA device when API is configured (no upload without OTA in config)."""
|
||||
setup_core(config={CONF_API: {}}, address="192.168.1.100")
|
||||
@@ -3135,6 +3185,22 @@ def test_has_resolvable_address() -> None:
|
||||
setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address=None)
|
||||
assert has_resolvable_address() is False
|
||||
|
||||
# mDNS disabled + .local, but the dashboard cached the address -> resolvable
|
||||
setup_core(
|
||||
config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local"
|
||||
)
|
||||
CORE.address_cache = AddressCache(
|
||||
mdns_cache={"esphome-device.local": ["192.168.1.100"]}
|
||||
)
|
||||
assert has_resolvable_address() is True
|
||||
|
||||
# mDNS disabled + .local, cache present but missing this host -> not resolvable
|
||||
setup_core(
|
||||
config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local"
|
||||
)
|
||||
CORE.address_cache = AddressCache(mdns_cache={"other-device.local": ["10.0.0.1"]})
|
||||
assert has_resolvable_address() is False
|
||||
|
||||
|
||||
def test_has_name_add_mac_suffix() -> None:
|
||||
"""Test has_name_add_mac_suffix function."""
|
||||
@@ -6222,3 +6288,28 @@ def test_command_run_defaults_subscribe_states_true(
|
||||
mock_run_logs.assert_called_once_with(
|
||||
CORE.config, ["192.168.1.100"], subscribe_states=True
|
||||
)
|
||||
|
||||
|
||||
def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None:
|
||||
"""Under the native ESP-IDF toolchain, idedata is emitted as JSON."""
|
||||
setup_core()
|
||||
CORE.toolchain = Toolchain.ESP_IDF
|
||||
data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"}
|
||||
|
||||
with patch("esphome.espidf.toolchain.get_idedata", return_value=data) as mock_get:
|
||||
result = command_idedata(MagicMock(), CORE.config)
|
||||
|
||||
assert result == 0
|
||||
mock_get.assert_called_once_with()
|
||||
assert json.loads(capsys.readouterr().out) == data
|
||||
|
||||
|
||||
def test_command_idedata_esp_idf_no_build_errors() -> None:
|
||||
"""Under ESP-IDF, a missing build (no idedata) returns an error, not a crash."""
|
||||
setup_core()
|
||||
CORE.toolchain = Toolchain.ESP_IDF
|
||||
|
||||
with patch("esphome.espidf.toolchain.get_idedata", return_value=None):
|
||||
result = command_idedata(MagicMock(), CORE.config)
|
||||
|
||||
assert result == 1
|
||||
|
||||
Reference in New Issue
Block a user