Compare commits

...

68 Commits

Author SHA1 Message Date
Jonathan Swoboda
d8c23d4fc9 Merge pull request #12772 from esphome/bump-2025.12.4
2025.12.4
2025-12-31 17:42:39 -05:00
Jonathan Swoboda
e9e0712959 Bump version to 2025.12.4 2025-12-31 16:07:00 -05:00
J. Nick Koston
062840dd7b [docker] Add build-essential to fix ruamel.yaml 0.19.0 compilation (#12769)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2025-12-31 16:07:00 -05:00
J. Nick Koston
f0f01c081a [wifi] Fix ESP-IDF reporting connected before DHCP completes on reconnect (#12755) 2025-12-31 16:07:00 -05:00
Stuart Parmenter
dd855985be [hub75] Add clipping check (#12762) 2025-12-31 16:06:59 -05:00
Jonathan Swoboda
5b5cede5f9 Merge pull request #12752 from esphome/bump-2025.12.3
2025.12.3
2025-12-30 09:31:31 -05:00
Jonathan Swoboda
c737033cc4 Bump version to 2025.12.3 2025-12-30 09:22:03 -05:00
J. Nick Koston
0194bfd9ea [core] Fix incremental build failures when adding components on ESP32-Arduino (#12745) 2025-12-30 09:22:03 -05:00
J. Nick Koston
339399eb70 [lvgl] Fix lambdas in canvas actions called from outside LVGL context (#12671) 2025-12-30 09:22:03 -05:00
Jonathan Swoboda
99f7e9aeb7 Merge pull request #12632 from esphome/bump-2025.12.2
2025.12.2
2025-12-23 11:17:01 -05:00
Jonathan Swoboda
ebb6babb3d Fix hash 2025-12-23 09:26:38 -05:00
Jonathan Swoboda
0922f240e0 Bump version to 2025.12.2 2025-12-23 09:23:04 -05:00
Jonathan Swoboda
c8fb694dcb [cc1101] Fix packet mode RSSI/LQI (#12630)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-23 09:23:04 -05:00
J. Nick Koston
6054685dae [esp32_camera] Throttle frame logging to reduce overhead and improve throughput (#12586) 2025-12-23 09:23:04 -05:00
Anna Oake
61ec3508ed [cc1101] Fix option defaults and move them to YAML (#12608) 2025-12-23 09:23:04 -05:00
Leo Bergolth
086ec770ea send NIL ("-") as timestamp if time source is not valid (#12588) 2025-12-23 09:23:04 -05:00
Stuart Parmenter
b055f5b4bf [hub75] Bump esp-hub75 version to 0.1.7 (#12564) 2025-12-23 09:23:00 -05:00
Eduard Llull
726db746c8 [display_menu_base] Call on_value_ after updating the select (#12584) 2025-12-23 09:21:54 -05:00
Keith Burzinski
1922455fa7 [wifi] Fix for wifi_info when static IP is configured (#12576) 2025-12-23 09:21:54 -05:00
Thomas Rupprecht
dc943d7e7a [pca9685,sx126x,sx127x] Use frequency/float_range check (#12490)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2025-12-23 09:21:54 -05:00
Jonathan Swoboda
93e38f2608 Merge pull request #12569 from esphome/bump-2025.12.1
2025.12.1
2025-12-19 10:53:05 -05:00
Jonathan Swoboda
3a888326d8 Bump version to 2025.12.1 2025-12-19 10:13:35 -05:00
Keith Burzinski
f0d0ea60a7 [esp32_ble, esp32_ble_tracker] Fix crash, error messages when ble.disable called during boot (#12560) 2025-12-19 10:13:35 -05:00
Jonathan Swoboda
7ca11764ab [template.alarm_control_panel] Fix compile without binary_sensor (#12548)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-19 10:13:35 -05:00
Jonathan Swoboda
3e38a5e630 [esp32_camera] Fix I2C driver conflict with other components (#12533)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-12-19 10:13:35 -05:00
Jonathan Swoboda
636be92c97 [bme68x_bsec2_i2c] Add MULTI_CONF support for multiple sensors (#12535)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-19 10:13:35 -05:00
Jack Wilsdon
195b1c6323 [pm1006] Fix "never" update interval detection (#12529) 2025-12-19 10:13:35 -05:00
Anna Oake
7e08092012 [cc1101] Fix default frequencies (#12539) 2025-12-19 10:13:35 -05:00
Jonathan Swoboda
0ea5f2fd81 Merge pull request #12525 from esphome/bump-2025.12.0
2025.12.0
2025-12-16 18:57:20 -05:00
Jonathan Swoboda
fa3d998c3d Bump version to 2025.12.0 2025-12-16 17:15:50 -05:00
Jonathan Swoboda
864aaeec01 Merge pull request #12520 from esphome/bump-2025.12.0b5
2025.12.0b5
2025-12-16 11:25:57 -05:00
Jonathan Swoboda
9c88e44300 Bump version to 2025.12.0b5 2025-12-16 10:35:31 -05:00
Jonathan Swoboda
4d6a93f92d [uart] Fix UART on default UART0 pins for ESP-IDF (#12519)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-16 10:35:31 -05:00
J. Nick Koston
7216120bfd [socket] Fix getpeername() returning local address instead of remote in LWIP raw TCP (#12475) 2025-12-16 10:35:31 -05:00
Jonathan Swoboda
8cf0ee38a3 Merge pull request #12513 from esphome/bump-2025.12.0b4
2025.12.0b4
2025-12-15 19:01:02 -05:00
Jonathan Swoboda
4c926cca60 Bump version to 2025.12.0b4 2025-12-15 18:09:42 -05:00
Pascal Vizeli
57634b612a [http_request] Fix infinite loop when server doesn't send Content-Length header (#12480)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-15 18:09:42 -05:00
Jonathan Swoboda
8dff7ee746 [esp32] Support all IDF component version operators in shorthand syntax (#12499)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-15 18:09:42 -05:00
Jonathan Swoboda
803bb742c9 [remote_base] Fix crash when ABBWelcome action has no data field (#12493)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-15 18:09:42 -05:00
Jonathan Swoboda
3e6a65e7dc Merge pull request #12488 from esphome/bump-2025.12.0b3
2025.12.0b3
2025-12-14 19:17:58 -05:00
Jonathan Swoboda
3a101d8886 Bump version to 2025.12.0b3 2025-12-14 18:17:00 -05:00
J. Nick Koston
fa0f07bfe9 [wifi] Fix WiFi recovery after failed connection attempts (#12483) 2025-12-14 18:17:00 -05:00
mbohdal
fffa16e4d8 [ethernet] fix used pins validation in configuration of RMII pins (#12486) 2025-12-14 18:17:00 -05:00
guillempages
734710d22a [core] Use Arduino string macros only on ESP8266 (#12471) 2025-12-14 18:17:00 -05:00
J. Nick Koston
3a1be6822e [ota] Match client timeout to device timeout to prevent premature failures (#12484) 2025-12-14 18:17:00 -05:00
J. Nick Koston
c85b1b8609 [web_server_idf] Always enable LRU purge to prevent socket exhaustion (#12481) 2025-12-14 18:17:00 -05:00
J. Nick Koston
2e9ddd967c [wifi_signal] Skip publishing disconnected RSSI value (#12482) 2025-12-14 18:17:00 -05:00
J. Nick Koston
078afe9656 [dashboard] Add ESPHOME_TRUSTED_DOMAINS support to events WebSocket (#12479) 2025-12-14 18:17:00 -05:00
Jonathan Swoboda
46574fcbec [cc1101] Add packet mode support (#12474)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-14 18:17:00 -05:00
Jonathan Swoboda
359f45400f [core] Fix polling_component_schema and type consistency (#12478)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-14 18:16:59 -05:00
Clyde Stubbs
4da95ccd7e [packet_transport] Ensure retransmission at update intervals (#12472)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-14 18:16:59 -05:00
J. Nick Koston
c69d58273a [core] Fix CORE.raw_config not updated after package merge (#12456) 2025-12-14 18:16:59 -05:00
Jonathan Swoboda
375e53105f Merge pull request #12444 from esphome/bump-2025.12.0b2
2025.12.0b2
2025-12-12 12:15:41 -05:00
Jonathan Swoboda
c9506b056d Bump version to 2025.12.0b2 2025-12-12 11:12:58 -05:00
Jonathan Swoboda
2c77668a05 [http_request] Skip update check when network not connected (#12418)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-12 11:12:58 -05:00
J. Nick Koston
5567d96dd9 [esp8266] Eliminate up to 16ms socket latency (#12397) 2025-12-12 11:12:58 -05:00
J. Nick Koston
78b76045ce [api] Fix potential buffer overflow in noise PSK base64 decode (#12395) 2025-12-12 11:12:58 -05:00
J. Nick Koston
1d13d18a16 [light] Add zero-copy support for API effect commands (#12384) 2025-12-12 11:12:58 -05:00
Jonathan Swoboda
a3a2a6d965 Merge pull request #12396 from esphome/bump-2025.12.0b1
2025.12.0b1
2025-12-09 21:33:58 -05:00
Jonathan Swoboda
26770e09dc Bump version to 2025.12.0b1 2025-12-09 20:08:35 -05:00
Javier Peletier
9f2693ead5 [core] Packages refactor and conditional package inclusion (package refactor part 1) (#11605)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-12-10 00:59:58 +01:00
J. Nick Koston
3642399460 [tests] Fix clang-tidy warnings in custom_api_device_component fixture (#12390) 2025-12-10 00:50:26 +01:00
J. Nick Koston
3a6edbc2c7 [micronova] Fix test UART package key to match directory name (#12391) 2025-12-10 00:49:44 +01:00
J. Nick Koston
608f834eaa [ci] Isolate usb_cdc_acm in component tests due to tinyusb/usb_host conflict (#12392) 2025-12-10 00:49:29 +01:00
J. Nick Koston
5919355d18 [ci] Allow memory impact target branch build to fail without blocking CI (#12381) 2025-12-10 00:26:24 +01:00
dependabot[bot]
1e23b10eed Bump aioesphomeapi from 43.1.0 to 43.2.1 (#12385)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-09 22:02:42 +00:00
Clyde Stubbs
ad0218fd40 [mipi_rgb] Add Waveshare 3.16 (#12309) 2025-12-10 08:17:59 +11:00
Clyde Stubbs
87142efbb4 [epaper_spi] Set reasonable default update interval (#12331) 2025-12-10 06:42:11 +11:00
115 changed files with 1732 additions and 462 deletions

View File

@@ -1 +1 @@
766420905c06eeb6c5f360f68fd965e5ddd9c4a5db6b823263d3ad3accb64a07
5969e705693278d984c5292e998df0cbaf34f7e1f04dfc7f7b7ad7168527bfa7

View File

@@ -959,13 +959,13 @@ jobs:
- memory-impact-comment
if: always()
steps:
- name: Success
if: ${{ !(contains(needs.*.result, 'failure')) }}
run: exit 0
- name: Failure
if: ${{ contains(needs.*.result, 'failure') }}
- name: Check job results
env:
JSON_DOC: ${{ toJSON(needs) }}
NEEDS_JSON: ${{ toJSON(needs) }}
run: |
echo $JSON_DOC | jq
exit 1
# memory-impact-target-branch is allowed to fail without blocking CI.
# This job builds the target branch (dev/beta/release) which may fail because:
# 1. The target branch has a build issue independent of this PR
# 2. This PR fixes a build issue on the target branch
# In either case, we only care that the PR branch builds successfully.
echo "$NEEDS_JSON" | jq -e 'del(.["memory-impact-target-branch"]) | all(.result != "failure")'

View File

@@ -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 = 2025.12.0-dev
PROJECT_NUMBER = 2025.12.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

View File

@@ -11,6 +11,16 @@ FROM base-source-${BUILD_TYPE} AS base
RUN git config --system --add safe.directory "*"
# Install build tools for Python packages that require compilation
# (e.g., ruamel.yaml.clibz used by ESP-IDF's idf-component-manager)
RUN if command -v apk > /dev/null; then \
apk add --no-cache build-base; \
else \
apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*; \
fi
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
RUN pip install --no-cache-dir -U pip uv==0.6.14

View File

@@ -227,7 +227,7 @@ CONFIG_SCHEMA = cv.All(
{
cv.GenerateID(): cv.declare_id(ADE7880),
cv.Optional(CONF_FREQUENCY, default="50Hz"): cv.All(
cv.frequency, cv.Range(min=45.0, max=66.0)
cv.frequency, cv.float_range(min=45.0, max=66.0)
),
cv.Optional(CONF_IRQ0_PIN): pins.internal_gpio_input_pin_schema,
cv.Required(CONF_IRQ1_PIN): pins.internal_gpio_input_pin_schema,

View File

@@ -579,7 +579,7 @@ message LightCommandRequest {
bool has_flash_length = 16;
uint32 flash_length = 17;
bool has_effect = 18;
string effect = 19;
string effect = 19 [(pointer_to_buffer) = true];
uint32 device_id = 28 [(field_ifdef) = "USE_DEVICES"];
}

View File

@@ -533,7 +533,7 @@ void APIConnection::light_command(const LightCommandRequest &msg) {
if (msg.has_flash_length)
call.set_flash_length(msg.flash_length);
if (msg.has_effect)
call.set_effect(msg.effect);
call.set_effect(reinterpret_cast<const char *>(msg.effect), msg.effect_len);
call.perform();
}
#endif
@@ -1669,7 +1669,7 @@ bool APIConnection::send_noise_encryption_set_key_response(const NoiseEncryption
} else {
ESP_LOGW(TAG, "Failed to clear encryption key");
}
} else if (base64_decode(msg.key, psk.data(), msg.key.size()) != psk.size()) {
} else if (base64_decode(msg.key, psk.data(), psk.size()) != psk.size()) {
ESP_LOGW(TAG, "Invalid encryption key length");
} else if (!this->parent_->save_noise_psk(psk, true)) {
ESP_LOGW(TAG, "Failed to save encryption key");

View File

@@ -611,9 +611,12 @@ bool LightCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) {
}
bool LightCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) {
switch (field_id) {
case 19:
this->effect = value.as_string();
case 19: {
// Use raw data directly to avoid allocation
this->effect = value.data();
this->effect_len = value.size();
break;
}
default:
return false;
}

View File

@@ -840,7 +840,7 @@ class LightStateResponse final : public StateResponseProtoMessage {
class LightCommandRequest final : public CommandProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 32;
static constexpr uint8_t ESTIMATED_SIZE = 112;
static constexpr uint8_t ESTIMATED_SIZE = 122;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *message_name() const override { return "light_command_request"; }
#endif
@@ -869,7 +869,8 @@ class LightCommandRequest final : public CommandProtoMessage {
bool has_flash_length{false};
uint32_t flash_length{0};
bool has_effect{false};
std::string effect{};
const uint8_t *effect{nullptr};
uint16_t effect_len{0};
#ifdef HAS_PROTO_MESSAGE_DUMP
void dump_to(std::string &out) const override;
#endif

View File

@@ -999,7 +999,9 @@ void LightCommandRequest::dump_to(std::string &out) const {
dump_field(out, "has_flash_length", this->has_flash_length);
dump_field(out, "flash_length", this->flash_length);
dump_field(out, "has_effect", this->has_effect);
dump_field(out, "effect", this->effect);
out.append(" effect: ");
out.append(format_hex_pretty(this->effect, this->effect_len));
out.append("\n");
#ifdef USE_DEVICES
dump_field(out, "device_id", this->device_id);
#endif

View File

@@ -11,6 +11,7 @@ CODEOWNERS = ["@neffs", "@kbx81"]
AUTO_LOAD = ["bme68x_bsec2"]
DEPENDENCIES = ["i2c"]
MULTI_CONF = True
bme68x_bsec2_i2c_ns = cg.esphome_ns.namespace("bme68x_bsec2_i2c")
BME68xBSEC2I2CComponent = bme68x_bsec2_i2c_ns.class_(

View File

@@ -65,12 +65,6 @@ void CaptivePortal::start() {
this->base_->init();
if (!this->initialized_) {
this->base_->add_handler(this);
#ifdef USE_ESP32
// Enable LRU socket purging to handle captive portal detection probe bursts
// OS captive portal detection makes many simultaneous HTTP requests which can
// exhaust sockets. LRU purging automatically closes oldest idle connections.
this->base_->get_server()->set_lru_purge_enable(true);
#endif
}
network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip();

View File

@@ -40,10 +40,6 @@ class CaptivePortal : public AsyncWebHandler, public Component {
void end() {
this->active_ = false;
this->disable_loop(); // Stop processing DNS requests
#ifdef USE_ESP32
// Disable LRU socket purging now that captive portal is done
this->base_->get_server()->set_lru_purge_enable(false);
#endif
this->base_->deinit();
if (this->dns_server_ != nullptr) {
this->dns_server_->stop();

View File

@@ -1,9 +1,17 @@
from esphome import automation
from esphome import automation, pins
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components import spi
from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET
import esphome.config_validation as cv
from esphome.const import CONF_CHANNEL, CONF_FREQUENCY, CONF_ID, CONF_WAIT_TIME
from esphome.const import (
CONF_CHANNEL,
CONF_DATA,
CONF_FREQUENCY,
CONF_ID,
CONF_WAIT_TIME,
)
from esphome.core import ID
CODEOWNERS = ["@lygris", "@gabest11"]
DEPENDENCIES = ["spi"]
@@ -29,7 +37,6 @@ CONF_MANCHESTER = "manchester"
CONF_NUM_PREAMBLE = "num_preamble"
CONF_SYNC1 = "sync1"
CONF_SYNC0 = "sync0"
CONF_PKTLEN = "pktlen"
CONF_MAGN_TARGET = "magn_target"
CONF_MAX_LNA_GAIN = "max_lna_gain"
CONF_MAX_DVGA_GAIN = "max_dvga_gain"
@@ -41,6 +48,12 @@ CONF_FILTER_LENGTH_ASK_OOK = "filter_length_ask_ook"
CONF_FREEZE = "freeze"
CONF_HYST_LEVEL = "hyst_level"
# Packet mode config keys
CONF_PACKET_MODE = "packet_mode"
CONF_PACKET_LENGTH = "packet_length"
CONF_WHITENING = "whitening"
CONF_GDO0_PIN = "gdo0_pin"
# Enums
SyncMode = ns.enum("SyncMode", True)
SYNC_MODE = {
@@ -147,45 +160,89 @@ HYST_LEVEL = {
"High": HystLevel.HYST_LEVEL_HIGH,
}
# Config key -> Validator mapping
# Optional settings to generate setter calls for
CONFIG_MAP = {
CONF_OUTPUT_POWER: cv.float_range(min=-30.0, max=11.0),
CONF_RX_ATTENUATION: cv.enum(RX_ATTENUATION, upper=False),
CONF_DC_BLOCKING_FILTER: cv.boolean,
CONF_FREQUENCY: cv.All(cv.frequency, cv.float_range(min=300000000, max=928000000)),
CONF_IF_FREQUENCY: cv.All(cv.frequency, cv.float_range(min=25000, max=788000)),
CONF_FILTER_BANDWIDTH: cv.All(cv.frequency, cv.float_range(min=58000, max=812000)),
CONF_CHANNEL: cv.uint8_t,
CONF_CHANNEL_SPACING: cv.All(cv.frequency, cv.float_range(min=25000, max=405000)),
CONF_FSK_DEVIATION: cv.All(cv.frequency, cv.float_range(min=1500, max=381000)),
CONF_MSK_DEVIATION: cv.int_range(min=1, max=8),
CONF_SYMBOL_RATE: cv.float_range(min=600, max=500000),
CONF_SYNC_MODE: cv.enum(SYNC_MODE, upper=False),
CONF_CARRIER_SENSE_ABOVE_THRESHOLD: cv.boolean,
CONF_MODULATION_TYPE: cv.enum(MODULATION, upper=False),
CONF_MANCHESTER: cv.boolean,
CONF_NUM_PREAMBLE: cv.int_range(min=0, max=7),
CONF_SYNC1: cv.hex_uint8_t,
CONF_SYNC0: cv.hex_uint8_t,
CONF_PKTLEN: cv.uint8_t,
CONF_MAGN_TARGET: cv.enum(MAGN_TARGET, upper=False),
CONF_MAX_LNA_GAIN: cv.enum(MAX_LNA_GAIN, upper=False),
CONF_MAX_DVGA_GAIN: cv.enum(MAX_DVGA_GAIN, upper=False),
CONF_CARRIER_SENSE_ABS_THR: cv.int_range(min=-8, max=7),
CONF_CARRIER_SENSE_REL_THR: cv.enum(CARRIER_SENSE_REL_THR, upper=False),
CONF_LNA_PRIORITY: cv.boolean,
CONF_FILTER_LENGTH_FSK_MSK: cv.enum(FILTER_LENGTH_FSK_MSK, upper=False),
CONF_FILTER_LENGTH_ASK_OOK: cv.enum(FILTER_LENGTH_ASK_OOK, upper=False),
CONF_FREEZE: cv.enum(FREEZE, upper=False),
CONF_WAIT_TIME: cv.enum(WAIT_TIME, upper=False),
CONF_HYST_LEVEL: cv.enum(HYST_LEVEL, upper=False),
cv.Optional(CONF_OUTPUT_POWER, default=10): cv.float_range(min=-30.0, max=11.0),
cv.Optional(CONF_RX_ATTENUATION, default="0dB"): cv.enum(
RX_ATTENUATION, upper=False
),
cv.Optional(CONF_DC_BLOCKING_FILTER, default=True): cv.boolean,
cv.Optional(CONF_FREQUENCY, default="433.92MHz"): cv.All(
cv.frequency, cv.float_range(min=300.0e6, max=928.0e6)
),
cv.Optional(CONF_IF_FREQUENCY, default="153kHz"): cv.All(
cv.frequency, cv.float_range(min=25000, max=788000)
),
cv.Optional(CONF_FILTER_BANDWIDTH, default="203kHz"): cv.All(
cv.frequency, cv.float_range(min=58000, max=812000)
),
cv.Optional(CONF_CHANNEL, default=0): cv.uint8_t,
cv.Optional(CONF_CHANNEL_SPACING, default="200kHz"): cv.All(
cv.frequency, cv.float_range(min=25000, max=405000)
),
cv.Optional(CONF_FSK_DEVIATION): cv.All(
cv.frequency, cv.float_range(min=1500, max=381000)
),
cv.Optional(CONF_MSK_DEVIATION): cv.int_range(min=1, max=8),
cv.Optional(CONF_SYMBOL_RATE, default=5000): cv.float_range(min=600, max=500000),
cv.Optional(CONF_SYNC_MODE, default="16/16"): cv.enum(SYNC_MODE, upper=False),
cv.Optional(CONF_CARRIER_SENSE_ABOVE_THRESHOLD, default=False): cv.boolean,
cv.Optional(CONF_MODULATION_TYPE, default="ASK/OOK"): cv.enum(
MODULATION, upper=False
),
cv.Optional(CONF_MANCHESTER, default=False): cv.boolean,
cv.Optional(CONF_NUM_PREAMBLE, default=2): cv.int_range(min=0, max=7),
cv.Optional(CONF_SYNC1, default=0xD3): cv.hex_uint8_t,
cv.Optional(CONF_SYNC0, default=0x91): cv.hex_uint8_t,
cv.Optional(CONF_MAGN_TARGET, default="42dB"): cv.enum(MAGN_TARGET, upper=False),
cv.Optional(CONF_MAX_LNA_GAIN, default="Default"): cv.enum(
MAX_LNA_GAIN, upper=False
),
cv.Optional(CONF_MAX_DVGA_GAIN, default="-3"): cv.enum(MAX_DVGA_GAIN, upper=False),
cv.Optional(CONF_CARRIER_SENSE_ABS_THR): cv.int_range(min=-8, max=7),
cv.Optional(CONF_CARRIER_SENSE_REL_THR): cv.enum(
CARRIER_SENSE_REL_THR, upper=False
),
cv.Optional(CONF_LNA_PRIORITY, default=False): cv.boolean,
cv.Optional(CONF_FILTER_LENGTH_FSK_MSK): cv.enum(
FILTER_LENGTH_FSK_MSK, upper=False
),
cv.Optional(CONF_FILTER_LENGTH_ASK_OOK): cv.enum(
FILTER_LENGTH_ASK_OOK, upper=False
),
cv.Optional(CONF_FREEZE): cv.enum(FREEZE, upper=False),
cv.Optional(CONF_WAIT_TIME, default="32"): cv.enum(WAIT_TIME, upper=False),
cv.Optional(CONF_HYST_LEVEL): cv.enum(HYST_LEVEL, upper=False),
cv.Optional(CONF_PACKET_MODE, default=False): cv.boolean,
cv.Optional(CONF_PACKET_LENGTH): cv.uint8_t,
cv.Optional(CONF_CRC_ENABLE, default=False): cv.boolean,
cv.Optional(CONF_WHITENING, default=False): cv.boolean,
}
CONFIG_SCHEMA = (
cv.Schema({cv.GenerateID(): cv.declare_id(CC1101Component)})
.extend({cv.Optional(key): validator for key, validator in CONFIG_MAP.items()})
def _validate_packet_mode(config):
if config.get(CONF_PACKET_MODE, False):
if CONF_GDO0_PIN not in config:
raise cv.Invalid("gdo0_pin is required when packet_mode is enabled")
if CONF_PACKET_LENGTH not in config:
raise cv.Invalid("packet_length is required when packet_mode is enabled")
if config[CONF_PACKET_LENGTH] > 64:
raise cv.Invalid("packet_length must be <= 64 (FIFO size)")
return config
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(CC1101Component),
cv.Optional(CONF_GDO0_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_ON_PACKET): automation.validate_automation(single=True),
}
)
.extend(CONFIG_MAP)
.extend(cv.COMPONENT_SCHEMA)
.extend(spi.spi_device_schema(cs_pin_required=True))
.extend(spi.spi_device_schema(cs_pin_required=True)),
_validate_packet_mode,
)
@@ -194,16 +251,34 @@ async def to_code(config):
await cg.register_component(var, config)
await spi.register_spi_device(var, config)
for key in CONFIG_MAP:
for opt in CONFIG_MAP:
key = opt.schema
if key in config:
cg.add(getattr(var, f"set_{key}")(config[key]))
if CONF_GDO0_PIN in config:
gdo0_pin = await cg.gpio_pin_expression(config[CONF_GDO0_PIN])
cg.add(var.set_gdo0_pin(gdo0_pin))
if CONF_ON_PACKET in config:
await automation.build_automation(
var.get_packet_trigger(),
[
(cg.std_vector.template(cg.uint8), "x"),
(cg.float_, "rssi"),
(cg.uint8, "lqi"),
],
config[CONF_ON_PACKET],
)
# Actions
BeginTxAction = ns.class_("BeginTxAction", automation.Action)
BeginRxAction = ns.class_("BeginRxAction", automation.Action)
ResetAction = ns.class_("ResetAction", automation.Action)
SetIdleAction = ns.class_("SetIdleAction", automation.Action)
SendPacketAction = ns.class_(
"SendPacketAction", automation.Action, cg.Parented.template(CC1101Component)
)
CC1101_ACTION_SCHEMA = cv.Schema(
maybe_simple_id({cv.GenerateID(CONF_ID): cv.use_id(CC1101Component)})
@@ -218,3 +293,42 @@ async def cc1101_action_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
return var
def validate_raw_data(value):
if isinstance(value, str):
return value.encode("utf-8")
if isinstance(value, list):
return cv.Schema([cv.hex_uint8_t])(value)
raise cv.Invalid(
"data must either be a string wrapped in quotes or a list of bytes"
)
SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value(
{
cv.GenerateID(): cv.use_id(CC1101Component),
cv.Required(CONF_DATA): cv.templatable(validate_raw_data),
},
key=CONF_DATA,
)
@automation.register_action(
"cc1101.send_packet", SendPacketAction, SEND_PACKET_ACTION_SCHEMA
)
async def send_packet_action_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
data = config[CONF_DATA]
if isinstance(data, bytes):
data = list(data)
if cg.is_template(data):
templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8))
cg.add(var.set_data_template(templ))
else:
# Generate static array in flash to avoid RAM copy
arr_id = ID(f"{action_id}_data", is_declaration=True, type=cg.uint8)
arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data))
cg.add(var.set_data_static(arr, len(data)))
return var

View File

@@ -98,25 +98,8 @@ CC1101Component::CC1101Component() {
this->state_.LENGTH_CONFIG = 2;
this->state_.FS_AUTOCAL = 1;
// Default Settings
this->set_frequency(433920);
this->set_if_frequency(153);
this->set_filter_bandwidth(203);
this->set_channel(0);
this->set_channel_spacing(200);
this->set_symbol_rate(5000);
this->set_sync_mode(SyncMode::SYNC_MODE_NONE);
this->set_carrier_sense_above_threshold(true);
this->set_modulation_type(Modulation::MODULATION_ASK_OOK);
this->set_magn_target(MagnTarget::MAGN_TARGET_42DB);
this->set_max_lna_gain(MaxLnaGain::MAX_LNA_GAIN_DEFAULT);
this->set_max_dvga_gain(MaxDvgaGain::MAX_DVGA_GAIN_MINUS_3);
this->set_lna_priority(false);
this->set_wait_time(WaitTime::WAIT_TIME_32_SAMPLES);
// CRITICAL: Initialize PA Table to avoid transmitting 0 power (Silence)
memset(this->pa_table_, 0, sizeof(this->pa_table_));
this->set_output_power(10.0f);
}
void CC1101Component::setup() {
@@ -143,6 +126,11 @@ void CC1101Component::setup() {
return;
}
// Setup GDO0 pin if configured
if (this->gdo0_pin_ != nullptr) {
this->gdo0_pin_->setup();
}
this->initialized_ = true;
for (uint8_t i = 0; i <= static_cast<uint8_t>(Register::TEST0); i++) {
@@ -151,8 +139,70 @@ void CC1101Component::setup() {
}
this->write_(static_cast<Register>(i));
}
this->write_(Register::PATABLE, this->pa_table_, sizeof(this->pa_table_));
this->set_output_power(this->output_power_requested_);
this->strobe_(Command::RX);
// Defer pin mode setup until after all components have completed setup()
// This handles the case where remote_transmitter runs after CC1101 and changes pin mode
if (this->gdo0_pin_ != nullptr) {
this->defer([this]() { this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT); });
}
}
void CC1101Component::loop() {
if (this->state_.PKT_FORMAT != static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO) || this->gdo0_pin_ == nullptr ||
!this->gdo0_pin_->digital_read()) {
return;
}
// Read state
this->read_(Register::RXBYTES);
uint8_t rx_bytes = this->state_.NUM_RXBYTES;
bool overflow = this->state_.RXFIFO_OVERFLOW;
if (overflow || rx_bytes == 0) {
ESP_LOGW(TAG, "RX FIFO overflow, flushing");
this->enter_idle_();
this->strobe_(Command::FRX);
this->strobe_(Command::RX);
this->wait_for_state_(State::RX);
return;
}
// Read packet
uint8_t payload_length, expected_rx;
if (this->state_.LENGTH_CONFIG == static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_VARIABLE)) {
this->read_(Register::FIFO, &payload_length, 1);
expected_rx = payload_length + 1;
} else {
payload_length = this->state_.PKTLEN;
expected_rx = payload_length;
}
if (payload_length == 0 || payload_length > 64 || rx_bytes != expected_rx) {
ESP_LOGW(TAG, "Invalid packet: rx_bytes %u, payload_length %u", rx_bytes, payload_length);
this->enter_idle_();
this->strobe_(Command::FRX);
this->strobe_(Command::RX);
this->wait_for_state_(State::RX);
return;
}
this->packet_.resize(payload_length);
this->read_(Register::FIFO, this->packet_.data(), payload_length);
// Read status from registers (more reliable than FIFO status bytes due to timing issues)
this->read_(Register::RSSI);
this->read_(Register::LQI);
float rssi = (this->state_.RSSI * RSSI_STEP) - RSSI_OFFSET;
bool crc_ok = (this->state_.LQI & STATUS_CRC_OK_MASK) != 0;
uint8_t lqi = this->state_.LQI & STATUS_LQI_MASK;
if (this->state_.CRC_EN == 0 || crc_ok) {
this->packet_trigger_->trigger(this->packet_, rssi, lqi);
}
// Return to rx
this->enter_idle_();
this->strobe_(Command::FRX);
this->strobe_(Command::RX);
this->wait_for_state_(State::RX);
}
void CC1101Component::dump_config() {
@@ -177,9 +227,12 @@ void CC1101Component::dump_config() {
}
void CC1101Component::begin_tx() {
// Ensure Packet Format is 3 (Async Serial), use GDO0 as input during TX
// Ensure Packet Format is 3 (Async Serial)
this->write_(Register::PKTCTRL0, 0x32);
ESP_LOGV(TAG, "Beginning TX sequence");
if (this->gdo0_pin_ != nullptr) {
this->gdo0_pin_->pin_mode(gpio::FLAG_OUTPUT);
}
this->strobe_(Command::TX);
if (!this->wait_for_state_(State::TX, 50)) {
ESP_LOGW(TAG, "Timed out waiting for TX state!");
@@ -188,6 +241,9 @@ void CC1101Component::begin_tx() {
void CC1101Component::begin_rx() {
ESP_LOGV(TAG, "Beginning RX sequence");
if (this->gdo0_pin_ != nullptr) {
this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT);
}
this->strobe_(Command::RX);
}
@@ -201,20 +257,6 @@ void CC1101Component::set_idle() {
this->enter_idle_();
}
void CC1101Component::set_gdo0_config(uint8_t value) {
this->state_.GDO0_CFG = value;
if (this->initialized_) {
this->write_(Register::IOCFG0);
}
}
void CC1101Component::set_gdo2_config(uint8_t value) {
this->state_.GDO2_CFG = value;
if (this->initialized_) {
this->write_(Register::IOCFG2);
}
}
bool CC1101Component::wait_for_state_(State target_state, uint32_t timeout_ms) {
uint32_t start = millis();
while (millis() - start < timeout_ms) {
@@ -282,6 +324,33 @@ void CC1101Component::read_(Register reg, uint8_t *buffer, size_t length) {
this->disable();
}
CC1101Error CC1101Component::transmit_packet(const std::vector<uint8_t> &packet) {
if (this->state_.PKT_FORMAT != static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO)) {
return CC1101Error::PARAMS;
}
// Write packet
this->enter_idle_();
this->strobe_(Command::FTX);
if (this->state_.LENGTH_CONFIG == static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_VARIABLE)) {
this->write_(Register::FIFO, static_cast<uint8_t>(packet.size()));
}
this->write_(Register::FIFO, packet.data(), packet.size());
this->strobe_(Command::TX);
if (!this->wait_for_state_(State::IDLE, 1000)) {
ESP_LOGW(TAG, "TX timeout");
this->enter_idle_();
this->strobe_(Command::RX);
this->wait_for_state_(State::RX);
return CC1101Error::TIMEOUT;
}
// Return to rx
this->strobe_(Command::RX);
this->wait_for_state_(State::RX);
return CC1101Error::NONE;
}
// Setters
void CC1101Component::set_output_power(float value) {
this->output_power_requested_ = value;
@@ -428,6 +497,7 @@ void CC1101Component::set_modulation_type(Modulation value) {
this->state_.PA_POWER = value == Modulation::MODULATION_ASK_OOK ? 1 : 0;
if (this->initialized_) {
this->enter_idle_();
this->set_output_power(this->output_power_requested_);
this->write_(Register::MDMCFG2);
this->write_(Register::FREND0);
this->strobe_(Command::RX);
@@ -462,13 +532,6 @@ void CC1101Component::set_sync0(uint8_t value) {
}
}
void CC1101Component::set_pktlen(uint8_t value) {
this->state_.PKTLEN = value;
if (this->initialized_) {
this->write_(Register::PKTLEN);
}
}
void CC1101Component::set_magn_target(MagnTarget value) {
this->state_.MAGN_TARGET = static_cast<uint8_t>(value);
if (this->initialized_) {
@@ -546,4 +609,53 @@ void CC1101Component::set_hyst_level(HystLevel value) {
}
}
void CC1101Component::set_packet_mode(bool value) {
this->state_.PKT_FORMAT =
static_cast<uint8_t>(value ? PacketFormat::PACKET_FORMAT_FIFO : PacketFormat::PACKET_FORMAT_ASYNC_SERIAL);
if (value) {
// Configure GDO0 for FIFO status (asserts on RX FIFO threshold or end of packet)
this->state_.GDO0_CFG = 0x01;
// Set max RX FIFO threshold to ensure we only trigger on end-of-packet
this->state_.FIFO_THR = 15;
// Don't append status bytes to FIFO - we read from registers instead
this->state_.APPEND_STATUS = 0;
} else {
// Configure GDO0 for serial data (async serial mode)
this->state_.GDO0_CFG = 0x0D;
}
if (this->initialized_) {
this->write_(Register::PKTCTRL0);
this->write_(Register::PKTCTRL1);
this->write_(Register::IOCFG0);
this->write_(Register::FIFOTHR);
}
}
void CC1101Component::set_packet_length(uint8_t value) {
if (value == 0) {
this->state_.LENGTH_CONFIG = static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_VARIABLE);
} else {
this->state_.LENGTH_CONFIG = static_cast<uint8_t>(LengthConfig::LENGTH_CONFIG_FIXED);
this->state_.PKTLEN = value;
}
if (this->initialized_) {
this->write_(Register::PKTCTRL0);
this->write_(Register::PKTLEN);
}
}
void CC1101Component::set_crc_enable(bool value) {
this->state_.CRC_EN = value ? 1 : 0;
if (this->initialized_) {
this->write_(Register::PKTCTRL0);
}
}
void CC1101Component::set_whitening(bool value) {
this->state_.WHITE_DATA = value ? 1 : 0;
if (this->initialized_) {
this->write_(Register::PKTCTRL0);
}
}
} // namespace esphome::cc1101

View File

@@ -5,9 +5,12 @@
#include "esphome/components/spi/spi.h"
#include "esphome/core/automation.h"
#include "cc1101defs.h"
#include <vector>
namespace esphome::cc1101 {
enum class CC1101Error { NONE = 0, TIMEOUT, PARAMS, CRC_ERROR, FIFO_OVERFLOW };
class CC1101Component : public Component,
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW,
spi::CLOCK_PHASE_LEADING, spi::DATA_RATE_1MHZ> {
@@ -15,6 +18,7 @@ class CC1101Component : public Component,
CC1101Component();
void setup() override;
void loop() override;
void dump_config() override;
// Actions
@@ -24,8 +28,7 @@ class CC1101Component : public Component,
void set_idle();
// GDO Pin Configuration
void set_gdo0_config(uint8_t value);
void set_gdo2_config(uint8_t value);
void set_gdo0_pin(InternalGPIOPin *pin) { this->gdo0_pin_ = pin; }
// Configuration Setters
void set_output_power(float value);
@@ -48,7 +51,6 @@ class CC1101Component : public Component,
void set_num_preamble(uint8_t value);
void set_sync1(uint8_t value);
void set_sync0(uint8_t value);
void set_pktlen(uint8_t value);
// AGC settings
void set_magn_target(MagnTarget value);
@@ -63,6 +65,16 @@ class CC1101Component : public Component,
void set_wait_time(WaitTime value);
void set_hyst_level(HystLevel value);
// Packet mode settings
void set_packet_mode(bool value);
void set_packet_length(uint8_t value);
void set_crc_enable(bool value);
void set_whitening(bool value);
// Packet mode operations
CC1101Error transmit_packet(const std::vector<uint8_t> &packet);
Trigger<std::vector<uint8_t>, float, uint8_t> *get_packet_trigger() const { return this->packet_trigger_; }
protected:
uint16_t chip_id_{0};
bool initialized_{false};
@@ -73,6 +85,13 @@ class CC1101Component : public Component,
CC1101State state_;
// GDO pin for packet reception
InternalGPIOPin *gdo0_pin_{nullptr};
// Packet handling
Trigger<std::vector<uint8_t>, float, uint8_t> *packet_trigger_{new Trigger<std::vector<uint8_t>, float, uint8_t>()};
std::vector<uint8_t> packet_;
// Low-level Helpers
uint8_t strobe_(Command cmd);
void write_(Register reg);
@@ -107,4 +126,28 @@ template<typename... Ts> class SetIdleAction : public Action<Ts...>, public Pare
void play(const Ts &...x) override { this->parent_->set_idle(); }
};
template<typename... Ts> class SendPacketAction : public Action<Ts...>, public Parented<CC1101Component> {
public:
void set_data_template(std::function<std::vector<uint8_t>(Ts...)> func) { this->data_func_ = func; }
void set_data_static(const uint8_t *data, size_t len) {
this->data_static_ = data;
this->data_static_len_ = len;
}
void play(const Ts &...x) override {
if (this->data_func_) {
auto data = this->data_func_(x...);
this->parent_->transmit_packet(data);
} else if (this->data_static_ != nullptr) {
std::vector<uint8_t> data(this->data_static_, this->data_static_ + this->data_static_len_);
this->parent_->transmit_packet(data);
}
}
protected:
std::function<std::vector<uint8_t>(Ts...)> data_func_{};
const uint8_t *data_static_{nullptr};
size_t data_static_len_{0};
};
} // namespace esphome::cc1101

View File

@@ -6,6 +6,12 @@ namespace esphome::cc1101 {
static constexpr float XTAL_FREQUENCY = 26000000;
static constexpr float RSSI_OFFSET = 74.0f;
static constexpr float RSSI_STEP = 0.5f;
static constexpr uint8_t STATUS_CRC_OK_MASK = 0x80;
static constexpr uint8_t STATUS_LQI_MASK = 0x7F;
static constexpr uint8_t BUS_BURST = 0x40;
static constexpr uint8_t BUS_READ = 0x80;
static constexpr uint8_t BUS_WRITE = 0x00;
@@ -134,6 +140,10 @@ enum class SyncMode : uint8_t {
SYNC_MODE_15_16,
SYNC_MODE_16_16,
SYNC_MODE_30_32,
SYNC_MODE_NONE_CS,
SYNC_MODE_15_16_CS,
SYNC_MODE_16_16_CS,
SYNC_MODE_30_32_CS,
};
enum class Modulation : uint8_t {
@@ -218,6 +228,19 @@ enum class HystLevel : uint8_t {
HYST_LEVEL_HIGH,
};
enum class PacketFormat : uint8_t {
PACKET_FORMAT_FIFO,
PACKET_FORMAT_SYNC_SERIAL,
PACKET_FORMAT_RANDOM_TX,
PACKET_FORMAT_ASYNC_SERIAL,
};
enum class LengthConfig : uint8_t {
LENGTH_CONFIG_FIXED,
LENGTH_CONFIG_VARIABLE,
LENGTH_CONFIG_INFINITE,
};
struct __attribute__((packed)) CC1101State {
// Byte array accessors for bulk SPI transfers
uint8_t *regs() { return reinterpret_cast<uint8_t *>(this); }

View File

@@ -7,9 +7,11 @@ BYTE_ORDER_LITTLE = "little_endian"
BYTE_ORDER_BIG = "big_endian"
CONF_COLOR_DEPTH = "color_depth"
CONF_CRC_ENABLE = "crc_enable"
CONF_DRAW_ROUNDING = "draw_rounding"
CONF_ENABLED = "enabled"
CONF_IGNORE_NOT_FOUND = "ignore_not_found"
CONF_ON_PACKET = "on_packet"
CONF_ON_RECEIVE = "on_receive"
CONF_ON_STATE_CHANGE = "on_state_change"
CONF_REQUEST_HEADERS = "request_headers"

View File

@@ -54,6 +54,7 @@ bool MenuItemSelect::select_next() {
if (this->select_var_ != nullptr) {
this->select_var_->make_call().select_next(true).perform();
this->on_value_();
changed = true;
}
@@ -65,6 +66,7 @@ bool MenuItemSelect::select_prev() {
if (this->select_var_ != nullptr) {
this->select_var_->make_call().select_previous(true).perform();
this->on_value_();
changed = true;
}

View File

@@ -41,6 +41,7 @@ AUTO_LOAD = ["split_buffer"]
DEPENDENCIES = ["spi"]
CONF_INIT_SEQUENCE_ID = "init_sequence_id"
CONF_MINIMUM_UPDATE_INTERVAL = "minimum_update_interval"
epaper_spi_ns = cg.esphome_ns.namespace("epaper_spi")
EPaperBase = epaper_spi_ns.class_(
@@ -71,6 +72,9 @@ TRANSFORM_OPTIONS = {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY}
def model_schema(config):
model = MODELS[config[CONF_MODEL]]
class_name = epaper_spi_ns.class_(model.class_name, EPaperBase)
minimum_update_interval = update_interval(
model.get_default(CONF_MINIMUM_UPDATE_INTERVAL, "1s")
)
cv_dimensions = cv.Optional if model.get_default(CONF_WIDTH) else cv.Required
return (
display.FULL_DISPLAY_SCHEMA.extend(
@@ -90,9 +94,9 @@ def model_schema(config):
{
cv.Optional(CONF_ROTATION, default=0): validate_rotation,
cv.Required(CONF_MODEL): cv.one_of(model.name, upper=True),
cv.Optional(
CONF_UPDATE_INTERVAL, default=cv.UNDEFINED
): update_interval,
cv.Optional(CONF_UPDATE_INTERVAL, default=cv.UNDEFINED): cv.All(
update_interval, cv.Range(min=minimum_update_interval)
),
cv.Optional(CONF_TRANSFORM): cv.Schema(
{
cv.Required(CONF_MIRROR_X): cv.boolean,
@@ -153,9 +157,8 @@ def _final_validate(config):
else:
# If no drawing methods are configured, and LVGL is not enabled, show a test card
config[CONF_SHOW_TEST_CARD] = True
config[CONF_UPDATE_INTERVAL] = core.TimePeriod(
seconds=60
).total_milliseconds
elif CONF_UPDATE_INTERVAL not in config:
config[CONF_UPDATE_INTERVAL] = update_interval("1min")
return config

View File

@@ -286,7 +286,7 @@ void EPaperBase::initialise_() {
* @param y
* @return false if the coordinates are out of bounds
*/
bool EPaperBase::rotate_coordinates_(int &x, int &y) const {
bool EPaperBase::rotate_coordinates_(int &x, int &y) {
if (!this->get_clipping().inside(x, y))
return false;
if (this->transform_ & SWAP_XY)
@@ -297,6 +297,10 @@ bool EPaperBase::rotate_coordinates_(int &x, int &y) const {
y = this->height_ - y - 1;
if (x >= this->width_ || y >= this->height_ || x < 0 || y < 0)
return false;
this->x_low_ = clamp_at_most(this->x_low_, x);
this->x_high_ = clamp_at_least(this->x_high_, x + 1);
this->y_low_ = clamp_at_most(this->y_low_, y);
this->y_high_ = clamp_at_least(this->y_high_, y + 1);
return true;
}
@@ -319,10 +323,6 @@ void HOT EPaperBase::draw_pixel_at(int x, int y, Color color) {
} else {
this->buffer_[byte_position] = original | pixel_bit;
}
this->x_low_ = clamp_at_most(this->x_low_, x);
this->x_high_ = clamp_at_least(this->x_high_, x + 1);
this->y_low_ = clamp_at_most(this->y_low_, y);
this->y_high_ = clamp_at_least(this->y_high_, y + 1);
}
void EPaperBase::dump_config() {

View File

@@ -106,7 +106,7 @@ class EPaperBase : public Display,
void initialise_();
void wait_for_idle_(bool should_wait);
bool init_buffer_(size_t buffer_length);
bool rotate_coordinates_(int &x, int &y) const;
bool rotate_coordinates_(int &x, int &y);
/**
* Methods that must be implemented by concrete classes to control the display

View File

@@ -4,8 +4,8 @@ from . import EpaperModel
class SpectraE6(EpaperModel):
def __init__(self, name, class_name="EPaperSpectraE6", **kwargs):
super().__init__(name, class_name, **kwargs)
def __init__(self, name, class_name="EPaperSpectraE6", **defaults):
super().__init__(name, class_name, **defaults)
# fmt: off
def get_init_sequence(self, config: dict):
@@ -30,7 +30,7 @@ class SpectraE6(EpaperModel):
return self.defaults.get(key, fallback)
spectra_e6 = SpectraE6("spectra-e6")
spectra_e6 = SpectraE6("spectra-e6", minimum_update_interval="30s")
spectra_e6_7p3 = spectra_e6.extend(
"7.3in-Spectra-E6",

View File

@@ -4,6 +4,7 @@ import itertools
import logging
import os
from pathlib import Path
import re
from esphome import yaml_util
import esphome.codegen as cg
@@ -616,10 +617,13 @@ def require_vfs_dir() -> None:
def _parse_idf_component(value: str) -> ConfigType:
"""Parse IDF component shorthand syntax like 'owner/component^version'"""
if "^" not in value:
raise cv.Invalid(f"Invalid IDF component shorthand '{value}'")
name, ref = value.split("^", 1)
return {CONF_NAME: name, CONF_REF: ref}
# Match operator followed by version-like string (digit or *)
if match := re.search(r"(~=|>=|<=|==|!=|>|<|\^|~)(\d|\*)", value):
return {CONF_NAME: value[: match.start()], CONF_REF: value[match.start() :]}
raise cv.Invalid(
f"Invalid IDF component shorthand '{value}'. "
f"Expected format: 'owner/component<op>version' where <op> is one of: ^, ~, ~=, ==, !=, >=, >, <=, <"
)
def _validate_idf_component(config: ConfigType) -> ConfigType:

View File

@@ -308,13 +308,21 @@ bool ESP32BLE::ble_setup_() {
bool ESP32BLE::ble_dismantle_() {
esp_err_t err = esp_bluedroid_disable();
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_bluedroid_disable failed: %d", err);
return false;
// ESP_ERR_INVALID_STATE means Bluedroid is already disabled, which is fine
if (err != ESP_ERR_INVALID_STATE) {
ESP_LOGE(TAG, "esp_bluedroid_disable failed: %d", err);
return false;
}
ESP_LOGD(TAG, "Already disabled");
}
err = esp_bluedroid_deinit();
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_bluedroid_deinit failed: %d", err);
return false;
// ESP_ERR_INVALID_STATE means Bluedroid is already deinitialized, which is fine
if (err != ESP_ERR_INVALID_STATE) {
ESP_LOGE(TAG, "esp_bluedroid_deinit failed: %d", err);
return false;
}
ESP_LOGD(TAG, "Already deinitialized");
}
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID

View File

@@ -212,17 +212,23 @@ extern ESP32BLE *global_ble;
template<typename... Ts> class BLEEnabledCondition : public Condition<Ts...> {
public:
bool check(const Ts &...x) override { return global_ble->is_active(); }
bool check(const Ts &...x) override { return global_ble != nullptr && global_ble->is_active(); }
};
template<typename... Ts> class BLEEnableAction : public Action<Ts...> {
public:
void play(const Ts &...x) override { global_ble->enable(); }
void play(const Ts &...x) override {
if (global_ble != nullptr)
global_ble->enable();
}
};
template<typename... Ts> class BLEDisableAction : public Action<Ts...> {
public:
void play(const Ts &...x) override { global_ble->disable(); }
void play(const Ts &...x) override {
if (global_ble != nullptr)
global_ble->disable();
}
};
} // namespace esphome::esp32_ble

View File

@@ -185,7 +185,10 @@ void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_();
void ESP32BLETracker::stop_scan_() {
if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) {
ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_));
// If scanner is already idle, there's nothing to stop - this is not an error
if (this->scanner_state_ != ScannerState::IDLE) {
ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_));
}
return;
}
// Reset timeout state machine when stopping scan

View File

@@ -3,7 +3,7 @@ import logging
from esphome import automation, pins
import esphome.codegen as cg
from esphome.components import i2c
from esphome.components.esp32 import add_idf_component
from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option
from esphome.components.psram import DOMAIN as psram_domain
import esphome.config_validation as cv
from esphome.const import (
@@ -186,7 +186,7 @@ CONFIG_SCHEMA = cv.All(
{
cv.Required(CONF_PIN): pins.internal_gpio_input_pin_number,
cv.Optional(CONF_FREQUENCY, default="20MHz"): cv.All(
cv.frequency, cv.Range(min=8e6, max=20e6)
cv.frequency, cv.float_range(min=8e6, max=20e6)
),
}
),
@@ -352,6 +352,8 @@ async def to_code(config):
cg.add_define("USE_CAMERA")
add_idf_component(name="espressif/esp32-camera", ref="2.1.1")
add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_NEW", True)
add_idf_sdkconfig_option("CONFIG_SCCB_HARDWARE_I2C_DRIVER_LEGACY", False)
for conf in config.get(CONF_ON_STREAM_START, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)

View File

@@ -11,6 +11,9 @@ namespace esphome {
namespace esp32_camera {
static const char *const TAG = "esp32_camera";
#if ESPHOME_LOG_LEVEL < ESPHOME_LOG_LEVEL_VERBOSE
static constexpr uint32_t FRAME_LOG_INTERVAL_MS = 60000;
#endif
/* ---------------- public API (derivated) ---------------- */
void ESP32Camera::setup() {
@@ -204,7 +207,20 @@ void ESP32Camera::loop() {
}
this->current_image_ = std::make_shared<ESP32CameraImage>(fb, this->single_requesters_ | this->stream_requesters_);
ESP_LOGD(TAG, "Got Image: len=%u", fb->len);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
ESP_LOGV(TAG, "Got Image: len=%u", fb->len);
#else
// Initialize log time on first frame to ensure accurate interval measurement
if (this->frame_count_ == 0) {
this->last_log_time_ = now;
}
this->frame_count_++;
if (now - this->last_log_time_ >= FRAME_LOG_INTERVAL_MS) {
ESP_LOGD(TAG, "Received %u images in last %us", this->frame_count_, FRAME_LOG_INTERVAL_MS / 1000);
this->last_log_time_ = now;
this->frame_count_ = 0;
}
#endif
for (auto *listener : this->listeners_) {
listener->on_camera_image(this->current_image_);
}

View File

@@ -213,6 +213,10 @@ class ESP32Camera : public camera::Camera {
uint32_t last_idle_request_{0};
uint32_t last_update_{0};
#if ESPHOME_LOG_LEVEL < ESPHOME_LOG_LEVEL_VERBOSE
uint32_t last_log_time_{0};
uint16_t frame_count_{0};
#endif
#ifdef USE_I2C
i2c::InternalI2CBus *i2c_bus_{nullptr};
#endif // USE_I2C

View File

@@ -16,7 +16,7 @@ def valid_pwm_pin(value):
esp8266_pwm_ns = cg.esphome_ns.namespace("esp8266_pwm")
ESP8266PWM = esp8266_pwm_ns.class_("ESP8266PWM", output.FloatOutput, cg.Component)
SetFrequencyAction = esp8266_pwm_ns.class_("SetFrequencyAction", automation.Action)
validate_frequency = cv.All(cv.frequency, cv.Range(min=1.0e-6))
validate_frequency = cv.All(cv.frequency, cv.float_range(min=1.0e-6))
CONFIG_SCHEMA = cv.All(
output.FLOAT_OUTPUT_SCHEMA.extend(

View File

@@ -13,7 +13,7 @@ static const char *const TAG = "espnow.transport";
bool ESPNowTransport::should_send() { return this->parent_ != nullptr && !this->parent_->is_failed(); }
void ESPNowTransport::setup() {
packet_transport::PacketTransport::setup();
PacketTransport::setup();
if (this->parent_ == nullptr) {
ESP_LOGE(TAG, "ESPNow component not set");
@@ -26,15 +26,10 @@ void ESPNowTransport::setup() {
this->peer_address_[2], this->peer_address_[3], this->peer_address_[4], this->peer_address_[5]);
// Register received handler
this->parent_->register_received_handler(static_cast<ESPNowReceivedPacketHandler *>(this));
this->parent_->register_received_handler(this);
// Register broadcasted handler
this->parent_->register_broadcasted_handler(static_cast<ESPNowBroadcastedHandler *>(this));
}
void ESPNowTransport::update() {
packet_transport::PacketTransport::update();
this->updated_ = true;
this->parent_->register_broadcasted_handler(this);
}
void ESPNowTransport::send_packet(const std::vector<uint8_t> &buf) const {

View File

@@ -18,7 +18,6 @@ class ESPNowTransport : public packet_transport::PacketTransport,
public ESPNowBroadcastedHandler {
public:
void setup() override;
void update() override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
void set_peer_address(peer_address_t address) {

View File

@@ -434,10 +434,13 @@ def _final_validate_rmii_pins(config: ConfigType) -> None:
# Check all used pins against RMII reserved pins
for pin_list in pins.PIN_SCHEMA_REGISTRY.pins_used.values():
for pin_path, _, pin_config in pin_list:
for pin_path, pin_device, pin_config in pin_list:
pin_num = pin_config.get(CONF_NUMBER)
if pin_num not in rmii_pins:
continue
# Skip if pin is not directly on ESP, but at some expander (device set to something else than 'None')
if pin_device is not None:
continue
# Found a conflict - show helpful error message
pin_function = rmii_pins[pin_num]
component_path = ".".join(str(p) for p in pin_path)

View File

@@ -255,6 +255,9 @@ template<typename... Ts> class HttpRequestSendAction : public Action<Ts...> {
size_t read_index = 0;
while (container->get_bytes_read() < max_length) {
int read = container->read(buf + read_index, std::min<size_t>(max_length - read_index, 512));
if (read <= 0) {
break;
}
App.feed_wdt();
yield();
read_index += read;

View File

@@ -132,11 +132,18 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
App.feed_wdt();
yield();
if (bufsize < 0) {
ESP_LOGE(TAG, "Stream closed");
this->cleanup_(std::move(backend), container);
return OTA_CONNECTION_ERROR;
} else if (bufsize > 0 && bufsize <= OtaHttpRequestComponent::HTTP_RECV_BUFFER) {
// Exit loop if no data available (stream closed or end of data)
if (bufsize <= 0) {
if (bufsize < 0) {
ESP_LOGE(TAG, "Stream closed with error");
this->cleanup_(std::move(backend), container);
return OTA_CONNECTION_ERROR;
}
// bufsize == 0: no more data available, exit loop
break;
}
if (bufsize <= OtaHttpRequestComponent::HTTP_RECV_BUFFER) {
// add read bytes to MD5
md5_receive.add(buf, bufsize);
@@ -247,6 +254,9 @@ bool OtaHttpRequestComponent::http_get_md5_() {
int read_len = 0;
while (container->get_bytes_read() < MD5_SIZE) {
read_len = container->read((uint8_t *) this->md5_expected_.data(), MD5_SIZE);
if (read_len <= 0) {
break;
}
App.feed_wdt();
yield();
}

View File

@@ -36,6 +36,10 @@ void HttpRequestUpdate::setup() {
}
void HttpRequestUpdate::update() {
if (!network::is_connected()) {
ESP_LOGD(TAG, "Network not connected, skipping update check");
return;
}
#ifdef USE_ESP32
xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, (void *) this, 1, &this->update_task_handle_);
#else
@@ -72,6 +76,11 @@ void HttpRequestUpdate::update_task(void *params) {
yield();
if (read_bytes <= 0) {
// Network error or connection closed - break to avoid infinite loop
break;
}
read_index += read_bytes;
}

View File

@@ -93,35 +93,35 @@ CONF_DOUBLE_BUFFER = "double_buffer"
CONF_MIN_REFRESH_RATE = "min_refresh_rate"
# Map to hub75 library enums (in global namespace)
ShiftDriver = cg.global_ns.enum("ShiftDriver", is_class=True)
Hub75ShiftDriver = cg.global_ns.enum("Hub75ShiftDriver", is_class=True)
SHIFT_DRIVERS = {
"GENERIC": ShiftDriver.GENERIC,
"FM6126A": ShiftDriver.FM6126A,
"ICN2038S": ShiftDriver.ICN2038S,
"FM6124": ShiftDriver.FM6124,
"MBI5124": ShiftDriver.MBI5124,
"DP3246": ShiftDriver.DP3246,
"GENERIC": Hub75ShiftDriver.GENERIC,
"FM6126A": Hub75ShiftDriver.FM6126A,
"ICN2038S": Hub75ShiftDriver.ICN2038S,
"FM6124": Hub75ShiftDriver.FM6124,
"MBI5124": Hub75ShiftDriver.MBI5124,
"DP3246": Hub75ShiftDriver.DP3246,
}
PanelLayout = cg.global_ns.enum("PanelLayout", is_class=True)
Hub75PanelLayout = cg.global_ns.enum("Hub75PanelLayout", is_class=True)
PANEL_LAYOUTS = {
"HORIZONTAL": PanelLayout.HORIZONTAL,
"TOP_LEFT_DOWN": PanelLayout.TOP_LEFT_DOWN,
"TOP_RIGHT_DOWN": PanelLayout.TOP_RIGHT_DOWN,
"BOTTOM_LEFT_UP": PanelLayout.BOTTOM_LEFT_UP,
"BOTTOM_RIGHT_UP": PanelLayout.BOTTOM_RIGHT_UP,
"TOP_LEFT_DOWN_ZIGZAG": PanelLayout.TOP_LEFT_DOWN_ZIGZAG,
"TOP_RIGHT_DOWN_ZIGZAG": PanelLayout.TOP_RIGHT_DOWN_ZIGZAG,
"BOTTOM_LEFT_UP_ZIGZAG": PanelLayout.BOTTOM_LEFT_UP_ZIGZAG,
"BOTTOM_RIGHT_UP_ZIGZAG": PanelLayout.BOTTOM_RIGHT_UP_ZIGZAG,
"HORIZONTAL": Hub75PanelLayout.HORIZONTAL,
"TOP_LEFT_DOWN": Hub75PanelLayout.TOP_LEFT_DOWN,
"TOP_RIGHT_DOWN": Hub75PanelLayout.TOP_RIGHT_DOWN,
"BOTTOM_LEFT_UP": Hub75PanelLayout.BOTTOM_LEFT_UP,
"BOTTOM_RIGHT_UP": Hub75PanelLayout.BOTTOM_RIGHT_UP,
"TOP_LEFT_DOWN_ZIGZAG": Hub75PanelLayout.TOP_LEFT_DOWN_ZIGZAG,
"TOP_RIGHT_DOWN_ZIGZAG": Hub75PanelLayout.TOP_RIGHT_DOWN_ZIGZAG,
"BOTTOM_LEFT_UP_ZIGZAG": Hub75PanelLayout.BOTTOM_LEFT_UP_ZIGZAG,
"BOTTOM_RIGHT_UP_ZIGZAG": Hub75PanelLayout.BOTTOM_RIGHT_UP_ZIGZAG,
}
ScanPattern = cg.global_ns.enum("ScanPattern", is_class=True)
Hub75ScanWiring = cg.global_ns.enum("Hub75ScanWiring", is_class=True)
SCAN_PATTERNS = {
"STANDARD_TWO_SCAN": ScanPattern.STANDARD_TWO_SCAN,
"FOUR_SCAN_16PX_HIGH": ScanPattern.FOUR_SCAN_16PX_HIGH,
"FOUR_SCAN_32PX_HIGH": ScanPattern.FOUR_SCAN_32PX_HIGH,
"FOUR_SCAN_64PX_HIGH": ScanPattern.FOUR_SCAN_64PX_HIGH,
"STANDARD_TWO_SCAN": Hub75ScanWiring.STANDARD_TWO_SCAN,
"FOUR_SCAN_16PX_HIGH": Hub75ScanWiring.FOUR_SCAN_16PX_HIGH,
"FOUR_SCAN_32PX_HIGH": Hub75ScanWiring.FOUR_SCAN_32PX_HIGH,
"FOUR_SCAN_64PX_HIGH": Hub75ScanWiring.FOUR_SCAN_64PX_HIGH,
}
Hub75ClockSpeed = cg.global_ns.enum("Hub75ClockSpeed", is_class=True)
@@ -528,7 +528,7 @@ def _build_config_struct(
async def to_code(config: ConfigType) -> None:
add_idf_component(
name="esphome/esp-hub75",
ref="0.1.6",
ref="0.1.7",
)
# Set compile-time configuration via defines

View File

@@ -111,6 +111,9 @@ void HOT HUB75Display::draw_pixel_at(int x, int y, Color color) {
if (x >= this->get_width_internal() || x < 0 || y >= this->get_height_internal() || y < 0) [[unlikely]]
return;
if (!this->get_clipping().inside(x, y))
return;
driver_->set_pixel(x, y, color.r, color.g, color.b);
App.feed_wdt();
}

View File

@@ -121,7 +121,7 @@ CONFIG_SCHEMA = cv.All(
nrf52="100kHz",
): cv.All(
cv.frequency,
cv.Range(min=0, min_included=False),
cv.float_range(min=0, min_included=False),
),
cv.Optional(CONF_TIMEOUT): cv.All(
cv.only_with_framework(["arduino", "esp-idf"]),

View File

@@ -45,7 +45,9 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
{
cv.Required(CONF_ID): cv.declare_id(LEDCOutput),
cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema,
cv.Optional(CONF_FREQUENCY, default="1kHz"): cv.frequency,
cv.Optional(CONF_FREQUENCY, default="1kHz"): cv.All(
cv.frequency, cv.float_range(min=0, min_included=False)
),
cv.Optional(CONF_CHANNEL): cv.int_range(min=0, max=15),
cv.Optional(CONF_PHASE_ANGLE): cv.All(
cv.only_with_esp_idf, cv.angle, cv.float_range(min=0.0, max=360.0)

View File

@@ -14,7 +14,9 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
{
cv.Required(CONF_ID): cv.declare_id(LibreTinyPWM),
cv.Required(CONF_PIN): pins.internal_gpio_output_pin_schema,
cv.Optional(CONF_FREQUENCY, default="1kHz"): cv.frequency,
cv.Optional(CONF_FREQUENCY, default="1kHz"): cv.All(
cv.frequency, cv.float_range(min=0, min_included=False)
),
}
).extend(cv.COMPONENT_SCHEMA)

View File

@@ -504,8 +504,8 @@ color_mode_bitmask_t LightCall::get_suitable_color_modes_mask_() {
#undef KEY
}
LightCall &LightCall::set_effect(const std::string &effect) {
if (strcasecmp(effect.c_str(), "none") == 0) {
LightCall &LightCall::set_effect(const char *effect, size_t len) {
if (len == 4 && strncasecmp(effect, "none", 4) == 0) {
this->set_effect(0);
return *this;
}
@@ -513,15 +513,16 @@ LightCall &LightCall::set_effect(const std::string &effect) {
bool found = false;
for (uint32_t i = 0; i < this->parent_->effects_.size(); i++) {
LightEffect *e = this->parent_->effects_[i];
const char *name = e->get_name();
if (strcasecmp(effect.c_str(), e->get_name()) == 0) {
if (strncasecmp(effect, name, len) == 0 && name[len] == '\0') {
this->set_effect(i + 1);
found = true;
break;
}
}
if (!found) {
ESP_LOGW(TAG, "'%s': no such effect '%s'", this->parent_->get_name().c_str(), effect.c_str());
ESP_LOGW(TAG, "'%s': no such effect '%.*s'", this->parent_->get_name().c_str(), (int) len, effect);
}
return *this;
}

View File

@@ -129,7 +129,9 @@ class LightCall {
/// Set the effect of the light by its name.
LightCall &set_effect(optional<std::string> effect);
/// Set the effect of the light by its name.
LightCall &set_effect(const std::string &effect);
LightCall &set_effect(const std::string &effect) { return this->set_effect(effect.data(), effect.size()); }
/// Set the effect of the light by its name and length (zero-copy from API).
LightCall &set_effect(const char *effect, size_t len);
/// Set the effect of the light by its internal index number (only for internal use).
LightCall &set_effect(uint32_t effect_number);
LightCall &set_effect(optional<uint32_t> effect_number);

View File

@@ -5,7 +5,7 @@ Constants already defined in esphome.const are not duplicated here and must be i
"""
import logging
from typing import TYPE_CHECKING, Any
from typing import Any
from esphome import codegen as cg, config_validation as cv
from esphome.const import CONF_ITEMS
@@ -96,13 +96,9 @@ class LValidator:
return None
if isinstance(value, Lambda):
# Local import to avoid circular import
from .lvcode import CodeContext, LambdaContext
from .lvcode import get_lambda_context_args
if TYPE_CHECKING:
# CodeContext does not have get_automation_parameters
# so we need to assert the type here
assert isinstance(CodeContext.code_context, LambdaContext)
args = args or CodeContext.code_context.get_automation_parameters()
args = args or get_lambda_context_args()
return cg.RawExpression(
call_lambda(
await cg.process_lambda(value, args, return_type=self.rtype)

View File

@@ -1,5 +1,5 @@
import re
from typing import TYPE_CHECKING, Any
from typing import Any
import esphome.codegen as cg
from esphome.components import image
@@ -404,14 +404,9 @@ class TextValidator(LValidator):
self, value: Any, args: list[tuple[SafeExpType, str]] | None = None
) -> Expression:
# Local import to avoid circular import at module level
from .lvcode import get_lambda_context_args
from .lvcode import CodeContext, LambdaContext
if TYPE_CHECKING:
# CodeContext does not have get_automation_parameters
# so we need to assert the type here
assert isinstance(CodeContext.code_context, LambdaContext)
args = args or CodeContext.code_context.get_automation_parameters()
args = args or get_lambda_context_args()
if isinstance(value, dict):
if format_str := value.get(CONF_FORMAT):

View File

@@ -1,4 +1,5 @@
import abc
from typing import TYPE_CHECKING
from esphome import codegen as cg
from esphome.config import Config
@@ -200,6 +201,21 @@ class LvContext(LambdaContext):
return self.add(*args)
def get_lambda_context_args() -> list[tuple[SafeExpType, str]]:
"""Get automation parameters from the current lambda context if available.
When called from outside LVGL's context (e.g., from interval),
CodeContext.code_context will be None, so return empty args.
"""
if CodeContext.code_context is None:
return []
if TYPE_CHECKING:
# CodeContext base class doesn't define get_automation_parameters(),
# but LambdaContext and LvContext (the concrete implementations) do.
assert isinstance(CodeContext.code_context, LambdaContext)
return CodeContext.code_context.get_automation_parameters()
class LocalVariable(MockObj):
"""
Create a local variable and enclose the code using it within a block.

View File

@@ -498,12 +498,12 @@ void LvglComponent::setup() {
buf_bytes /= MIN_BUFFER_FRAC;
buffer = lv_custom_mem_alloc(buf_bytes); // NOLINT
}
this->buffer_frac_ = frac;
if (buffer == nullptr) {
this->status_set_error(LOG_STR("Memory allocation failure"));
this->mark_failed();
return;
}
this->buffer_frac_ = frac;
lv_disp_draw_buf_init(&this->draw_buf_, buffer, nullptr, buffer_pixels);
this->disp_drv_.hor_res = display->get_width();
this->disp_drv_.ver_res = display->get_height();

View File

@@ -24,7 +24,7 @@ from esphome.components.mipi import (
CONF_VSYNC_BACK_PORCH,
CONF_VSYNC_FRONT_PORCH,
CONF_VSYNC_PULSE_WIDTH,
MODE_BGR,
MODE_RGB,
PIXEL_MODE_16BIT,
PIXEL_MODE_18BIT,
DriverChip,
@@ -157,7 +157,7 @@ def model_schema(config):
model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list(
pins.gpio_output_pin_schema
),
model.option(CONF_COLOR_ORDER, MODE_BGR): cv.enum(COLOR_ORDERS, upper=True),
model.option(CONF_COLOR_ORDER, MODE_RGB): cv.enum(COLOR_ORDERS, upper=True),
model.option(CONF_DRAW_ROUNDING, 2): power_of_two,
model.option(CONF_PIXEL_MODE, PIXEL_MODE_16BIT): cv.one_of(
*pixel_modes, lower=True
@@ -280,14 +280,9 @@ async def to_code(config):
red_pins = config[CONF_DATA_PINS][CONF_RED]
green_pins = config[CONF_DATA_PINS][CONF_GREEN]
blue_pins = config[CONF_DATA_PINS][CONF_BLUE]
if config[CONF_COLOR_ORDER] == "BGR":
dpins.extend(red_pins)
dpins.extend(green_pins)
dpins.extend(blue_pins)
else:
dpins.extend(blue_pins)
dpins.extend(green_pins)
dpins.extend(red_pins)
dpins.extend(blue_pins)
dpins.extend(green_pins)
dpins.extend(red_pins)
# swap bytes to match big-endian format
dpins = dpins[8:16] + dpins[0:8]
else:

View File

@@ -371,17 +371,10 @@ void MipiRgb::dump_config() {
get_pin_name(this->de_pin_).c_str(), get_pin_name(this->pclk_pin_).c_str(),
get_pin_name(this->hsync_pin_).c_str(), get_pin_name(this->vsync_pin_).c_str());
if (this->madctl_ & MADCTL_BGR) {
this->dump_pins_(8, 13, "Blue", 0);
this->dump_pins_(13, 16, "Green", 0);
this->dump_pins_(0, 3, "Green", 3);
this->dump_pins_(3, 8, "Red", 0);
} else {
this->dump_pins_(8, 13, "Red", 0);
this->dump_pins_(13, 16, "Green", 0);
this->dump_pins_(0, 3, "Green", 3);
this->dump_pins_(3, 8, "Blue", 0);
}
this->dump_pins_(8, 13, "Blue", 0);
this->dump_pins_(13, 16, "Green", 0);
this->dump_pins_(0, 3, "Green", 3);
this->dump_pins_(3, 8, "Red", 0);
}
} // namespace mipi_rgb

View File

@@ -7,7 +7,6 @@ ST7701S(
"T-PANEL-S3",
width=480,
height=480,
color_order="BGR",
invert_colors=False,
swap_xy=UNDEFINED,
spi_mode="MODE3",
@@ -56,7 +55,6 @@ t_rgb = ST7701S(
"T-RGB-2.1",
width=480,
height=480,
color_order="BGR",
pixel_mode="18bit",
invert_colors=False,
swap_xy=UNDEFINED,

View File

@@ -82,7 +82,6 @@ st7701s.extend(
"MAKERFABS-4",
width=480,
height=480,
color_order="RGB",
invert_colors=True,
pixel_mode="18bit",
cs_pin=1,

View File

@@ -1,13 +1,13 @@
from esphome.components.mipi import DriverChip
from esphome.components.mipi import DriverChip, delay
from esphome.config_validation import UNDEFINED
from .st7701s import st7701s
# fmt: off
wave_4_3 = DriverChip(
"ESP32-S3-TOUCH-LCD-4.3",
swap_xy=UNDEFINED,
initsequence=(),
color_order="RGB",
width=800,
height=480,
pclk_frequency="16MHz",
@@ -55,10 +55,9 @@ wave_4_3.extend(
)
st7701s.extend(
"WAVESHARE-4-480x480",
"WAVESHARE-4-480X480",
data_rate="2MHz",
spi_mode="MODE3",
color_order="BGR",
pixel_mode="18bit",
width=480,
height=480,
@@ -76,3 +75,72 @@ st7701s.extend(
"blue": [5, 45, 48, 47, 21],
},
)
st7701s.extend(
"WAVESHARE-3.16-320X820",
width=320,
height=820,
de_pin=40,
hsync_pin=38,
vsync_pin=39,
pclk_pin=41,
cs_pin={
"number": 0,
"ignore_strapping_warning": True,
},
pclk_frequency="18MHz",
reset_pin=16,
hsync_back_porch=30,
hsync_front_porch=30,
hsync_pulse_width=6,
vsync_back_porch=20,
vsync_front_porch=20,
vsync_pulse_width=40,
data_pins={
"red": [17, 46, 3, 8, 18],
"green": [14, 13, 12, 11, 10, 9],
"blue": [21, 5, 45, 48, 47],
},
initsequence=(
(0xFF, 0x77, 0x01, 0x00, 0x00, 0x13),
(0xEF, 0x08),
(0xFF, 0x77, 0x01, 0x00, 0x00, 0x10),
(0xC0, 0xE5, 0x02),
(0xC1, 0x15, 0x0A),
(0xC2, 0x07, 0x02),
(0xCC, 0x10),
(0xB0, 0x00, 0x08, 0x51, 0x0D, 0xCE, 0x06, 0x00, 0x08, 0x08, 0x24, 0x05, 0xD0, 0x0F, 0x6F, 0x36, 0x1F),
(0xB1, 0x00, 0x10, 0x4F, 0x0C, 0x11, 0x05, 0x00, 0x07, 0x07, 0x18, 0x02, 0xD3, 0x11, 0x6E, 0x34, 0x1F),
(0xFF, 0x77, 0x01, 0x00, 0x00, 0x11),
(0xB0, 0x4D),
(0xB1, 0x37),
(0xB2, 0x87),
(0xB3, 0x80),
(0xB5, 0x4A),
(0xB7, 0x85),
(0xB8, 0x21),
(0xB9, 0x00, 0x13),
(0xC0, 0x09),
(0xC1, 0x78),
(0xC2, 0x78),
(0xD0, 0x88),
(0xE0, 0x80, 0x00, 0x02),
(0xE1, 0x0F, 0xA0, 0x00, 0x00, 0x10, 0xA0, 0x00, 0x00, 0x00, 0x60, 0x60),
(0xE2, 0x30, 0x30, 0x60, 0x60, 0x45, 0xA0, 0x00, 0x00, 0x46, 0xA0, 0x00, 0x00, 0x00),
(0xE3, 0x00, 0x00, 0x33, 0x33),
(0xE4, 0x44, 0x44),
(0xE5, 0x0F, 0x4A, 0xA0, 0xA0, 0x11, 0x4A, 0xA0, 0xA0, 0x13, 0x4A, 0xA0, 0xA0, 0x15, 0x4A, 0xA0, 0xA0),
(0xE6, 0x00, 0x00, 0x33, 0x33),
(0xE7, 0x44, 0x44),
(0xE8, 0x10, 0x4A, 0xA0, 0xA0, 0x12, 0x4A, 0xA0, 0xA0, 0x14, 0x4A, 0xA0, 0xA0, 0x16, 0x4A, 0xA0, 0xA0),
(0xEB, 0x02, 0x00, 0x4E, 0x4E, 0xEE, 0x44, 0x00),
(0xED, 0xFF, 0xFF, 0x04, 0x56, 0x72, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x27, 0x65, 0x40, 0xFF, 0xFF),
(0xEF, 0x08, 0x08, 0x08, 0x40, 0x3F, 0x64),
(0xFF, 0x77, 0x01, 0x00, 0x00, 0x13),
(0xE8, 0x00, 0x0E),
(0xE8, 0x00, 0x0C),
delay(10),
(0xE8, 0x00, 0x00),
(0xFF, 0x77, 0x01, 0x00, 0x00, 0x00),
)
)

View File

@@ -1,5 +1,9 @@
from collections import UserDict
from collections.abc import Callable
from functools import reduce
import logging
from pathlib import Path
from typing import Any
from esphome import git, yaml_util
from esphome.components.substitutions.jinja import has_jinja
@@ -15,6 +19,7 @@ from esphome.const import (
CONF_PATH,
CONF_REF,
CONF_REFRESH,
CONF_SUBSTITUTIONS,
CONF_URL,
CONF_USERNAME,
CONF_VARS,
@@ -27,32 +32,43 @@ _LOGGER = logging.getLogger(__name__)
DOMAIN = CONF_PACKAGES
def valid_package_contents(package_config: dict):
"""Validates that a package_config that will be merged looks as much as possible to a valid config
to fail early on obvious mistakes."""
if isinstance(package_config, dict):
if CONF_URL in package_config:
# If a URL key is found, then make sure the config conforms to a remote package schema:
return REMOTE_PACKAGE_SCHEMA(package_config)
def validate_has_jinja(value: Any):
if not isinstance(value, str) or not has_jinja(value):
raise cv.Invalid("string does not contain Jinja syntax")
return value
# Validate manually since Voluptuous would regenerate dicts and lose metadata
# such as ESPHomeDataBase
for k, v in package_config.items():
if not isinstance(k, str):
raise cv.Invalid("Package content keys must be strings")
if isinstance(v, (dict, list, Remove)):
continue # e.g. script: [], psram: !remove, logger: {level: debug}
if v is None:
continue # e.g. web_server:
if isinstance(v, str) and has_jinja(v):
# e.g: remote package shorthand:
# package_name: github://esphome/repo/file.yaml@${ branch }
continue
raise cv.Invalid("Invalid component content in package definition")
return package_config
def valid_package_contents(allow_jinja: bool = True) -> Callable[[Any], dict]:
"""Returns a validator that checks if a package_config that will be merged looks as
much as possible to a valid config to fail early on obvious mistakes."""
raise cv.Invalid("Package contents must be a dict")
def validator(package_config: dict) -> dict:
if isinstance(package_config, dict):
if CONF_URL in package_config:
# If a URL key is found, then make sure the config conforms to a remote package schema:
return REMOTE_PACKAGE_SCHEMA(package_config)
# Validate manually since Voluptuous would regenerate dicts and lose metadata
# such as ESPHomeDataBase
for k, v in package_config.items():
if not isinstance(k, str):
raise cv.Invalid("Package content keys must be strings")
if isinstance(v, (dict, list, Remove)):
continue # e.g. script: [], psram: !remove, logger: {level: debug}
if v is None:
continue # e.g. web_server:
if allow_jinja and isinstance(v, str) and has_jinja(v):
# e.g: remote package shorthand:
# package_name: github://esphome/repo/file.yaml@${ branch }, or:
# switch: ${ expression that evals to a switch }
continue
raise cv.Invalid("Invalid component content in package definition")
return package_config
raise cv.Invalid("Package contents must be a dict")
return validator
def expand_file_to_files(config: dict):
@@ -142,7 +158,10 @@ REMOTE_PACKAGE_SCHEMA = cv.All(
PACKAGE_SCHEMA = cv.Any( # A package definition is either:
validate_source_shorthand, # A git URL shorthand string that expands to a remote package schema, or
REMOTE_PACKAGE_SCHEMA, # a valid remote package schema, or
valid_package_contents, # Something that at least looks like an actual package, e.g. {wifi:{ssid: xxx}}
validate_has_jinja, # a Jinja string that may resolve to a package, or
valid_package_contents(
allow_jinja=True
), # Something that at least looks like an actual package, e.g. {wifi:{ssid: xxx}}
# which will have to be fully validated later as per each component's schema.
)
@@ -235,32 +254,84 @@ def _process_remote_package(config: dict, skip_update: bool = False) -> dict:
return {"packages": packages}
def _process_package(package_config, config, skip_update: bool = False):
recursive_package = package_config
if CONF_URL in package_config:
package_config = _process_remote_package(package_config, skip_update)
if isinstance(package_config, dict):
recursive_package = do_packages_pass(package_config, skip_update)
return merge_config(recursive_package, config)
def do_packages_pass(config: dict, skip_update: bool = False):
def _walk_packages(
config: dict, callback: Callable[[dict], dict], validate_deprecated: bool = True
) -> dict:
if CONF_PACKAGES not in config:
return config
packages = config[CONF_PACKAGES]
with cv.prepend_path(CONF_PACKAGES):
# The following block and `validate_deprecated` parameter can be safely removed
# once single-package deprecation is effective
if validate_deprecated:
packages = CONFIG_SCHEMA(packages)
with cv.prepend_path(CONF_PACKAGES):
if isinstance(packages, dict):
for package_name, package_config in reversed(packages.items()):
with cv.prepend_path(package_name):
config = _process_package(package_config, config, skip_update)
package_config = callback(package_config)
packages[package_name] = _walk_packages(package_config, callback)
elif isinstance(packages, list):
for package_config in reversed(packages):
config = _process_package(package_config, config, skip_update)
for idx in reversed(range(len(packages))):
with cv.prepend_path(idx):
package_config = callback(packages[idx])
packages[idx] = _walk_packages(package_config, callback)
else:
raise cv.Invalid(
f"Packages must be a key to value mapping or list, got {type(packages)} instead"
)
del config[CONF_PACKAGES]
config[CONF_PACKAGES] = packages
return config
def do_packages_pass(config: dict, skip_update: bool = False) -> dict:
"""Processes, downloads and validates all packages in the config.
Also extracts and merges all substitutions found in packages into the main config substitutions.
"""
if CONF_PACKAGES not in config:
return config
substitutions = UserDict(config.pop(CONF_SUBSTITUTIONS, {}))
def process_package_callback(package_config: dict) -> dict:
"""This will be called for each package found in the config."""
package_config = PACKAGE_SCHEMA(package_config)
if isinstance(package_config, str):
return package_config # Jinja string, skip processing
if CONF_URL in package_config:
package_config = _process_remote_package(package_config, skip_update)
# Extract substitutions from the package and merge them into the main substitutions:
substitutions.data = merge_config(
package_config.pop(CONF_SUBSTITUTIONS, {}), substitutions.data
)
return package_config
_walk_packages(config, process_package_callback)
if substitutions:
config[CONF_SUBSTITUTIONS] = substitutions.data
return config
def merge_packages(config: dict) -> dict:
"""Merges all packages into the main config and removes the `packages:` key."""
if CONF_PACKAGES not in config:
return config
# Build flat list of all package configs to merge in priority order:
merge_list: list[dict] = []
validate_package = valid_package_contents(allow_jinja=False)
def process_package_callback(package_config: dict) -> dict:
"""This will be called for each package found in the config."""
merge_list.append(validate_package(package_config))
return package_config
_walk_packages(config, process_package_callback, validate_deprecated=False)
# Merge all packages into the main config:
config = reduce(lambda new, old: merge_config(old, new), merge_list, config)
del config[CONF_PACKAGES]
return config

View File

@@ -176,17 +176,22 @@ async def register_packet_transport(var, config):
if encryption := provider.get(CONF_ENCRYPTION):
cg.add(var.set_provider_encryption(name, hash_encryption_key(encryption)))
is_provider = False
for sens_conf in config.get(CONF_SENSORS, ()):
is_provider = True
sens_id = sens_conf[CONF_ID]
sensor = await cg.get_variable(sens_id)
bcst_id = sens_conf.get(CONF_BROADCAST_ID, sens_id.id)
cg.add(var.add_sensor(bcst_id, sensor))
for sens_conf in config.get(CONF_BINARY_SENSORS, ()):
is_provider = True
sens_id = sens_conf[CONF_ID]
sensor = await cg.get_variable(sens_id)
bcst_id = sens_conf.get(CONF_BROADCAST_ID, sens_id.id)
cg.add(var.add_binary_sensor(bcst_id, sensor))
if is_provider:
cg.add(var.set_is_provider(True))
if encryption := config.get(CONF_ENCRYPTION):
cg.add(var.set_encryption_key(hash_encryption_key(encryption)))
return providers

View File

@@ -263,6 +263,7 @@ void PacketTransport::flush_() {
xxtea::encrypt((uint32_t *) (encode_buffer.data() + header_len), len / 4,
(uint32_t *) this->encryption_key_.data());
}
ESP_LOGVV(TAG, "Sending packet %s", format_hex_pretty(encode_buffer.data(), encode_buffer.size()).c_str());
this->send_packet(encode_buffer);
}
@@ -316,6 +317,9 @@ void PacketTransport::send_data_(bool all) {
}
void PacketTransport::update() {
// resend all sensors if required
if (this->is_provider_)
this->send_data_(true);
if (!this->ping_pong_enable_) {
return;
}
@@ -551,7 +555,7 @@ void PacketTransport::loop() {
if (this->resend_ping_key_)
this->send_ping_pong_request_();
if (this->updated_) {
this->send_data_(this->resend_data_);
this->send_data_(false);
}
}

View File

@@ -91,6 +91,7 @@ class PacketTransport : public PollingComponent {
}
}
void set_is_provider(bool is_provider) { this->is_provider_ = is_provider; }
void set_encryption_key(std::vector<uint8_t> key) { this->encryption_key_ = std::move(key); }
void set_rolling_code_enable(bool enable) { this->rolling_code_enable_ = enable; }
void set_ping_pong_enable(bool enable) { this->ping_pong_enable_ = enable; }
@@ -129,7 +130,7 @@ class PacketTransport : public PollingComponent {
uint32_t ping_pong_recyle_time_{};
uint32_t last_key_time_{};
bool resend_ping_key_{};
bool resend_data_{};
bool is_provider_{};
const char *name_{};
ESPPreferenceObject pref_{};

View File

@@ -38,7 +38,7 @@ CONFIG_SCHEMA = cv.All(
{
cv.GenerateID(): cv.declare_id(PCA9685Output),
cv.Optional(CONF_FREQUENCY): cv.All(
cv.frequency, cv.Range(min=23.84, max=1525.88)
cv.frequency, cv.float_range(min=23.84, max=1525.88)
),
cv.Optional(CONF_EXTERNAL_CLOCK_INPUT, default=False): cv.boolean,
cv.Optional(CONF_PHASE_BALANCER, default="linear"): cv.enum(

View File

@@ -7,10 +7,10 @@ from esphome.const import (
CONF_UPDATE_INTERVAL,
DEVICE_CLASS_PM25,
ICON_BLUR,
SCHEDULER_DONT_RUN,
STATE_CLASS_MEASUREMENT,
UNIT_MICROGRAMS_PER_CUBIC_METER,
)
from esphome.core import TimePeriodMilliseconds
CODEOWNERS = ["@habbie"]
DEPENDENCIES = ["uart"]
@@ -41,16 +41,12 @@ CONFIG_SCHEMA = cv.All(
def validate_interval_uart(config):
require_tx = False
interval = config.get(CONF_UPDATE_INTERVAL)
if isinstance(interval, TimePeriodMilliseconds):
# 'never' is encoded as a very large int, not as a TimePeriodMilliseconds objects
require_tx = True
uart.final_validate_device_schema(
"pm1006", baud_rate=9600, require_rx=True, require_tx=require_tx
"pm1006",
baud_rate=9600,
require_rx=True,
require_tx=interval.total_milliseconds != SCHEDULER_DONT_RUN,
)(config)

View File

@@ -232,10 +232,10 @@ template<typename... Ts> class ABBWelcomeAction : public RemoteTransmitterAction
data.set_message_id(this->message_id_.value(x...));
data.auto_message_id = this->auto_message_id_.value(x...);
std::vector<uint8_t> data_vec;
if (this->len_ >= 0) {
if (this->len_ > 0) {
// Static mode: copy from flash to vector
data_vec.assign(this->data_.data, this->data_.data + this->len_);
} else {
} else if (this->len_ < 0) {
// Template mode: call function
data_vec = this->data_.func(x...);
}
@@ -245,7 +245,7 @@ template<typename... Ts> class ABBWelcomeAction : public RemoteTransmitterAction
}
protected:
ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length
ssize_t len_{0}; // <0 = template mode, >=0 = static mode with length
union Data {
std::vector<uint8_t> (*func)(Ts...); // Function pointer (stateless lambdas)
const uint8_t *data; // Pointer to static data in flash

View File

@@ -11,7 +11,7 @@ DEPENDENCIES = ["rp2040"]
rp2040_pwm_ns = cg.esphome_ns.namespace("rp2040_pwm")
RP2040PWM = rp2040_pwm_ns.class_("RP2040PWM", output.FloatOutput, cg.Component)
SetFrequencyAction = rp2040_pwm_ns.class_("SetFrequencyAction", automation.Action)
validate_frequency = cv.All(cv.frequency, cv.Range(min=1.0e-6))
validate_frequency = cv.All(cv.frequency, cv.float_range(min=1.0e-6))
CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
{

View File

@@ -14,13 +14,36 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifdef USE_ESP8266
#include <coredecls.h> // For esp_schedule()
#endif
namespace esphome {
namespace socket {
#ifdef USE_ESP8266
// Flag to signal socket activity - checked by socket_delay() to exit early
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
static volatile bool s_socket_woke = false;
void socket_delay(uint32_t ms) {
// Use esp_delay with a callback that checks if socket data arrived.
// This allows the delay to exit early when socket_wake() is called by
// lwip recv_fn/accept_fn callbacks, reducing socket latency.
s_socket_woke = false;
esp_delay(ms, []() { return !s_socket_woke; });
}
void socket_wake() {
s_socket_woke = true;
esp_schedule();
}
#endif
static const char *const TAG = "socket.lwip";
// set to 1 to enable verbose lwip logging
#if 0
#if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if)
#define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__)
#else
#define LWIP_LOG(msg, ...)
@@ -165,7 +188,7 @@ class LWIPRawImpl : public Socket {
errno = EINVAL;
return -1;
}
return this->ip2sockaddr_(&pcb_->local_ip, pcb_->local_port, name, addrlen);
return this->ip2sockaddr_(&pcb_->remote_ip, pcb_->remote_port, name, addrlen);
}
std::string getpeername() override {
if (pcb_ == nullptr) {
@@ -323,9 +346,10 @@ class LWIPRawImpl : public Socket {
for (int i = 0; i < iovcnt; i++) {
ssize_t err = read(reinterpret_cast<uint8_t *>(iov[i].iov_base), iov[i].iov_len);
if (err == -1) {
if (ret != 0)
if (ret != 0) {
// if we already read some don't return an error
break;
}
return err;
}
ret += err;
@@ -393,9 +417,10 @@ class LWIPRawImpl : public Socket {
ssize_t written = internal_write(buf, len);
if (written == -1)
return -1;
if (written == 0)
if (written == 0) {
// no need to output if nothing written
return 0;
}
if (nodelay_) {
int err = internal_output();
if (err == -1)
@@ -408,18 +433,20 @@ class LWIPRawImpl : public Socket {
for (int i = 0; i < iovcnt; i++) {
ssize_t err = internal_write(reinterpret_cast<uint8_t *>(iov[i].iov_base), iov[i].iov_len);
if (err == -1) {
if (written != 0)
if (written != 0) {
// if we already read some don't return an error
break;
}
return err;
}
written += err;
if ((size_t) err != iov[i].iov_len)
break;
}
if (written == 0)
if (written == 0) {
// no need to output if nothing written
return 0;
}
if (nodelay_) {
int err = internal_output();
if (err == -1)
@@ -473,6 +500,10 @@ class LWIPRawImpl : public Socket {
} else {
pbuf_cat(rx_buf_, pb);
}
#ifdef USE_ESP8266
// Wake the main loop immediately so it can process the received data.
socket_wake();
#endif
return ERR_OK;
}
@@ -612,7 +643,7 @@ class LWIPRawListenImpl : public LWIPRawImpl {
}
private:
err_t accept_fn(struct tcp_pcb *newpcb, err_t err) {
err_t accept_fn_(struct tcp_pcb *newpcb, err_t err) {
LWIP_LOG("accept(newpcb=%p err=%d)", newpcb, err);
if (err != ERR_OK || newpcb == nullptr) {
// "An error code if there has been an error accepting. Only return ERR_ABRT if you have
@@ -633,12 +664,16 @@ class LWIPRawListenImpl : public LWIPRawImpl {
sock->init();
accepted_sockets_[accepted_socket_count_++] = std::move(sock);
LWIP_LOG("Accepted connection, queue size: %d", accepted_socket_count_);
#ifdef USE_ESP8266
// Wake the main loop immediately so it can accept the new connection.
socket_wake();
#endif
return ERR_OK;
}
static err_t s_accept_fn(void *arg, struct tcp_pcb *newpcb, err_t err) {
LWIPRawListenImpl *arg_this = reinterpret_cast<LWIPRawListenImpl *>(arg);
return arg_this->accept_fn(newpcb, err);
return arg_this->accept_fn_(newpcb, err);
}
// Accept queue - holds incoming connections briefly until the event loop calls accept()

View File

@@ -82,6 +82,15 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const std::stri
/// Set a sockaddr to the any address and specified port for the IP version used by socket_ip().
socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port);
#if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP)
/// Delay that can be woken early by socket activity.
/// On ESP8266, lwip callbacks set a flag and call esp_schedule() to wake the delay.
void socket_delay(uint32_t ms);
/// Called by lwip callbacks to signal socket activity and wake delay.
void socket_wake();
#endif
} // namespace socket
} // namespace esphome
#endif

View File

@@ -1,6 +1,7 @@
from esphome import automation, pins
import esphome.codegen as cg
from esphome.components import spi
from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET
import esphome.config_validation as cv
from esphome.const import CONF_BUSY_PIN, CONF_DATA, CONF_FREQUENCY, CONF_ID
from esphome.core import ID, TimePeriod
@@ -14,7 +15,6 @@ CONF_SX126X_ID = "sx126x_id"
CONF_BANDWIDTH = "bandwidth"
CONF_BITRATE = "bitrate"
CONF_CODING_RATE = "coding_rate"
CONF_CRC_ENABLE = "crc_enable"
CONF_CRC_INVERTED = "crc_inverted"
CONF_CRC_SIZE = "crc_size"
CONF_CRC_POLYNOMIAL = "crc_polynomial"
@@ -23,7 +23,6 @@ CONF_DEVIATION = "deviation"
CONF_DIO1_PIN = "dio1_pin"
CONF_HW_VERSION = "hw_version"
CONF_MODULATION = "modulation"
CONF_ON_PACKET = "on_packet"
CONF_PA_POWER = "pa_power"
CONF_PA_RAMP = "pa_ramp"
CONF_PAYLOAD_LENGTH = "payload_length"
@@ -200,9 +199,13 @@ CONFIG_SCHEMA = (
cv.Optional(CONF_CRC_INITIAL, default=0x1D0F): cv.All(
cv.hex_int, cv.Range(min=0, max=0xFFFF)
),
cv.Optional(CONF_DEVIATION, default=5000): cv.int_range(min=0, max=100000),
cv.Optional(CONF_DEVIATION, default="5kHz"): cv.All(
cv.frequency, cv.float_range(min=0, max=100000)
),
cv.Required(CONF_DIO1_PIN): pins.gpio_input_pin_schema,
cv.Required(CONF_FREQUENCY): cv.int_range(min=137000000, max=1020000000),
cv.Required(CONF_FREQUENCY): cv.All(
cv.frequency, cv.float_range(min=137.0e6, max=1020.0e6)
),
cv.Required(CONF_HW_VERSION): cv.one_of(
"sx1261", "sx1262", "sx1268", "llcc68", lower=True
),

View File

@@ -12,12 +12,6 @@ void SX126xTransport::setup() {
this->parent_->register_listener(this);
}
void SX126xTransport::update() {
PacketTransport::update();
this->updated_ = true;
this->resend_data_ = true;
}
void SX126xTransport::send_packet(const std::vector<uint8_t> &buf) const { this->parent_->transmit_packet(buf); }
void SX126xTransport::on_packet(const std::vector<uint8_t> &packet, float rssi, float snr) { this->process_(packet); }

View File

@@ -11,7 +11,6 @@ namespace sx126x {
class SX126xTransport : public packet_transport::PacketTransport, public Parented<SX126x>, public SX126xListener {
public:
void setup() override;
void update() override;
void on_packet(const std::vector<uint8_t> &packet, float rssi, float snr) override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }

View File

@@ -1,6 +1,7 @@
from esphome import automation, pins
import esphome.codegen as cg
from esphome.components import spi
from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET
import esphome.config_validation as cv
from esphome.const import CONF_DATA, CONF_FREQUENCY, CONF_ID
from esphome.core import ID
@@ -16,11 +17,9 @@ CONF_BANDWIDTH = "bandwidth"
CONF_BITRATE = "bitrate"
CONF_BITSYNC = "bitsync"
CONF_CODING_RATE = "coding_rate"
CONF_CRC_ENABLE = "crc_enable"
CONF_DEVIATION = "deviation"
CONF_DIO0_PIN = "dio0_pin"
CONF_MODULATION = "modulation"
CONF_ON_PACKET = "on_packet"
CONF_PA_PIN = "pa_pin"
CONF_PA_POWER = "pa_power"
CONF_PA_RAMP = "pa_ramp"
@@ -197,9 +196,13 @@ CONFIG_SCHEMA = (
cv.Optional(CONF_BITSYNC): cv.boolean,
cv.Optional(CONF_CODING_RATE, default="CR_4_5"): cv.enum(CODING_RATE),
cv.Optional(CONF_CRC_ENABLE, default=False): cv.boolean,
cv.Optional(CONF_DEVIATION, default=5000): cv.int_range(min=0, max=100000),
cv.Optional(CONF_DEVIATION, default="5kHz"): cv.All(
cv.frequency, cv.float_range(min=0, max=100000)
),
cv.Optional(CONF_DIO0_PIN): pins.internal_gpio_input_pin_schema,
cv.Required(CONF_FREQUENCY): cv.int_range(min=137000000, max=1020000000),
cv.Required(CONF_FREQUENCY): cv.All(
cv.frequency, cv.float_range(min=137.0e6, max=1020.0e6)
),
cv.Required(CONF_MODULATION): cv.enum(MOD),
cv.Optional(CONF_ON_PACKET): automation.validate_automation(single=True),
cv.Optional(CONF_PA_PIN, default="BOOST"): cv.enum(PA_PIN),

View File

@@ -12,12 +12,6 @@ void SX127xTransport::setup() {
this->parent_->register_listener(this);
}
void SX127xTransport::update() {
PacketTransport::update();
this->updated_ = true;
this->resend_data_ = true;
}
void SX127xTransport::send_packet(const std::vector<uint8_t> &buf) const { this->parent_->transmit_packet(buf); }
void SX127xTransport::on_packet(const std::vector<uint8_t> &packet, float rssi, float snr) { this->process_(packet); }

View File

@@ -11,7 +11,6 @@ namespace sx127x {
class SX127xTransport : public packet_transport::PacketTransport, public Parented<SX127x>, public SX127xListener {
public:
void setup() override;
void update() override;
void on_packet(const std::vector<uint8_t> &packet, float rssi, float snr) override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }

View File

@@ -34,7 +34,15 @@ void Syslog::log_(const int level, const char *tag, const char *message, size_t
severity = LOG_LEVEL_TO_SYSLOG_SEVERITY[level];
}
int pri = this->facility_ * 8 + severity;
auto timestamp = this->time_->now().strftime("%b %e %H:%M:%S");
auto now = this->time_->now();
std::string timestamp;
if (now.is_valid()) {
timestamp = now.strftime("%b %e %H:%M:%S");
} else {
// RFC 5424: A syslog application MUST use the NILVALUE as TIMESTAMP if the syslog application is incapable of
// obtaining system time.
timestamp = "-";
}
size_t len = message_len;
// remove color formatting
if (this->strip_ && message[0] == 0x1B && len > 11) {

View File

@@ -39,6 +39,7 @@ enum TemplateAlarmControlPanelRestoreMode {
ALARM_CONTROL_PANEL_RESTORE_DEFAULT_DISARMED,
};
#ifdef USE_BINARY_SENSOR
struct SensorDataStore {
bool last_chime_state;
};
@@ -49,7 +50,6 @@ struct SensorInfo {
uint8_t store_index;
};
#ifdef USE_BINARY_SENSOR
struct AlarmSensor {
binary_sensor::BinarySensor *sensor;
SensorInfo info;
@@ -139,6 +139,9 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl
FixedVector<AlarmSensor> sensors_;
// a list of automatically bypassed sensors
std::vector<uint8_t> bypassed_sensor_indicies_;
// Per sensor data store
std::vector<SensorDataStore> sensor_data_;
uint8_t next_store_index_ = 0;
#endif
TemplateAlarmControlPanelRestoreMode restore_mode_{};
@@ -154,14 +157,11 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl
uint32_t trigger_time_;
// a list of codes
std::vector<std::string> codes_;
// Per sensor data store
std::vector<SensorDataStore> sensor_data_;
// requires a code to arm
bool requires_code_to_arm_ = false;
bool supports_arm_home_ = false;
bool supports_arm_night_ = false;
bool sensors_ready_ = false;
uint8_t next_store_index_ = 0;
// check if the code is valid
bool is_code_valid_(optional<std::string> code);

View File

@@ -55,12 +55,6 @@ void UARTTransport::loop() {
}
}
void UARTTransport::update() {
this->updated_ = true;
this->resend_data_ = true;
PacketTransport::update();
}
/**
* Write a byte to the UART bus. If the byte is a flag or control byte, it will be escaped.
* @param byte The byte to write.

View File

@@ -23,7 +23,6 @@ static const uint8_t CONTROL_BYTE = 0x7D;
class UARTTransport : public packet_transport::PacketTransport, public UARTDevice {
public:
void loop() override;
void update() override;
float get_setup_priority() const override { return setup_priority::PROCESSOR; }
protected:

View File

@@ -9,6 +9,7 @@
#include "esphome/core/gpio.h"
#include "driver/gpio.h"
#include "soc/gpio_num.h"
#include "soc/uart_pins.h"
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
@@ -139,6 +140,22 @@ void IDFUARTComponent::load_settings(bool dump_config) {
return;
}
int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1;
int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1;
int8_t flow_control = this->flow_control_pin_ != nullptr ? this->flow_control_pin_->get_pin() : -1;
// Workaround for ESP-IDF issue: https://github.com/espressif/esp-idf/issues/17459
// Commit 9ed617fb17 removed gpio_func_sel() calls from uart_set_pin(), which breaks
// UART on default UART0 pins that may have residual state from boot console.
// Reset these pins before configuring UART to ensure they're in a clean state.
if (tx == U0TXD_GPIO_NUM || tx == U0RXD_GPIO_NUM) {
gpio_reset_pin(static_cast<gpio_num_t>(tx));
}
if (rx == U0TXD_GPIO_NUM || rx == U0RXD_GPIO_NUM) {
gpio_reset_pin(static_cast<gpio_num_t>(rx));
}
// Setup pins after reset to preserve open drain/pullup/pulldown flags
auto setup_pin_if_needed = [](InternalGPIOPin *pin) {
if (!pin) {
return;
@@ -154,10 +171,6 @@ void IDFUARTComponent::load_settings(bool dump_config) {
setup_pin_if_needed(this->tx_pin_);
}
int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1;
int8_t rx = this->rx_pin_ != nullptr ? this->rx_pin_->get_pin() : -1;
int8_t flow_control = this->flow_control_pin_ != nullptr ? this->flow_control_pin_->get_pin() : -1;
uint32_t invert = 0;
if (this->tx_pin_ != nullptr && this->tx_pin_->is_inverted()) {
invert |= UART_SIGNAL_TXD_INV;

View File

@@ -8,29 +8,14 @@ namespace udp {
static const char *const TAG = "udp_transport";
bool UDPTransport::should_send() { return this->should_broadcast_ && network::is_connected(); }
bool UDPTransport::should_send() { return network::is_connected(); }
void UDPTransport::setup() {
PacketTransport::setup();
this->should_broadcast_ = this->ping_pong_enable_;
#ifdef USE_SENSOR
this->should_broadcast_ |= !this->sensors_.empty();
#endif
#ifdef USE_BINARY_SENSOR
this->should_broadcast_ |= !this->binary_sensors_.empty();
#endif
if (this->should_broadcast_)
this->parent_->set_should_broadcast();
if (!this->providers_.empty() || this->is_encrypted_()) {
this->parent_->add_listener([this](std::vector<uint8_t> &buf) { this->process_(buf); });
}
}
void UDPTransport::update() {
PacketTransport::update();
this->updated_ = true;
this->resend_data_ = this->should_broadcast_;
}
void UDPTransport::send_packet(const std::vector<uint8_t> &buf) const { this->parent_->send_packet(buf); }
} // namespace udp
} // namespace esphome

View File

@@ -12,14 +12,12 @@ namespace udp {
class UDPTransport : public packet_transport::PacketTransport, public Parented<UDPComponent> {
public:
void setup() override;
void update() override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
protected:
void send_packet(const std::vector<uint8_t> &buf) const override;
bool should_send() override;
bool should_broadcast_{false};
size_t get_max_packet_size() override { return MAX_PACKET_SIZE; }
};

View File

@@ -117,18 +117,6 @@ void AsyncWebServer::end() {
}
}
void AsyncWebServer::set_lru_purge_enable(bool enable) {
if (this->lru_purge_enable_ == enable) {
return; // No change needed
}
this->lru_purge_enable_ = enable;
// If server is already running, restart it with new config
if (this->server_) {
this->end();
this->begin();
}
}
void AsyncWebServer::begin() {
if (this->server_) {
this->end();
@@ -136,8 +124,11 @@ void AsyncWebServer::begin() {
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.server_port = this->port_;
config.uri_match_fn = [](const char * /*unused*/, const char * /*unused*/, size_t /*unused*/) { return true; };
// Enable LRU purging if requested (e.g., by captive portal to handle probe bursts)
config.lru_purge_enable = this->lru_purge_enable_;
// Always enable LRU purging to handle socket exhaustion gracefully.
// When max sockets is reached, the oldest connection is closed to make room for new ones.
// This prevents "httpd_accept_conn: error in accept (23)" errors.
// See: https://github.com/esphome/esphome/issues/12464
config.lru_purge_enable = true;
// Use custom close function that shuts down before closing to prevent lwIP race conditions
config.close_fn = AsyncWebServer::safe_close_with_shutdown;
if (httpd_start(&this->server_, &config) == ESP_OK) {

View File

@@ -199,13 +199,11 @@ class AsyncWebServer {
return *handler;
}
void set_lru_purge_enable(bool enable);
httpd_handle_t get_server() { return this->server_; }
protected:
uint16_t port_{};
httpd_handle_t server_{};
bool lru_purge_enable_{false};
static esp_err_t request_handler(httpd_req_t *r);
static esp_err_t request_post_handler(httpd_req_t *r);
esp_err_t request_handler_(AsyncWebServerRequest *request) const;

View File

@@ -205,6 +205,21 @@ static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500;
/// While connecting, WiFi can't beacon the AP properly, so needs longer cooldown
static constexpr uint32_t WIFI_COOLDOWN_WITH_AP_ACTIVE_MS = 30000;
/// Timeout for WiFi scan operations
/// This is a fallback in case we don't receive a scan done callback from the WiFi driver.
/// Normal scans complete via callback; this only triggers if something goes wrong.
static constexpr uint32_t WIFI_SCAN_TIMEOUT_MS = 31000;
/// Timeout for WiFi connection attempts
/// This is a fallback in case we don't receive connection success/failure callbacks.
/// Some platforms (especially LibreTiny/Beken) can take 30-60 seconds to connect,
/// particularly with fast_connect enabled where no prior scan provides channel info.
/// Do not lower this value - connection failures are detected via callbacks, not timeout.
/// If this timeout fires prematurely while a connection is still in progress, it causes
/// cascading failures: the subsequent scan will also fail because the WiFi driver is
/// still busy with the previous connection attempt.
static constexpr uint32_t WIFI_CONNECT_TIMEOUT_MS = 46000;
static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) {
switch (phase) {
case WiFiRetryPhase::INITIAL_CONNECT:
@@ -1035,7 +1050,7 @@ __attribute__((noinline)) static void log_scan_result(const WiFiScanResult &res)
void WiFiComponent::check_scanning_finished() {
if (!this->scan_done_) {
if (millis() - this->action_started_ > 30000) {
if (millis() - this->action_started_ > WIFI_SCAN_TIMEOUT_MS) {
ESP_LOGE(TAG, "Scan timeout");
this->retry_connect();
}
@@ -1184,8 +1199,9 @@ void WiFiComponent::check_connecting_finished() {
}
uint32_t now = millis();
if (now - this->action_started_ > 30000) {
ESP_LOGW(TAG, "Connection timeout");
if (now - this->action_started_ > WIFI_CONNECT_TIMEOUT_MS) {
ESP_LOGW(TAG, "Connection timeout, aborting connection attempt");
this->wifi_disconnect_();
this->retry_connect();
return;
}
@@ -1405,6 +1421,10 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) {
// without disrupting the captive portal/improv connection
if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) {
this->restart_adapter();
} else {
// Even when skipping full restart, disconnect to clear driver state
// Without this, platforms like LibreTiny may think we're still connecting
this->wifi_disconnect_();
}
// Clear scan flag - we're starting a new retry cycle
this->did_scan_this_cycle_ = false;

View File

@@ -528,6 +528,16 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) {
for (auto *listener : global_wifi_component->connect_state_listeners_) {
listener->on_wifi_connect_state(global_wifi_component->wifi_ssid(), global_wifi_component->wifi_bssid());
}
// For static IP configurations, GOT_IP event may not fire, so notify IP listeners here
#ifdef USE_WIFI_MANUAL_IP
if (const WiFiAP *config = global_wifi_component->get_selected_sta_();
config && config->get_manual_ip().has_value()) {
for (auto *listener : global_wifi_component->ip_state_listeners_) {
listener->on_ip_state(global_wifi_component->wifi_sta_ip_addresses(),
global_wifi_component->get_dns_address(0), global_wifi_component->get_dns_address(1));
}
}
#endif
#endif
break;
}

View File

@@ -483,6 +483,12 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
s_sta_connected = false;
s_sta_connect_error = false;
s_sta_connect_not_found = false;
// Reset IP address flags - ensures we don't report connected before DHCP completes
// (IP_EVENT_STA_LOST_IP doesn't always fire on disconnect)
this->got_ipv4_address_ = false;
#if USE_NETWORK_IPV6
this->num_ipv6_addresses_ = 0;
#endif
err = esp_wifi_connect();
if (err != ESP_OK) {
@@ -720,6 +726,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
} else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_STOP) {
ESP_LOGV(TAG, "STA stop");
s_sta_started = false;
s_sta_connecting = false;
} else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_AUTHMODE_CHANGE) {
const auto &it = data->data.sta_authmode_change;
@@ -738,6 +745,14 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
for (auto *listener : this->connect_state_listeners_) {
listener->on_wifi_connect_state(this->wifi_ssid(), this->wifi_bssid());
}
// For static IP configurations, GOT_IP event may not fire, so notify IP listeners here
#ifdef USE_WIFI_MANUAL_IP
if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) {
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
}
#endif
#endif
} else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_DISCONNECTED) {

View File

@@ -291,6 +291,7 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_
}
case ESPHOME_EVENT_ID_WIFI_STA_STOP: {
ESP_LOGV(TAG, "STA stop");
s_sta_connecting = false;
break;
}
case ESPHOME_EVENT_ID_WIFI_STA_CONNECTED: {
@@ -304,6 +305,14 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_
for (auto *listener : this->connect_state_listeners_) {
listener->on_wifi_connect_state(this->wifi_ssid(), this->wifi_bssid());
}
// For static IP configurations, GOT_IP event may not fire, so notify IP listeners here
#ifdef USE_WIFI_MANUAL_IP
if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) {
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
}
#endif
#endif
break;
}
@@ -322,7 +331,7 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_
// wifi_sta_connect_status_() to return IDLE. The main loop then sees
// "Unknown connection status 0" (wifi_component.cpp check_connecting_finished)
// and calls retry_connect(), aborting a connection that may succeed moments later.
// Real connection failures will have ssid/bssid populated, or we'll hit the 30s timeout.
// Real connection failures will have ssid/bssid populated, or we'll hit the connection timeout.
if (it.ssid_len == 0 && s_sta_connecting) {
ESP_LOGV(TAG, "Ignoring disconnect event with empty ssid while connecting (reason=%s)",
get_disconnect_reason_str(it.reason));
@@ -527,7 +536,12 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) {
network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {WiFi.softAPIP()}; }
#endif // USE_WIFI_AP
bool WiFiComponent::wifi_disconnect_() { return WiFi.disconnect(); }
bool WiFiComponent::wifi_disconnect_() {
// Clear connecting flag first so disconnect events aren't ignored
// and wifi_sta_connect_status_() returns IDLE instead of CONNECTING
s_sta_connecting = false;
return WiFi.disconnect();
}
bssid_t WiFiComponent::wifi_bssid() {
bssid_t bssid{};

View File

@@ -259,6 +259,15 @@ void WiFiComponent::wifi_loop_() {
for (auto *listener : this->connect_state_listeners_) {
listener->on_wifi_connect_state(this->wifi_ssid(), this->wifi_bssid());
}
// For static IP configurations, notify IP listeners immediately as the IP is already configured
#ifdef USE_WIFI_MANUAL_IP
if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) {
s_sta_had_ip = true;
for (auto *listener : this->ip_state_listeners_) {
listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
}
}
#endif
#endif
} else if (!is_connected && s_sta_was_connected) {
// Just disconnected

View File

@@ -16,7 +16,12 @@ class WiFiSignalSensor : public sensor::Sensor, public PollingComponent {
#ifdef USE_WIFI_LISTENERS
void setup() override { wifi::global_wifi_component->add_connect_state_listener(this); }
#endif
void update() override { this->publish_state(wifi::global_wifi_component->wifi_rssi()); }
void update() override {
int8_t rssi = wifi::global_wifi_component->wifi_rssi();
if (rssi != wifi::WIFI_RSSI_DISCONNECTED) {
this->publish_state(rssi);
}
}
void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }

View File

@@ -1010,16 +1010,22 @@ def validate_config(
result.add_error(err)
return result
# 1.1. Merge packages
if CONF_PACKAGES in config:
from esphome.components.packages import merge_packages
config = merge_packages(config)
CORE.raw_config = config
# 1.1. Resolve !extend and !remove and check for REPLACEME
# 1.2. Resolve !extend and !remove and check for REPLACEME
# After this step, there will not be any Extend or Remove values in the config anymore
try:
resolve_extend_remove(config)
except vol.Invalid as err:
result.add_error(err)
# 1.2. Load external_components
# 1.3. Load external_components
if CONF_EXTERNAL_COMPONENTS in config:
from esphome.components.external_components import do_external_components_pass

View File

@@ -71,6 +71,7 @@ from esphome.const import (
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_RP2040,
SCHEDULER_DONT_RUN,
TYPE_GIT,
TYPE_LOCAL,
VALID_SUBSTITUTIONS_CHARACTERS,
@@ -894,7 +895,7 @@ def time_period_in_minutes_(value):
def update_interval(value):
if value == "never":
return 4294967295 # uint32_t max
return TimePeriodMilliseconds(milliseconds=SCHEDULER_DONT_RUN)
return positive_time_period_milliseconds(value)
@@ -2009,7 +2010,7 @@ def polling_component_schema(default_update_interval):
if default_update_interval is None:
return COMPONENT_SCHEMA.extend(
{
Required(CONF_UPDATE_INTERVAL): default_update_interval,
Required(CONF_UPDATE_INTERVAL): update_interval,
}
)
assert isinstance(default_update_interval, str)

View File

@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2025.12.0-dev"
__version__ = "2025.12.4"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (

View File

@@ -12,6 +12,10 @@
#include "esphome/components/status_led/status_led.h"
#endif
#if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP)
#include "esphome/components/socket/socket.h"
#endif
#ifdef USE_SOCKET_SELECT_SUPPORT
#include <cerrno>
@@ -627,6 +631,9 @@ void Application::yield_with_select_(uint32_t delay_ms) {
// No sockets registered, use regular delay
delay(delay_ms);
}
#elif defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP)
// No select support but can wake on socket activity via esp_schedule()
socket::socket_delay(delay_ms);
#else
// No select support, use regular delay
delay(delay_ms);

View File

@@ -480,22 +480,13 @@ std::string base64_encode(const uint8_t *buf, size_t buf_len) {
}
size_t base64_decode(const std::string &encoded_string, uint8_t *buf, size_t buf_len) {
std::vector<uint8_t> decoded = base64_decode(encoded_string);
if (decoded.size() > buf_len) {
ESP_LOGW(TAG, "Base64 decode: buffer too small, truncating");
decoded.resize(buf_len);
}
memcpy(buf, decoded.data(), decoded.size());
return decoded.size();
}
std::vector<uint8_t> base64_decode(const std::string &encoded_string) {
int in_len = encoded_string.size();
int i = 0;
int j = 0;
int in = 0;
size_t out = 0;
uint8_t char_array_4[4], char_array_3[3];
std::vector<uint8_t> ret;
bool truncated = false;
// SAFETY: The loop condition checks is_base64() before processing each character.
// This ensures base64_find_char() is only called on valid base64 characters,
@@ -511,8 +502,13 @@ std::vector<uint8_t> base64_decode(const std::string &encoded_string) {
char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);
char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3];
for (i = 0; (i < 3); i++)
ret.push_back(char_array_3[i]);
for (i = 0; i < 3; i++) {
if (out < buf_len) {
buf[out++] = char_array_3[i];
} else {
truncated = true;
}
}
i = 0;
}
}
@@ -528,10 +524,28 @@ std::vector<uint8_t> base64_decode(const std::string &encoded_string) {
char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);
char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3];
for (j = 0; (j < i - 1); j++)
ret.push_back(char_array_3[j]);
for (j = 0; j < i - 1; j++) {
if (out < buf_len) {
buf[out++] = char_array_3[j];
} else {
truncated = true;
}
}
}
if (truncated) {
ESP_LOGW(TAG, "Base64 decode: buffer too small, truncating");
}
return out;
}
std::vector<uint8_t> base64_decode(const std::string &encoded_string) {
// Calculate maximum decoded size: every 4 base64 chars = 3 bytes
size_t max_len = ((encoded_string.size() + 3) / 4) * 3;
std::vector<uint8_t> ret(max_len);
size_t actual_len = base64_decode(encoded_string, ret.data(), max_len);
ret.resize(actual_len);
return ret;
}

View File

@@ -1,16 +1,16 @@
#pragma once
// Platform-agnostic macros for PROGMEM string handling
// On ESP32 (both Arduino and IDF): Use plain strings (no PROGMEM)
// On ESP8266/Arduino: Use Arduino's F() macro for PROGMEM strings
// On other platforms: Use plain strings (no PROGMEM)
#ifdef USE_ESP32
#define ESPHOME_F(string_literal) (string_literal)
#define ESPHOME_PGM_P const char *
#define ESPHOME_strncpy_P strncpy
#else
// ESP8266 and other Arduino platforms use Arduino macros
#ifdef USE_ESP8266
// ESP8266 uses Arduino macros
#define ESPHOME_F(string_literal) F(string_literal)
#define ESPHOME_PGM_P PGM_P
#define ESPHOME_strncpy_P strncpy_P
#else
#define ESPHOME_F(string_literal) (string_literal)
#define ESPHOME_PGM_P const char *
#define ESPHOME_strncpy_P strncpy
#endif

View File

@@ -164,8 +164,24 @@ def websocket_method(name):
return wrap
class CheckOriginMixin:
"""Mixin to handle WebSocket origin checks for reverse proxy setups."""
def check_origin(self, origin: str) -> bool:
if "ESPHOME_TRUSTED_DOMAINS" not in os.environ:
return super().check_origin(origin)
trusted_domains = [
s.strip() for s in os.environ["ESPHOME_TRUSTED_DOMAINS"].split(",")
]
url = urlparse(origin)
if url.hostname in trusted_domains:
return True
_LOGGER.info("check_origin %s, domain is not trusted", origin)
return False
@websocket_class
class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler):
class EsphomeCommandWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler):
"""Base class for ESPHome websocket commands."""
def __init__(
@@ -183,18 +199,6 @@ class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler):
# use Popen() with a reading thread instead
self._use_popen = os.name == "nt"
def check_origin(self, origin):
if "ESPHOME_TRUSTED_DOMAINS" not in os.environ:
return super().check_origin(origin)
trusted_domains = [
s.strip() for s in os.environ["ESPHOME_TRUSTED_DOMAINS"].split(",")
]
url = urlparse(origin)
if url.hostname in trusted_domains:
return True
_LOGGER.info("check_origin %s, domain is not trusted", origin)
return False
def open(self, *args: str, **kwargs: str) -> None:
"""Handle new WebSocket connection."""
# Ensure messages from the subprocess are sent immediately
@@ -601,7 +605,7 @@ DASHBOARD_SUBSCRIBER = DashboardSubscriber()
@websocket_class
class DashboardEventsWebSocket(tornado.websocket.WebSocketHandler):
class DashboardEventsWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler):
"""WebSocket handler for real-time dashboard events."""
_event_listeners: list[Callable[[], None]] | None = None

View File

@@ -322,8 +322,8 @@ def perform_ota(
hash_func, nonce_size, hash_name = _AUTH_METHODS[auth]
perform_auth(sock, password, hash_func, nonce_size, hash_name)
# Set higher timeout during upload
sock.settimeout(30.0)
# Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures
sock.settimeout(90.0)
upload_size = len(upload_contents)
upload_size_encoded = [

View File

@@ -27,3 +27,7 @@ dependencies:
version: "1.7.6~1"
rules:
- if: "target in [esp32s2, esp32s3, esp32p4]"
esphome/esp-hub75:
version: 0.1.7
rules:
- if: "target in [esp32, esp32s2, esp32s3, esp32p4]"

View File

@@ -99,14 +99,11 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool:
def storage_should_update_cmake_cache(old: StorageJSON, new: StorageJSON) -> bool:
if (
# ESP32 uses CMake for both Arduino and ESP-IDF frameworks
return (
old.loaded_integrations != new.loaded_integrations
or old.loaded_platforms != new.loaded_platforms
) and new.core_platform == PLATFORM_ESP32:
from esphome.components.esp32 import FRAMEWORK_ESP_IDF
return new.framework == FRAMEWORK_ESP_IDF
return False
) and new.core_platform == PLATFORM_ESP32
def update_storage_json() -> None:

View File

@@ -156,7 +156,6 @@ lib_deps =
esphome/ESP32-audioI2S@2.3.0 ; i2s_audio
droscy/esp_wireguard@0.4.2 ; wireguard
esphome/esp-audio-libs@2.0.1 ; audio
esphome/esp-hub75@0.1.6 ; hub75
build_flags =
${common:arduino.build_flags}
@@ -180,7 +179,6 @@ lib_deps =
droscy/esp_wireguard@0.4.2 ; wireguard
kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word
esphome/esp-audio-libs@2.0.1 ; audio
esphome/esp-hub75@0.1.6 ; hub75
build_flags =
${common:idf.build_flags}
-Wno-nonnull-compare

View File

@@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile
esptool==5.1.0
click==8.1.7
esphome-dashboard==20251013.0
aioesphomeapi==43.1.0
aioesphomeapi==43.2.1
zeroconf==0.148.0
puremagic==1.30
ruamel.yaml==0.18.16 # dashboard_import

View File

@@ -87,6 +87,7 @@ ISOLATED_COMPONENTS = {
"neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)",
"packages": "cannot merge packages",
"tinyusb": "Conflicts with usb_host component - cannot be used together",
"usb_cdc_acm": "Depends on tinyusb which conflicts with usb_host",
}

View File

@@ -215,6 +215,20 @@ def prepare_symbol_changes_data(
}
def format_components_str(components: list[str]) -> str:
"""Format a list of components for display.
Args:
components: List of component names
Returns:
Formatted string with backtick-quoted component names
"""
if len(components) == 1:
return f"`{components[0]}`"
return ", ".join(f"`{c}`" for c in sorted(components))
def prepare_component_breakdown_data(
target_analysis: dict | None, pr_analysis: dict | None
) -> list[tuple[str, int, int, int]] | None:
@@ -316,11 +330,10 @@ def create_comment_body(
}
# Format components list
context["components_str"] = format_components_str(components)
if len(components) == 1:
context["components_str"] = f"`{components[0]}`"
context["config_note"] = "a representative test configuration"
else:
context["components_str"] = ", ".join(f"`{c}`" for c in sorted(components))
context["config_note"] = (
f"a merged configuration with {len(components)} components"
)
@@ -502,6 +515,43 @@ def post_or_update_comment(pr_number: str, comment_body: str) -> None:
print("Comment posted/updated successfully", file=sys.stderr)
def create_target_unavailable_comment(
pr_data: dict,
) -> str:
"""Create a comment body when target branch data is unavailable.
This happens when the target branch (dev/beta/release) fails to build.
This can occur because:
1. The target branch has a build issue independent of this PR
2. This PR fixes a build issue on the target branch
In either case, we only care that the PR branch builds successfully.
Args:
pr_data: Dictionary with PR branch analysis results
Returns:
Formatted comment body
"""
components = pr_data.get("components", [])
platform = pr_data.get("platform", "unknown")
pr_ram = pr_data.get("ram_bytes", 0)
pr_flash = pr_data.get("flash_bytes", 0)
env = Environment(
loader=FileSystemLoader(TEMPLATE_DIR),
trim_blocks=True,
lstrip_blocks=True,
)
template = env.get_template("ci_memory_impact_target_unavailable.j2")
return template.render(
comment_marker=COMMENT_MARKER,
components_str=format_components_str(components),
platform=platform,
pr_ram=format_bytes(pr_ram),
pr_flash=format_bytes(pr_flash),
)
def main() -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
@@ -523,15 +573,25 @@ def main() -> int:
# Load analysis JSON files (all data comes from JSON for security)
target_data: dict | None = load_analysis_json(args.target_json)
if not target_data:
print("Error: Failed to load target analysis JSON", file=sys.stderr)
sys.exit(1)
pr_data: dict | None = load_analysis_json(args.pr_json)
# PR data is required - if the PR branch can't build, that's a real error
if not pr_data:
print("Error: Failed to load PR analysis JSON", file=sys.stderr)
sys.exit(1)
# Target data is optional - target branch (dev) may fail to build because:
# 1. The target branch has a build issue independent of this PR
# 2. This PR fixes a build issue on the target branch
if not target_data:
print(
"Warning: Target branch analysis unavailable, posting limited comment",
file=sys.stderr,
)
comment_body = create_target_unavailable_comment(pr_data)
post_or_update_comment(args.pr_number, comment_body)
return 0
# Extract detailed analysis if available
target_analysis: dict | None = None
pr_analysis: dict | None = None

View File

@@ -0,0 +1,19 @@
{{ comment_marker }}
## Memory Impact Analysis
**Components:** {{ components_str }}
**Platform:** `{{ platform }}`
| Metric | This PR |
|--------|---------|
| **RAM** | {{ pr_ram }} |
| **Flash** | {{ pr_flash }} |
> ⚠️ **Target branch comparison unavailable** - The target branch failed to build.
> This can happen when the target branch has a build issue, or when this PR fixes a build issue on the target branch.
> The PR branch compiled successfully with the memory usage shown above.
---
> **Note:** This analysis measures **static RAM and Flash usage** only (compile-time allocation).
*This analysis runs automatically when components change.*

View File

@@ -5,7 +5,8 @@ from unittest.mock import MagicMock, patch
import pytest
from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass
from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass, merge_packages
import esphome.config as config_module
from esphome.config import resolve_extend_remove
from esphome.config_helpers import Extend, Remove
import esphome.config_validation as cv
@@ -27,11 +28,13 @@ from esphome.const import (
CONF_REFRESH,
CONF_SENSOR,
CONF_SSID,
CONF_SUBSTITUTIONS,
CONF_UPDATE_INTERVAL,
CONF_URL,
CONF_VARS,
CONF_WIFI,
)
from esphome.core import CORE
from esphome.util import OrderedDict
# Test strings
@@ -68,11 +71,12 @@ def fixture_basic_esphome():
def packages_pass(config):
"""Wrapper around packages_pass that also resolves Extend and Remove."""
config = do_packages_pass(config)
config = merge_packages(config)
resolve_extend_remove(config)
return config
def test_package_unused(basic_esphome, basic_wifi):
def test_package_unused(basic_esphome, basic_wifi) -> None:
"""
Ensures do_package_pass does not change a config if packages aren't used.
"""
@@ -82,7 +86,7 @@ def test_package_unused(basic_esphome, basic_wifi):
assert actual == config
def test_package_invalid_dict(basic_esphome, basic_wifi):
def test_package_invalid_dict(basic_esphome, basic_wifi) -> None:
"""
If a url: key is present, it's expected to be well-formed remote package spec. Ensure an error is raised if not.
Any other simple dict passed as a package will be merged as usual but may fail later validation.
@@ -107,7 +111,7 @@ def test_package_invalid_dict(basic_esphome, basic_wifi):
],
],
)
def test_package_shorthand(packages):
def test_package_shorthand(packages) -> None:
CONFIG_SCHEMA(packages)
@@ -133,12 +137,12 @@ def test_package_shorthand(packages):
[3],
],
)
def test_package_invalid(packages):
def test_package_invalid(packages) -> None:
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA(packages)
def test_package_include(basic_wifi, basic_esphome):
def test_package_include(basic_wifi, basic_esphome) -> None:
"""
Tests the simple case where an independent config present in a package is added to the top-level config as is.
@@ -159,7 +163,7 @@ def test_single_package(
basic_esphome,
basic_wifi,
caplog: pytest.LogCaptureFixture,
):
) -> None:
"""
Tests the simple case where a single package is added to the top-level config as is.
In this test, the CONF_WIFI config is expected to be simply added to the top-level config.
@@ -179,7 +183,7 @@ def test_single_package(
assert "This method for including packages will go away in 2026.7.0" in caplog.text
def test_package_append(basic_wifi, basic_esphome):
def test_package_append(basic_wifi, basic_esphome) -> None:
"""
Tests the case where a key is present in both a package and top-level config.
@@ -204,7 +208,7 @@ def test_package_append(basic_wifi, basic_esphome):
assert actual == expected
def test_package_override(basic_wifi, basic_esphome):
def test_package_override(basic_wifi, basic_esphome) -> None:
"""
Ensures that the top-level configuration takes precedence over duplicate keys defined in a package.
@@ -228,7 +232,7 @@ def test_package_override(basic_wifi, basic_esphome):
assert actual == expected
def test_multiple_package_order():
def test_multiple_package_order() -> None:
"""
Ensures that mutiple packages are merged in order.
"""
@@ -257,7 +261,7 @@ def test_multiple_package_order():
assert actual == expected
def test_package_list_merge():
def test_package_list_merge() -> None:
"""
Ensures lists defined in both a package and the top-level config are merged correctly
"""
@@ -313,7 +317,7 @@ def test_package_list_merge():
assert actual == expected
def test_package_list_merge_by_id():
def test_package_list_merge_by_id() -> None:
"""
Ensures that components with matching IDs are merged correctly.
@@ -391,7 +395,7 @@ def test_package_list_merge_by_id():
assert actual == expected
def test_package_merge_by_id_with_list():
def test_package_merge_by_id_with_list() -> None:
"""
Ensures that components with matching IDs are merged correctly when their configuration contains lists.
@@ -430,7 +434,7 @@ def test_package_merge_by_id_with_list():
assert actual == expected
def test_package_merge_by_missing_id():
def test_package_merge_by_missing_id() -> None:
"""
Ensures that a validation error is thrown when trying to extend a missing ID.
"""
@@ -466,7 +470,7 @@ def test_package_merge_by_missing_id():
assert error_raised
def test_package_list_remove_by_id():
def test_package_list_remove_by_id() -> None:
"""
Ensures that components with matching IDs are removed correctly.
@@ -517,7 +521,7 @@ def test_package_list_remove_by_id():
assert actual == expected
def test_multiple_package_list_remove_by_id():
def test_multiple_package_list_remove_by_id() -> None:
"""
Ensures that components with matching IDs are removed correctly.
@@ -563,7 +567,7 @@ def test_multiple_package_list_remove_by_id():
assert actual == expected
def test_package_dict_remove_by_id(basic_wifi, basic_esphome):
def test_package_dict_remove_by_id(basic_wifi, basic_esphome) -> None:
"""
Ensures that components with missing IDs are removed from dict.
Ensures that the top-level configuration takes precedence over duplicate keys defined in a package.
@@ -584,7 +588,7 @@ def test_package_dict_remove_by_id(basic_wifi, basic_esphome):
assert actual == expected
def test_package_remove_by_missing_id():
def test_package_remove_by_missing_id() -> None:
"""
Ensures that components with missing IDs are not merged.
"""
@@ -632,7 +636,7 @@ def test_package_remove_by_missing_id():
@patch("esphome.git.clone_or_update")
def test_remote_packages_with_files_list(
mock_clone_or_update, mock_is_file, mock_load_yaml
):
) -> None:
"""
Ensures that packages are loaded as mixed list of dictionary and strings
"""
@@ -704,7 +708,7 @@ def test_remote_packages_with_files_list(
@patch("esphome.git.clone_or_update")
def test_remote_packages_with_files_and_vars(
mock_clone_or_update, mock_is_file, mock_load_yaml
):
) -> None:
"""
Ensures that packages are loaded as mixed list of dictionary and strings with vars
"""
@@ -793,3 +797,231 @@ def test_remote_packages_with_files_and_vars(
actual = packages_pass(config)
assert actual == expected
def test_packages_merge_substitutions() -> None:
"""
Tests that substitutions from packages in a complex package hierarchy
are extracted and merged into the top-level config.
"""
config = {
CONF_SUBSTITUTIONS: {
"a": 1,
"b": 2,
"c": 3,
},
CONF_PACKAGES: {
"package1": {
"logger": {
"level": "DEBUG",
},
CONF_PACKAGES: [
{
CONF_SUBSTITUTIONS: {
"a": 10,
"e": 5,
},
"sensor": [
{"platform": "template", "id": "sensor1"},
],
},
],
"sensor": [
{"platform": "template", "id": "sensor2"},
],
},
"package2": {
"logger": {
"level": "VERBOSE",
},
},
"package3": {
CONF_PACKAGES: [
{
CONF_PACKAGES: [
{
CONF_SUBSTITUTIONS: {
"b": 20,
"d": 4,
},
"sensor": [
{"platform": "template", "id": "sensor3"},
],
},
],
CONF_SUBSTITUTIONS: {
"b": 20,
"d": 6,
},
"sensor": [
{"platform": "template", "id": "sensor4"},
],
},
],
},
},
}
expected = {
CONF_SUBSTITUTIONS: {"a": 1, "e": 5, "b": 2, "d": 6, "c": 3},
CONF_PACKAGES: {
"package1": {
"logger": {
"level": "DEBUG",
},
CONF_PACKAGES: [
{
"sensor": [
{"platform": "template", "id": "sensor1"},
],
},
],
"sensor": [
{"platform": "template", "id": "sensor2"},
],
},
"package2": {
"logger": {
"level": "VERBOSE",
},
},
"package3": {
CONF_PACKAGES: [
{
CONF_PACKAGES: [
{
"sensor": [
{"platform": "template", "id": "sensor3"},
],
},
],
"sensor": [
{"platform": "template", "id": "sensor4"},
],
},
],
},
},
}
actual = do_packages_pass(config)
assert actual == expected
def test_package_merge() -> None:
"""
Tests that all packages are merged into the top-level config.
"""
config = {
CONF_SUBSTITUTIONS: {"a": 1, "e": 5, "b": 2, "d": 6, "c": 3},
CONF_PACKAGES: {
"package1": {
"logger": {
"level": "DEBUG",
},
CONF_PACKAGES: [
{
"sensor": [
{"platform": "template", "id": "sensor1"},
],
},
],
"sensor": [
{"platform": "template", "id": "sensor2"},
],
},
"package2": {
"logger": {
"level": "VERBOSE",
},
},
"package3": {
CONF_PACKAGES: [
{
CONF_PACKAGES: [
{
"sensor": [
{"platform": "template", "id": "sensor3"},
],
},
],
"sensor": [
{"platform": "template", "id": "sensor4"},
],
},
],
},
},
}
expected = {
"sensor": [
{"platform": "template", "id": "sensor1"},
{"platform": "template", "id": "sensor2"},
{"platform": "template", "id": "sensor3"},
{"platform": "template", "id": "sensor4"},
],
"logger": {"level": "VERBOSE"},
CONF_SUBSTITUTIONS: {"a": 1, "e": 5, "b": 2, "d": 6, "c": 3},
}
actual = merge_packages(config)
assert actual == expected
@pytest.mark.parametrize(
"invalid_package",
[
6,
"some string",
["some string"],
None,
True,
{"some_component": 8},
{3: 2},
{"some_component": r"${unevaluated expression}"},
],
)
def test_package_merge_invalid(invalid_package) -> None:
"""
Tests that trying to merge an invalid package raises an error.
"""
config = {
CONF_PACKAGES: {
"some_package": invalid_package,
},
}
with pytest.raises(cv.Invalid):
merge_packages(config)
def test_raw_config_contains_merged_esphome_from_package(tmp_path) -> None:
"""Test that CORE.raw_config contains esphome section from merged package.
This is a regression test for the bug where CORE.raw_config was set before
packages were merged, causing KeyError when components accessed
CORE.raw_config[CONF_ESPHOME] and the esphome section came from a package.
"""
# Create a config where esphome section comes from a package
test_config = OrderedDict()
test_config[CONF_PACKAGES] = {
"base": {
CONF_ESPHOME: {CONF_NAME: TEST_DEVICE_NAME},
}
}
test_config["esp32"] = {"board": "esp32dev"}
# Set up CORE for the test
test_yaml = tmp_path / "test.yaml"
test_yaml.write_text("# test config")
CORE.reset()
CORE.config_path = test_yaml
# Call validate_config - this should merge packages and set CORE.raw_config
config_module.validate_config(test_config, {})
# Verify that CORE.raw_config contains the esphome section from the package
assert CONF_ESPHOME in CORE.raw_config, (
"CORE.raw_config should contain esphome section after package merge"
)
assert CORE.raw_config[CONF_ESPHOME][CONF_NAME] == TEST_DEVICE_NAME

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