Compare commits

...

24 Commits

Author SHA1 Message Date
J. Nick Koston
5e881738da [api] Add speed_optimized proto option for hot encode paths
Add a new (speed_optimized) message option that emits
__attribute__((optimize("O2"))) on the generated encode() and
calculate_size() methods. Under -Os, GCC does not inline the small
ProtoEncode helpers (write_raw_byte, encode_varint, etc.) into the
generated methods, causing significant overhead on hot paths.

Apply to SensorStateResponse and BluetoothLERawAdvertisementsResponse
which are the highest-frequency encode paths.
2026-04-12 19:12:31 -10:00
J. Nick Koston
5a250cc74f [api] Compile noise-c and libsodium with -O2 for speed
Crypto libraries are CPU-bound and benefit significantly from speed
optimization over the default -Os. Add a post: extra_script that
appends -O2 to noise-c and libsodium build flags when API noise
encryption is enabled. GCC uses the last -O flag, so this overrides
the global -Os for these libraries only.
2026-04-12 19:03:21 -10:00
J. Nick Koston
02f828fcbf [benchmark] Use -Os to match firmware optimization level
CodSpeed benchmarks were building with -O2, while all firmware
targets (ESP8266, ESP32, LibreTiny) use -Os. This mismatch means
the benchmarks cannot detect inlining regressions that affect real
devices — GCC under -O2 inlines functions that -Os outlines due to
its size-conscious cost model.

Switch to -Os with -ffunction-sections/-fdata-sections for proper
dead-code stripping (needed because -Os preserves references that
-O2 optimizes away at compile time).
2026-04-12 18:37:50 -10:00
J. Nick Koston
ab64916c37 [benchmark] Use -Os to match firmware optimization level
CodSpeed benchmarks were building with -O2, while all firmware
targets (ESP8266, ESP32, LibreTiny) use -Os. This mismatch means
the benchmarks cannot detect inlining regressions that affect real
devices — GCC under -O2 inlines functions that -Os outlines due to
its size-conscious cost model.

Remove the -Os unflag and -O2 override so benchmarks use the
platform default -Os, matching what actually runs on devices.
2026-04-12 18:32:03 -10:00
Jesse Hills
5608aa10a5 [CI] Don't run label workflow on closed/merged PRs (#15678) 2026-04-12 12:46:49 -10:00
Javier Peletier
daa68a2a60 [packages] fix support packages: !include mypackages.yaml (#15677) 2026-04-13 09:48:30 +12:00
Clyde Stubbs
8754bbfa89 [lvgl] Fix use of rotation on host SDL (#15611)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-04-12 20:29:11 +00:00
J. Nick Koston
6d92cc3d2b [packages] Fix false deprecation warning and wrong error paths in nested packages (#15605) 2026-04-13 08:24:23 +12:00
Jonathan Swoboda
2f684bf4f3 [esp32] Bump platform to 55.03.38, Arduino to 3.3.8, ESP-IDF to 5.5.4 (#15666) 2026-04-12 10:07:04 -10:00
Jonathan Swoboda
45af21bf38 [canbus] Fix canbus.send can_id compile error (#15668) 2026-04-12 09:58:51 -10:00
Jonathan Swoboda
e6318a2d16 [mdns] Bump espressif/mdns to 1.11.0 (#15670) 2026-04-12 09:54:30 -10:00
Jonathan Swoboda
bef4c8a86c [cc1101] Extract chip configuration into configure() method (#15635) 2026-04-11 17:36:27 -04:00
Farmer-shin
6e67864510 [epaper_spi] Add Waveshare 3.97inch E-Paper Display (#15466) 2026-04-11 21:27:25 +10:00
dependabot[bot]
c2af4874f9 Bump aioesphomeapi from 44.13.2 to 44.13.3 (#15641)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-11 08:58:20 +00:00
dependabot[bot]
2001b91280 Bump resvg-py from 0.3.0 to 0.3.1 (#15640)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-11 08:57:39 +00:00
dependabot[bot]
5460ee7edd Bump aioesphomeapi from 44.13.1 to 44.13.2 (#15637)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 15:55:15 -10:00
J. Nick Koston
40081e5ae7 [rp2040] Fix W5500 Ethernet pbuf corruption by mirroring LWIPMutex semantics (#15624)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-10 13:13:05 -10:00
Jonathan Swoboda
a7c5b0ab46 [sx127x][cc1101][sx126x] Use GPIO interrupt to wake loop (#15627) 2026-04-10 16:26:09 -04:00
dependabot[bot]
e1a813e11f Bump peter-evans/create-pull-request from 8.1.0 to 8.1.1 (#15630)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 10:21:01 -10:00
dependabot[bot]
1dfeef0265 Bump actions/github-script from 8.0.0 to 9.0.0 (#15632)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 10:20:43 -10:00
dependabot[bot]
395610c117 Bump docker/build-push-action from 7.0.0 to 7.1.0 in /.github/actions/build-image (#15633)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 10:20:17 -10:00
dependabot[bot]
ae96f82b82 Bump actions/upload-artifact from 7.0.0 to 7.0.1 (#15631)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 10:20:04 -10:00
dependabot[bot]
2c610abcd0 Bump resvg-py from 0.2.6 to 0.3.0 (#15629)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 10:19:52 -10:00
Kevin Ahrendt
d3591c8d9e [micro_wake_word] Pin esp-nn version (#15628) 2026-04-10 15:21:26 -04:00
55 changed files with 729 additions and 313 deletions

View File

@@ -1 +1 @@
f31f13994768b5b07e29624406c9b053bf4bb26e1623ac2bc1e9d4a9477502d6
d48687d988ae2a94a9973226df773478a7db1d52133545f07aa05e34fc678dcf

View File

@@ -47,7 +47,7 @@ runs:
- name: Build and push to ghcr by digest
id: build-ghcr
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
@@ -73,7 +73,7 @@ runs:
- name: Build and push to dockerhub by digest
id: build-dockerhub
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false

View File

@@ -20,7 +20,7 @@ env:
jobs:
label:
runs-on: ubuntu-latest
if: github.event.action != 'labeled' || github.event.sender.type != 'Bot'
if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot')
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -33,7 +33,7 @@ jobs:
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
- name: Auto Label PR
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |

View File

@@ -47,7 +47,7 @@ jobs:
fi
- if: failure()
name: Review PR
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
await github.rest.pulls.createReview({
@@ -62,7 +62,7 @@ jobs:
run: git diff
- if: failure()
name: Archive artifacts
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: generated-proto-files
path: |
@@ -70,7 +70,7 @@ jobs:
esphome/components/api/api_pb2_service.*
- if: success()
name: Dismiss review
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
let reviews = await github.rest.pulls.listReviews({

View File

@@ -42,7 +42,7 @@ jobs:
- if: failure() && github.event.pull_request.head.repo.full_name == github.repository
name: Request changes
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
await github.rest.pulls.createReview({
@@ -55,7 +55,7 @@ jobs:
- if: success() && github.event.pull_request.head.repo.full_name == github.repository
name: Dismiss review
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
let reviews = await github.rest.pulls.listReviews({

View File

@@ -904,7 +904,7 @@ jobs:
fi
- name: Upload memory analysis JSON
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: memory-analysis-target
path: memory-analysis-target.json
@@ -969,7 +969,7 @@ jobs:
--platform "$platform"
- name: Upload memory analysis JSON
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: memory-analysis-pr
path: memory-analysis-pr.json

View File

@@ -34,7 +34,7 @@ jobs:
CODEOWNERS
- name: Check codeowner approval and update label
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
with:

View File

@@ -33,7 +33,7 @@ jobs:
ref: ${{ github.event.pull_request.base.sha }}
- name: Request reviews from component codeowners
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { loadCodeowners, getEffectiveOwners } = require('./.github/scripts/codeowners.js');

View File

@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Add external component comment
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |

View File

@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Notify codeowners for component issues
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const owner = context.repo.owner;

View File

@@ -18,7 +18,7 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const {

View File

@@ -138,7 +138,7 @@ jobs:
# version: ${{ needs.init.outputs.tag }}
- name: Upload digests
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digests-${{ matrix.platform.arch }}
path: /tmp/digests
@@ -229,7 +229,7 @@ jobs:
repositories: home-assistant-addon
- name: Trigger Workflow
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
@@ -264,7 +264,7 @@ jobs:
repositories: esphome-schema
- name: Trigger Workflow
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
@@ -295,7 +295,7 @@ jobs:
repositories: version-notifier
- name: Trigger Workflow
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check for blocking labels
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const blockingLabels = ['needs-docs', 'merge-after-release', 'chained-pr'];

View File

@@ -41,7 +41,7 @@ jobs:
python script/run-in-env.py pre-commit run --all-files
- name: Commit changes
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
commit-message: "Synchronise Device Classes from Home Assistant"
committer: esphomebot <esphome@openhomefoundation.org>

View File

@@ -1,5 +1,6 @@
import base64
import logging
import pathlib
from esphome import automation
from esphome.automation import Condition
@@ -458,6 +459,10 @@ async def to_code(config: ConfigType) -> None:
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
# Compile crypto libraries with -O2 for speed instead of -Os.
# Crypto is CPU-bound and benefits significantly from speed optimization.
# GCC uses the last -O flag, so appending -O2 overrides the global -Os.
_write_crypto_optimize_script()
else:
cg.add_define("USE_API_PLAINTEXT")
@@ -465,6 +470,17 @@ async def to_code(config: ConfigType) -> None:
cg.add_global(api_ns.using)
_CRYPTO_OPTIMIZE_SCRIPT = "crypto_optimize.py"
def _write_crypto_optimize_script() -> None:
from esphome.helpers import copy_file_if_changed
script_src = pathlib.Path(__file__).parent / f"{_CRYPTO_OPTIMIZE_SCRIPT}.script"
copy_file_if_changed(script_src, CORE.relative_build_path(_CRYPTO_OPTIMIZE_SCRIPT))
cg.add_platformio_option("extra_scripts", [f"post:{_CRYPTO_OPTIMIZE_SCRIPT}"])
KEY_VALUE_SCHEMA = cv.Schema({cv.string: cv.templatable(cv.string_strict)})

View File

@@ -671,6 +671,7 @@ message SensorStateResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_SENSOR";
option (no_delay) = true;
option (speed_optimized) = true;
fixed32 key = 1 [(force) = true];
float state = 2;
@@ -1638,6 +1639,7 @@ message BluetoothLERawAdvertisementsResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (no_delay) = true;
option (speed_optimized) = true;
repeated BluetoothLERawAdvertisement advertisements = 1 [(fixed_array_with_length_define) = "BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE"];
}

View File

@@ -23,6 +23,7 @@ extend google.protobuf.MessageOptions {
optional bool no_delay = 1040 [default=false];
optional string base_class = 1041;
optional bool inline_encode = 1042 [default=false];
optional bool speed_optimized = 1043 [default=false];
}
extend google.protobuf.FieldOptions {

View File

@@ -745,7 +745,8 @@ uint32_t ListEntitiesSensorResponse::calculate_size() const {
#endif
return size;
}
uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
__attribute__((optimize("O2"))) uint8_t *SensorStateResponse::encode(
ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 13, this->key);
ProtoEncode::encode_float(pos PROTO_ENCODE_DEBUG_ARG, 2, this->state);
@@ -755,7 +756,7 @@ uint8_t *SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG
#endif
return pos;
}
uint32_t SensorStateResponse::calculate_size() const {
__attribute__((optimize("O2"))) uint32_t SensorStateResponse::calculate_size() const {
uint32_t size = 0;
size += 5;
size += ProtoSize::calc_float(1, this->state);
@@ -2328,7 +2329,8 @@ bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id,
}
return true;
}
uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
__attribute__((optimize("O2"))) uint8_t *BluetoothLERawAdvertisementsResponse::encode(
ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
for (uint16_t i = 0; i < this->advertisements_len; i++) {
auto &sub_msg = this->advertisements[i];
@@ -2350,7 +2352,7 @@ uint8_t *BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer P
}
return pos;
}
uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const {
__attribute__((optimize("O2"))) uint32_t BluetoothLERawAdvertisementsResponse::calculate_size() const {
uint32_t size = 0;
for (uint16_t i = 0; i < this->advertisements_len; i++) {
auto &sub_msg = this->advertisements[i];

View File

@@ -0,0 +1,9 @@
# Compile crypto libraries with -O2 for speed instead of the default -Os.
# Crypto is CPU-bound and benefits significantly from speed optimization.
# GCC uses the last -O flag, so appending -O2 overrides the global -Os
# for these libraries only.
Import("env")
for lb in env.GetLibBuilders():
if lb.name in ("noise-c", "libsodium"):
lb.env.Append(CCFLAGS=["-O2"])

View File

@@ -162,7 +162,6 @@ async def canbus_action_to_code(config, action_id, template_arg, args):
await cg.register_parented(var, config[CONF_CANBUS_ID])
if (can_id := config.get(CONF_CAN_ID)) is not None:
can_id = await cg.templatable(can_id, args, cg.uint32)
cg.add(var.set_can_id(can_id))
cg.add(var.set_use_extended_id(config[CONF_USE_EXTENDED_ID]))

View File

@@ -102,8 +102,34 @@ CC1101Component::CC1101Component() {
memset(this->pa_table_, 0, sizeof(this->pa_table_));
}
void IRAM_ATTR CC1101Component::gpio_intr(CC1101Component *arg) { arg->enable_loop_soon_any_context(); }
void CC1101Component::setup() {
this->spi_setup();
if (this->gdo0_pin_ != nullptr) {
this->gdo0_pin_->setup();
}
this->configure();
if (this->is_failed()) {
return;
}
// 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);
if (this->state_.PKT_FORMAT == static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO)) {
this->gdo0_pin_->attach_interrupt(&CC1101Component::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE);
}
});
}
}
void CC1101Component::configure() {
// Manual reset sequence per CC1101 datasheet section 19.1.2
this->cs_->digital_write(true);
delayMicroseconds(1);
this->cs_->digital_write(false);
@@ -126,11 +152,6 @@ 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++) {
@@ -140,20 +161,11 @@ void CC1101Component::setup() {
this->write_(static_cast<Register>(i));
}
this->set_output_power(this->output_power_requested_);
if (!this->enter_rx_()) {
this->mark_failed();
return;
}
// 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); });
}
if (this->state_.PKT_FORMAT != static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO)) {
this->disable_loop();
}
}
void CC1101Component::call_listeners_(const std::vector<uint8_t> &packet, float freq_offset, float rssi, uint8_t lqi) {
@@ -164,6 +176,7 @@ void CC1101Component::call_listeners_(const std::vector<uint8_t> &packet, float
}
void CC1101Component::loop() {
this->disable_loop();
if (this->state_.PKT_FORMAT != static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO) || this->gdo0_pin_ == nullptr ||
!this->gdo0_pin_->digital_read()) {
return;
@@ -244,6 +257,7 @@ void CC1101Component::begin_tx() {
this->write_(Register::PKTCTRL0, 0x32);
ESP_LOGV(TAG, "Beginning TX sequence");
if (this->gdo0_pin_ != nullptr) {
this->gdo0_pin_->detach_interrupt();
this->gdo0_pin_->pin_mode(gpio::FLAG_OUTPUT);
}
// Transition through IDLE to bypass CCA (Clear Channel Assessment) which can
@@ -268,7 +282,7 @@ void CC1101Component::begin_rx() {
void CC1101Component::reset() {
this->strobe_(Command::RES);
this->setup();
this->configure();
}
void CC1101Component::set_idle() {
@@ -673,10 +687,12 @@ void CC1101Component::set_packet_mode(bool value) {
this->state_.GDO0_CFG = 0x0D;
}
if (this->initialized_) {
if (value) {
this->enable_loop();
} else {
this->disable_loop();
if (this->gdo0_pin_ != nullptr) {
if (value) {
this->gdo0_pin_->attach_interrupt(&CC1101Component::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE);
} else {
this->gdo0_pin_->detach_interrupt();
}
}
this->write_(Register::PKTCTRL0);
this->write_(Register::PKTCTRL1);

View File

@@ -25,6 +25,7 @@ class CC1101Component : public Component,
void setup() override;
void loop() override;
void dump_config() override;
void configure();
// Actions
void begin_tx();
@@ -93,6 +94,7 @@ class CC1101Component : public Component,
// GDO pin for packet reception
InternalGPIOPin *gdo0_pin_{nullptr};
static void IRAM_ATTR gpio_intr(CC1101Component *arg);
// Packet handling
void call_listeners_(const std::vector<uint8_t> &packet, float freq_offset, float rssi, uint8_t lqi);

View File

@@ -43,3 +43,11 @@ wave_4_26.extend(
},
},
)
ssd1677.extend(
"waveshare-3.97in",
width=800,
height=480,
mirror_x=True,
)

View File

@@ -671,11 +671,12 @@ def _is_framework_url(source: str) -> bool:
# The default/recommended arduino framework version
# - https://github.com/espressif/arduino-esp32/releases
ARDUINO_FRAMEWORK_VERSION_LOOKUP = {
"recommended": cv.Version(3, 3, 7),
"latest": cv.Version(3, 3, 7),
"dev": cv.Version(3, 3, 7),
"recommended": cv.Version(3, 3, 8),
"latest": cv.Version(3, 3, 8),
"dev": cv.Version(3, 3, 8),
}
ARDUINO_PLATFORM_VERSION_LOOKUP = {
cv.Version(3, 3, 8): cv.Version(55, 3, 38),
cv.Version(3, 3, 7): cv.Version(55, 3, 37),
cv.Version(3, 3, 6): cv.Version(55, 3, 36),
cv.Version(3, 3, 5): cv.Version(55, 3, 35),
@@ -695,6 +696,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = {
# These versions correspond to pioarduino/esp-idf releases
# See: https://github.com/pioarduino/esp-idf/releases
ARDUINO_IDF_VERSION_LOOKUP = {
cv.Version(3, 3, 8): cv.Version(5, 5, 4),
cv.Version(3, 3, 7): cv.Version(5, 5, 3, "1"),
cv.Version(3, 3, 6): cv.Version(5, 5, 2),
cv.Version(3, 3, 5): cv.Version(5, 5, 2),
@@ -714,17 +716,15 @@ ARDUINO_IDF_VERSION_LOOKUP = {
# The default/recommended esp-idf framework version
# - https://github.com/espressif/esp-idf/releases
ESP_IDF_FRAMEWORK_VERSION_LOOKUP = {
"recommended": cv.Version(5, 5, 3, "1"),
"latest": cv.Version(5, 5, 3, "1"),
"recommended": cv.Version(5, 5, 4),
"latest": cv.Version(5, 5, 4),
"dev": cv.Version(5, 5, 4),
}
ESP_IDF_PLATFORM_VERSION_LOOKUP = {
cv.Version(
6, 0, 0
): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6",
cv.Version(
5, 5, 4
): "https://github.com/pioarduino/platform-espressif32.git#develop",
cv.Version(5, 5, 4): cv.Version(55, 3, 38),
cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37),
cv.Version(5, 5, 3): cv.Version(55, 3, 37),
cv.Version(5, 5, 2): cv.Version(55, 3, 37),
@@ -744,8 +744,8 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = {
# The platform-espressif32 version
# - https://github.com/pioarduino/platform-espressif32/releases
PLATFORM_VERSION_LOOKUP = {
"recommended": cv.Version(55, 3, 37),
"latest": cv.Version(55, 3, 37),
"recommended": cv.Version(55, 3, 38),
"latest": cv.Version(55, 3, 38),
"dev": "https://github.com/pioarduino/platform-espressif32.git#develop",
}

View File

@@ -1960,6 +1960,10 @@ BOARDS = {
"name": "Hornbill ESP32 Minima",
"variant": VARIANT_ESP32,
},
"huidu_hd_wf1": {
"name": "Huidu HD-WF1",
"variant": VARIANT_ESP32S2,
},
"huidu_hd_wf2": {
"name": "Huidu HD-WF2",
"variant": VARIANT_ESP32S3,
@@ -2028,6 +2032,10 @@ BOARDS = {
"name": "LilyGo T-Display-S3",
"variant": VARIANT_ESP32S3,
},
"lilygo-t-energy-s3": {
"name": "LilyGo T-Energy-S3",
"variant": VARIANT_ESP32S3,
},
"lilygo-t3-s3": {
"name": "LilyGo T3-S3",
"variant": VARIANT_ESP32S3,
@@ -2289,10 +2297,18 @@ BOARDS = {
"name": "S.ODI Ultra v1",
"variant": VARIANT_ESP32,
},
"seeed_xiao_esp32_s3_plus": {
"name": "Seeed Studio XIAO ESP32S3 Plus",
"variant": VARIANT_ESP32S3,
},
"seeed_xiao_esp32c3": {
"name": "Seeed Studio XIAO ESP32C3",
"variant": VARIANT_ESP32C3,
},
"seeed_xiao_esp32c5": {
"name": "Seeed Studio XIAO ESP32C5",
"variant": VARIANT_ESP32C5,
},
"seeed_xiao_esp32c6": {
"name": "Seeed Studio XIAO ESP32C6",
"variant": VARIANT_ESP32C6,

View File

@@ -341,7 +341,7 @@ async def to_code(configs):
df.LOGGER.info("LVGL will use hardware rotation via display driver")
else:
rotation_type = RotationType.ROTATION_SOFTWARE
if get_esp32_variant() == VARIANT_ESP32P4:
if CORE.is_esp32 and get_esp32_variant() == VARIANT_ESP32P4:
df.LOGGER.info("LVGL will use software rotation (PPA accelerated)")
else:
df.LOGGER.info("LVGL will use software rotation")

View File

@@ -170,7 +170,7 @@ async def to_code(config):
cg.add_library("LEAmDNS", None)
if CORE.is_esp32:
add_idf_component(name="espressif/mdns", ref="1.10.0")
add_idf_component(name="espressif/mdns", ref="1.11.0")
cg.add_define("USE_MDNS")

View File

@@ -451,6 +451,8 @@ async def to_code(config):
ota.request_ota_state_listeners()
esp32.add_idf_component(name="espressif/esp-tflite-micro", ref="1.3.3~1")
# Pin esp-nn for stable future builds (esp-tflite-micro depends on esp-nn)
esp32.add_idf_component(name="espressif/esp-nn", ref="1.2.1")
cg.add_build_flag("-DTF_LITE_STATIC_MEMORY")
cg.add_build_flag("-DTF_LITE_DISABLE_X86_NEON")

View File

@@ -29,14 +29,6 @@ void VADModel::log_model_config() {
bool StreamingModel::load_model_() {
RAMAllocator<uint8_t> arena_allocator;
if (this->tensor_arena_ == nullptr) {
this->tensor_arena_ = arena_allocator.allocate(this->tensor_arena_size_);
if (this->tensor_arena_ == nullptr) {
ESP_LOGE(TAG, "Could not allocate the streaming model's tensor arena.");
return false;
}
}
if (this->var_arena_ == nullptr) {
this->var_arena_ = arena_allocator.allocate(STREAMING_MODEL_VARIABLE_ARENA_SIZE);
if (this->var_arena_ == nullptr) {
@@ -53,6 +45,26 @@ bool StreamingModel::load_model_() {
return false;
}
// Probe for the actual required tensor arena size if not yet determined
if (!this->tensor_arena_size_probed_) {
size_t probed_size = this->probe_arena_size_();
if (probed_size > 0) {
ESP_LOGD(TAG, "Probed tensor arena size: %zu bytes", probed_size);
this->tensor_arena_size_ = probed_size;
} else {
ESP_LOGW(TAG, "Arena size probe failed, using manifest size: %zu bytes", this->tensor_arena_size_);
}
this->tensor_arena_size_probed_ = true;
}
if (this->tensor_arena_ == nullptr) {
this->tensor_arena_ = arena_allocator.allocate(this->tensor_arena_size_);
if (this->tensor_arena_ == nullptr) {
ESP_LOGE(TAG, "Could not allocate the streaming model's tensor arena.");
return false;
}
}
if (this->interpreter_ == nullptr) {
this->interpreter_ =
make_unique<tflite::MicroInterpreter>(tflite::GetModel(this->model_start_), this->streaming_op_resolver_,
@@ -94,6 +106,70 @@ bool StreamingModel::load_model_() {
return true;
}
size_t StreamingModel::probe_arena_size_() {
RAMAllocator<uint8_t> arena_allocator;
// Try with the manifest size first, then escalates to 1.5, then 2x if it fails. Different platforms and different
// versions of the esp-nn library require different amounts of memory, so the manifest size may not always be correct,
// and probing allows us to find the actual required size for the current build and platform. Aligns test sizes to 16
// bytes.
size_t attempt_sizes[] = {(this->tensor_arena_size_ + 15) & ~15, (this->tensor_arena_size_ * 3 / 2 + 15) & ~15,
(this->tensor_arena_size_ * 2 + 15) & ~15};
for (size_t attempt_size : attempt_sizes) {
uint8_t *probe_arena = arena_allocator.allocate(attempt_size);
if (probe_arena == nullptr) {
continue;
}
// Verify the model works at all with this arena size
auto probe_interpreter = make_unique<tflite::MicroInterpreter>(
tflite::GetModel(this->model_start_), this->streaming_op_resolver_, probe_arena, attempt_size, this->mrv_);
if (probe_interpreter->AllocateTensors() != kTfLiteOk) {
probe_interpreter.reset();
arena_allocator.deallocate(probe_arena, attempt_size);
this->ma_ = tflite::MicroAllocator::Create(this->var_arena_, STREAMING_MODEL_VARIABLE_ARENA_SIZE);
this->mrv_ = tflite::MicroResourceVariables::Create(this->ma_, 20);
continue;
}
// Try to shrink the arena. Start with arena_used_bytes() + 16 (rounded to 16-byte alignment).
// If that works, use it. Otherwise, try midpoints between that and the full size until one succeeds.
size_t lower = (probe_interpreter->arena_used_bytes() + 16 + 15) & ~15;
probe_interpreter.reset();
this->ma_ = tflite::MicroAllocator::Create(this->var_arena_, STREAMING_MODEL_VARIABLE_ARENA_SIZE);
this->mrv_ = tflite::MicroResourceVariables::Create(this->ma_, 20);
size_t upper = attempt_size;
while (lower < upper) {
auto test_interpreter = make_unique<tflite::MicroInterpreter>(
tflite::GetModel(this->model_start_), this->streaming_op_resolver_, probe_arena, lower, this->mrv_);
bool ok = test_interpreter->AllocateTensors() == kTfLiteOk;
test_interpreter.reset();
this->ma_ = tflite::MicroAllocator::Create(this->var_arena_, STREAMING_MODEL_VARIABLE_ARENA_SIZE);
this->mrv_ = tflite::MicroResourceVariables::Create(this->ma_, 20);
if (ok) {
// Found a working size smaller than the full arena
upper = lower + 16; // Pad by 16 bytes to be safe for future allocations
break;
}
// Try the midpoint between current attempt and full size
lower = ((lower + upper) / 2 + 15) & ~15;
}
arena_allocator.deallocate(probe_arena, attempt_size);
return upper;
}
return 0;
}
void StreamingModel::unload_model() {
this->interpreter_.reset();

View File

@@ -63,6 +63,10 @@ class StreamingModel {
/// @brief Allocates tensor and variable arenas and sets up the model interpreter
/// @return True if successful, false otherwise
bool load_model_();
/// @brief Probes the actual required tensor arena size by trial allocation.
/// Tries the manifest size first, then 2x if that fails.
/// @return The required arena size rounded up to 16-byte alignment, or 0 on failure.
size_t probe_arena_size_();
/// @brief Returns true if successfully registered the streaming model's TensorFlow operations
bool register_streaming_ops_(tflite::MicroMutableOpResolver<20> &op_resolver);
@@ -70,6 +74,7 @@ class StreamingModel {
bool loaded_{false};
bool enabled_{true};
bool tensor_arena_size_probed_{false};
bool unprocessed_probability_status_{false};
uint8_t current_stride_step_{0};
int16_t ignore_windows_{-MIN_SLICES_BEFORE_DETECTION};

View File

@@ -45,6 +45,18 @@ def is_remote_package(package_config: dict) -> bool:
return CONF_URL in package_config
def is_package_definition(value: object) -> bool:
"""Returns True if the value looks like a package definition rather than a config fragment.
Package definitions are IncludeFile objects, git URL shorthand strings, or
remote package dicts (containing a ``url:`` key). Config fragments are
plain dicts that represent component configuration.
"""
return isinstance(value, (yaml_util.IncludeFile, str)) or (
isinstance(value, dict) and is_remote_package(value)
)
def valid_package_contents(package_config: dict) -> dict:
"""Validate that a package looks like a plausible ESPHome config fragment.
@@ -309,20 +321,23 @@ def _walk_packages(
return config
packages = config[CONF_PACKAGES]
if not isinstance(packages, (dict, list)):
raise cv.Invalid(
f"Packages must be a key to value mapping or list, got {type(packages)} instead"
)
with cv.prepend_path(CONF_PACKAGES):
if isinstance(packages, yaml_util.IncludeFile):
# If the packages key is an IncludeFile, resolve it first before processing.
packages, _ = resolve_include(packages, [], context, strict_undefined=False)
if not isinstance(packages, (dict, list)):
raise cv.Invalid(
f"Packages must be a key to value mapping or list, got {type(packages)} instead"
)
if not isinstance(packages, dict):
_walk_package_list(packages, callback, context)
elif (result := _walk_package_dict(packages, callback, context)) is not None:
if not validate_deprecated:
if not validate_deprecated or any(
is_package_definition(v) for v in packages.values()
):
raise result
# Fallback: treat the dict as a single deprecated package.
# Note: this catches *any* cv.Invalid from the callback, which may
# mask real validation errors in named package dicts.
# This block can be removed once the single-package
# deprecation period (2026.7.0) is over.
config[CONF_PACKAGES] = [packages]
@@ -461,6 +476,9 @@ class _PackageProcessor:
self, package_config: dict | str, context_vars: ContextVars | None
) -> dict:
"""Resolve a single package and recurse into any nested packages."""
from_remote = isinstance(package_config, dict) and is_remote_package(
package_config
)
package_config = self.resolve_package(package_config, context_vars)
self.collect_substitutions(package_config)
@@ -470,7 +488,18 @@ class _PackageProcessor:
# Push context from !include vars on the package root and on the packages key
context_vars = push_context(package_config, context_vars)
context_vars = push_context(package_config[CONF_PACKAGES], context_vars)
return _walk_packages(package_config, self.process_package, context_vars)
# Disable the deprecated single-package fallback for remote
# packages. _process_remote_package returns dicts with
# already-resolved values that is_package_definition cannot
# distinguish from config fragments, so the fallback would
# always fire and mask real errors with wrong paths
# (packages->0 instead of packages-><name>).
return _walk_packages(
package_config,
self.process_package,
context_vars,
validate_deprecated=not from_remote,
)
def do_packages_pass(

View File

@@ -9,7 +9,7 @@
#include <WiFi.h>
#include <pico/cyw43_arch.h> // For cyw43_arch_lwip_begin/end (LwIPLock)
#elif defined(USE_ETHERNET)
#include <LwipEthernet.h> // For ethernet_arch_lwip_begin/end (LwIPLock)
#include <lwip_wrap.h> // For LWIPMutex — LwIPLock mirrors its semantics (see below)
#include "esphome/components/ethernet/ethernet_component.h"
#endif
#include <hardware/structs/rosc.h>
@@ -43,9 +43,18 @@ IRAM_ATTR InterruptLock::~InterruptLock() { restore_interrupts(state_); }
// main loop, corrupting the shared rx_buf_ pbuf chain (use-after-free, pbuf_cat
// assertion failures). See esphome#10681.
//
// WiFi uses cyw43_arch_lwip_begin/end; Ethernet uses ethernet_arch_lwip_begin/end.
// Both acquire the async_context recursive mutex to prevent IRQ callbacks from
// firing during critical sections.
// WiFi uses cyw43_arch_lwip_begin/end.
//
// For wired Ethernet, taking only the async_context lock is NOT enough. The
// W5500 GPIO IRQ path (LwipIntfDev::_irq) checks arduino-pico's `__inLWIP`
// counter to decide whether to defer packet processing. If we hold the
// async_context lock without bumping `__inLWIP`, an interrupt-driven packet
// arrival re-enters lwIP from IRQ context and corrupts pbufs (the `pbuf_cat`
// assertion crash on wiznet-w5500-evb-pico). We mirror arduino-pico's
// LWIPMutex (cores/rp2040/lwip_wrap.h) exactly: bump `__inLWIP`, take the
// lock, and on release re-unmask any GPIO IRQs that were deferred while we
// held it. We can't `using LwIPLock = LWIPMutex;` in helpers.h because
// pulling lwip_wrap.h there poisons many TUs with lwIP types.
//
// When neither WiFi nor Ethernet is configured, this is a no-op since
// there's no network stack and no lwip callbacks to race with.
@@ -53,8 +62,18 @@ IRAM_ATTR InterruptLock::~InterruptLock() { restore_interrupts(state_); }
LwIPLock::LwIPLock() { cyw43_arch_lwip_begin(); }
LwIPLock::~LwIPLock() { cyw43_arch_lwip_end(); }
#elif defined(USE_ETHERNET)
LwIPLock::LwIPLock() { ethernet_arch_lwip_begin(); }
LwIPLock::~LwIPLock() { ethernet_arch_lwip_end(); }
LwIPLock::LwIPLock() {
__inLWIP++;
ethernet_arch_lwip_begin();
}
LwIPLock::~LwIPLock() {
ethernet_arch_lwip_end();
__inLWIP--;
if (__needsIRQEN && !__inLWIP) {
__needsIRQEN = false;
ethernet_arch_lwip_gpio_unmask();
}
}
#else
LwIPLock::LwIPLock() {}
LwIPLock::~LwIPLock() {}

View File

@@ -104,11 +104,17 @@ void SX126x::write_register_(uint16_t reg, uint8_t *data, uint8_t size) {
delayMicroseconds(SWITCHING_DELAY_US);
}
void IRAM_ATTR SX126x::gpio_intr(SX126x *arg) { arg->enable_loop_soon_any_context(); }
void SX126x::setup() {
// setup pins
this->busy_pin_->setup();
this->rst_pin_->setup();
this->dio1_pin_->setup();
if (this->dio1_pin_->is_internal()) {
static_cast<InternalGPIOPin *>(this->dio1_pin_)
->attach_interrupt(&SX126x::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE);
}
// start spi
this->spi_setup();
@@ -348,6 +354,9 @@ void SX126x::call_listeners_(const std::vector<uint8_t> &packet, float rssi, flo
}
void SX126x::loop() {
if (this->dio1_pin_->is_internal()) {
this->disable_loop();
}
if (!this->dio1_pin_->digital_read()) {
return;
}

View File

@@ -3,6 +3,7 @@
#include "esphome/components/spi/spi.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "sx126x_reg.h"
#include <utility>
#include <vector>
@@ -100,6 +101,7 @@ class SX126x : public Component,
Trigger<std::vector<uint8_t>, float, float> *get_packet_trigger() { return &this->packet_trigger_; }
protected:
static void IRAM_ATTR gpio_intr(SX126x *arg);
void configure_fsk_ook_();
void configure_lora_();
void set_packet_params_(uint8_t payload_length);

View File

@@ -53,6 +53,8 @@ void SX127x::write_fifo_(const std::vector<uint8_t> &packet) {
this->disable();
}
void IRAM_ATTR SX127x::gpio_intr(SX127x *arg) { arg->enable_loop_soon_any_context(); }
void SX127x::setup() {
// setup reset
this->rst_pin_->setup();
@@ -60,6 +62,7 @@ void SX127x::setup() {
// setup dio0
if (this->dio0_pin_) {
this->dio0_pin_->setup();
this->dio0_pin_->attach_interrupt(&SX127x::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE);
}
// start spi
@@ -313,6 +316,7 @@ void SX127x::call_listeners_(const std::vector<uint8_t> &packet, float rssi, flo
}
void SX127x::loop() {
this->disable_loop();
if (this->dio0_pin_ == nullptr || !this->dio0_pin_->digital_read()) {
return;
}
@@ -386,11 +390,6 @@ void SX127x::set_mode_(uint8_t modulation, uint8_t mode) {
return;
}
}
if (mode == MODE_RX && (modulation == MOD_LORA || this->packet_mode_)) {
this->enable_loop();
} else {
this->disable_loop();
}
}
void SX127x::set_mode_rx() {

View File

@@ -4,6 +4,7 @@
#include "esphome/components/spi/spi.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include <vector>
namespace esphome {
@@ -86,6 +87,7 @@ class SX127x : public Component,
Trigger<std::vector<uint8_t>, float, float> *get_packet_trigger() { return &this->packet_trigger_; }
protected:
static void IRAM_ATTR gpio_intr(SX127x *arg);
void configure_fsk_ook_();
void configure_lora_();
void set_mode_(uint8_t modulation, uint8_t mode);

View File

@@ -14,7 +14,7 @@ dependencies:
espressif/esp32-camera:
version: 2.1.6
espressif/mdns:
version: 1.10.0
version: 1.11.0
espressif/esp_wifi_remote:
version: 1.4.0
rules:

View File

@@ -5,104 +5,15 @@ import os
from pathlib import Path
import re
import subprocess
import time
from typing import Any
import sys
from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE
from esphome.core import CORE, EsphomeError
from esphome.util import run_external_command, run_external_process
from esphome.util import run_external_process
_LOGGER = logging.getLogger(__name__)
def patch_structhash():
# Patch platformio's structhash to not recompile the entire project when files are
# removed/added. This might have unintended consequences, but this improves compile
# times greatly when adding/removing components and a simple clean build solves
# all issues
from platformio.run import cli, helpers
def patched_clean_build_dir(build_dir, *args):
from platformio import fs
from platformio.project.helpers import get_project_dir
platformio_ini = Path(get_project_dir()) / "platformio.ini"
build_dir = Path(build_dir)
# if project's config is modified
if (
build_dir.is_dir()
and platformio_ini.stat().st_mtime > build_dir.stat().st_mtime
):
fs.rmtree(build_dir)
if not build_dir.is_dir():
build_dir.mkdir(parents=True)
helpers.clean_build_dir = patched_clean_build_dir
cli.clean_build_dir = patched_clean_build_dir
def patch_file_downloader():
"""Patch PlatformIO's FileDownloader to retry on PackageException errors.
PlatformIO's FileDownloader uses HTTPSession which lacks built-in retry
for 502/503 errors. We add retries with exponential backoff and close the
session between attempts to force a fresh TCP connection, which may route
to a different CDN edge node.
"""
from platformio.package.download import FileDownloader
from platformio.package.exception import PackageException
if getattr(FileDownloader.__init__, "_esphome_patched", False):
return
original_init = FileDownloader.__init__
def patched_init(self, *args: Any, **kwargs: Any) -> None:
max_retries = 5
for attempt in range(max_retries):
try:
original_init(self, *args, **kwargs)
return
except PackageException as e:
if attempt < max_retries - 1:
# Exponential backoff: 2, 4, 8, 16 seconds
delay = 2 ** (attempt + 1)
_LOGGER.warning(
"Package download failed: %s. "
"Retrying in %d seconds... (attempt %d/%d)",
str(e),
delay,
attempt + 1,
max_retries,
)
# Close the response and session to free resources
# and force a new TCP connection on retry, which may
# route to a different CDN edge node
# pylint: disable=protected-access,broad-except
try:
if (
hasattr(self, "_http_response")
and self._http_response is not None
):
self._http_response.close()
if hasattr(self, "_http_session"):
self._http_session.close()
except Exception:
pass
# pylint: enable=protected-access,broad-except
time.sleep(delay)
else:
# Final attempt - re-raise
raise
patched_init._esphome_patched = True # type: ignore[attr-defined] # pylint: disable=protected-access
FileDownloader.__init__ = patched_init
IGNORE_LIB_WARNINGS = f"(?:{'|'.join(['Hash', 'Update'])})"
FILTER_PLATFORMIO_LINES = [
r"Verbose mode can be enabled via `-v, --verbose` option.*",
@@ -142,20 +53,6 @@ FILTER_PLATFORMIO_LINES = [
]
class PlatformioLogFilter(logging.Filter):
"""Filter to suppress noisy platformio log messages."""
_PATTERN = re.compile(
r"|".join(r"(?:" + pattern + r")" for pattern in FILTER_PLATFORMIO_LINES)
)
def filter(self, record: logging.LogRecord) -> bool:
# Only filter messages from platformio-related loggers
if "platformio" not in record.name.lower():
return True
return self._PATTERN.match(record.getMessage()) is None
def run_platformio_cli(*args, **kwargs) -> str | int:
os.environ["PLATFORMIO_FORCE_COLOR"] = "true"
os.environ["PLATFORMIO_BUILD_DIR"] = str(CORE.relative_pioenvs_path().absolute())
@@ -166,30 +63,12 @@ def run_platformio_cli(*args, **kwargs) -> str | int:
os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning")
# Increase uv retry count to handle transient network errors (default is 3)
os.environ.setdefault("UV_HTTP_RETRIES", "10")
cmd = ["platformio"] + list(args)
cmd = [sys.executable, "-m", "esphome.platformio_runner"] + list(args)
if not CORE.verbose:
kwargs["filter_lines"] = FILTER_PLATFORMIO_LINES
if os.environ.get("ESPHOME_USE_SUBPROCESS") is not None:
return run_external_process(*cmd, **kwargs)
import platformio.__main__
patch_structhash()
patch_file_downloader()
# Add log filter to suppress noisy platformio messages
log_filter = PlatformioLogFilter() if not CORE.verbose else None
if log_filter:
for handler in logging.getLogger().handlers:
handler.addFilter(log_filter)
try:
return run_external_command(platformio.__main__.main, *cmd, **kwargs)
finally:
if log_filter:
for handler in logging.getLogger().handlers:
handler.removeFilter(log_filter)
return run_external_process(*cmd, **kwargs)
def run_platformio_cli_run(config, verbose, *args, **kwargs) -> str | int:

View File

@@ -0,0 +1,114 @@
"""Subprocess entry point that applies ESPHome's PlatformIO patches.
Invoked via ``python -m esphome.platformio_runner`` instead of
``python -m platformio`` so that the patches (incremental rebuild
preservation, download retries) apply inside the subprocess. Running
PlatformIO in a subprocess keeps its ``sys.path`` mutations and other
global state from leaking into the ESPHome process.
"""
from __future__ import annotations
import logging
from pathlib import Path
import sys
import time
from typing import Any
_LOGGER = logging.getLogger(__name__)
def patch_structhash() -> None:
"""Avoid full rebuilds when files are added or removed.
PlatformIO clears the build dir whenever its structure hash changes.
We replace that with an mtime check against ``platformio.ini`` so
incremental builds are preserved unless the project config changed.
"""
from platformio.run import cli, helpers
def patched_clean_build_dir(build_dir, *_args):
from platformio import fs
from platformio.project.helpers import get_project_dir
platformio_ini = Path(get_project_dir()) / "platformio.ini"
build_dir = Path(build_dir)
if (
build_dir.is_dir()
and platformio_ini.stat().st_mtime > build_dir.stat().st_mtime
):
fs.rmtree(build_dir)
if not build_dir.is_dir():
build_dir.mkdir(parents=True)
helpers.clean_build_dir = patched_clean_build_dir
cli.clean_build_dir = patched_clean_build_dir
def patch_file_downloader() -> None:
"""Retry PlatformIO package downloads with exponential backoff.
PlatformIO's ``FileDownloader`` uses an ``HTTPSession`` without built-in
retry for 502/503 errors. We wrap ``__init__`` to retry on
``PackageException`` and close the session between attempts so a new
TCP connection can route to a different CDN edge node.
"""
from platformio.package.download import FileDownloader
from platformio.package.exception import PackageException
if getattr(FileDownloader.__init__, "_esphome_patched", False):
return
original_init = FileDownloader.__init__
def patched_init(self, *args: Any, **kwargs: Any) -> None:
max_retries = 5
for attempt in range(max_retries):
try:
original_init(self, *args, **kwargs)
return
except PackageException as e:
if attempt < max_retries - 1:
delay = 2 ** (attempt + 1)
_LOGGER.warning(
"Package download failed: %s. "
"Retrying in %d seconds... (attempt %d/%d)",
str(e),
delay,
attempt + 1,
max_retries,
)
# pylint: disable=protected-access,broad-except
try:
if (
hasattr(self, "_http_response")
and self._http_response is not None
):
self._http_response.close()
if hasattr(self, "_http_session"):
self._http_session.close()
except Exception:
pass
# pylint: enable=protected-access,broad-except
time.sleep(delay)
else:
raise
patched_init._esphome_patched = True # type: ignore[attr-defined] # pylint: disable=protected-access
FileDownloader.__init__ = patched_init
def main() -> int:
patch_structhash()
patch_file_downloader()
import platformio.__main__
return platformio.__main__.main() or 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -133,10 +133,10 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script
; This are common settings for the ESP32 (all variants) using Arduino.
[common:esp32-arduino]
extends = common:arduino
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38/platform-espressif32.zip
platform_packages =
pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.7/esp32-core-3.3.7.tar.xz
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.3.1/esp-idf-v5.5.3.1.tar.xz
pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.8/esp32-core-3.3.8.tar.xz
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz
framework = arduino, espidf ; Arduino as an ESP-IDF component
lib_deps =
@@ -169,9 +169,9 @@ extra_scripts = post:esphome/components/esp32/post_build.py.script
; This are common settings for the ESP32 (all variants) using IDF.
[common:esp32-idf]
extends = common:idf
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38/platform-espressif32.zip
platform_packages =
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.3.1/esp-idf-v5.5.3.1.tar.xz
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz
framework = espidf
lib_deps =

View File

@@ -20,7 +20,6 @@ classifiers = [
"Topic :: Home Automation",
]
# Python 3.14 is not supported on Windows, see https://github.com/zephyrproject-rtos/windows-curses/issues/76
requires-python = ">=3.11.0,<3.15"
dynamic = ["dependencies", "optional-dependencies", "version"]

View File

@@ -12,14 +12,14 @@ platformio==6.1.19
esptool==5.2.0
click==8.3.2
esphome-dashboard==20260408.1
aioesphomeapi==44.13.1
aioesphomeapi==44.13.3
zeroconf==0.148.0
puremagic==1.30
ruamel.yaml==0.19.1 # dashboard_import
ruamel.yaml.clib==0.2.15 # dashboard_import
esphome-glyphsets==0.2.0
pillow==12.2.0
resvg-py==0.2.6
resvg-py==0.3.1
freetype-py==2.5.1
jinja2==3.1.6
bleak==2.1.1

View File

@@ -2679,6 +2679,13 @@ def build_message_type(
and get_opt(desc, inline_opt, False)
)
# Check if this message wants speed-optimized encode/calculate_size.
# When set, __attribute__((optimize("O2"))) is added to the definitions
# so GCC inlines the small ProtoEncode helpers even under -Os.
speed_opt = getattr(pb, "speed_optimized", None)
is_speed_optimized = speed_opt is not None and get_opt(desc, speed_opt, False)
speed_attr = '__attribute__((optimize("O2"))) ' if is_speed_optimized else ""
# Only generate encode method if this message needs encoding and has fields
if needs_encode and encode and not is_inline_only:
# Add PROTO_ENCODE_DEBUG_ARG after pos in all proto_* calls
@@ -2688,7 +2695,7 @@ def build_message_type(
)
for line in encode
]
o = f"uint8_t *{desc.name}::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {{\n"
o = f"{speed_attr}uint8_t *{desc.name}::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {{\n"
o += " uint8_t *__restrict__ pos = buffer.get_pos();\n"
o += indent("\n".join(encode_debug)) + "\n"
o += " return pos;\n"
@@ -2702,7 +2709,7 @@ def build_message_type(
# Add calculate_size method only if this message needs encoding and has fields
if needs_encode and size_calc and not is_inline_only:
o = f"uint32_t {desc.name}::calculate_size() const {{\n"
o = f"{speed_attr}uint32_t {desc.name}::calculate_size() const {{\n"
o += " uint32_t size = 0;\n"
o += indent("\n".join(size_calc)) + "\n"
o += " return size;\n"

View File

@@ -26,12 +26,11 @@ CORE_BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "core"
STUBS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "stubs"
PLATFORMIO_OPTIONS = {
"build_unflags": [
"-Os", # remove default size-opt
],
"build_flags": [
"-O2", # optimize for speed (CodSpeed recommends RelWithDebInfo)
"-Os", # match firmware optimization level (detects inlining regressions)
"-g", # debug symbols for profiling
"-ffunction-sections", # required for dead-code stripping with -Os
"-fdata-sections", # required for dead-code stripping with -Os
"-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish()
f"-I{STUBS_DIR}", # stub headers for ESP32-only components
],

View File

@@ -1,11 +1,18 @@
"""Tests for the packages component."""
import logging
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass, merge_packages
from esphome.components.packages import (
CONFIG_SCHEMA,
_walk_packages,
do_packages_pass,
is_package_definition,
merge_packages,
)
from esphome.components.substitutions import do_substitution_pass
import esphome.config as config_module
from esphome.config import resolve_extend_remove
@@ -37,7 +44,7 @@ from esphome.const import (
)
from esphome.core import CORE
from esphome.util import OrderedDict
from esphome.yaml_util import add_context
from esphome.yaml_util import IncludeFile, add_context
# Test strings
TEST_DEVICE_NAME = "test_device_name"
@@ -79,6 +86,44 @@ def packages_pass(config):
return config
_INCLUDE_FILE = "INCLUDE_FILE"
@pytest.mark.parametrize(
("value", "expected"),
[
# IncludeFile objects are package definitions
(_INCLUDE_FILE, True),
# Git URL shorthand strings are package definitions
("github://esphome/firmware/base.yaml@main", True),
# Remote package dicts (with url key) are package definitions
({"url": "https://github.com/esphome/firmware", "file": "base.yaml"}, True),
# Plain config dicts are NOT package definitions (they are config fragments)
({"wifi": {"ssid": "test"}}, False),
# None is not a package definition
(None, False),
# Lists are not package definitions
([{"wifi": {"ssid": "test"}}], False),
# Empty dicts are not package definitions
({}, False),
],
ids=[
"include_file",
"git_shorthand",
"remote_package",
"config_fragment",
"none",
"list",
"empty_dict",
],
)
def test_is_package_definition(value: object, expected: bool) -> None:
"""Test that is_package_definition correctly identifies package definitions."""
if value is _INCLUDE_FILE:
value = MagicMock(spec=IncludeFile)
assert is_package_definition(value) is expected
def test_package_unused(basic_esphome, basic_wifi) -> None:
"""
Ensures do_package_pass does not change a config if packages aren't used.
@@ -1061,6 +1106,51 @@ def test_packages_invalid_type_raises() -> None:
do_packages_pass(config)
@patch("esphome.components.packages.resolve_include")
def test_packages_include_file_resolves_to_list(mock_resolve_include) -> None:
"""When packages: is an IncludeFile that resolves to a list, it is processed correctly."""
include_file = MagicMock(spec=IncludeFile)
package_content = {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}}
mock_resolve_include.return_value = ([package_content], None)
config = {CONF_PACKAGES: include_file}
result = do_packages_pass(config)
result = merge_packages(result)
assert result == {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}}
@patch("esphome.components.packages.resolve_include")
def test_packages_include_file_resolves_to_dict(mock_resolve_include) -> None:
"""When packages: is an IncludeFile that resolves to a dict, it is processed correctly."""
include_file = MagicMock(spec=IncludeFile)
package_content = {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}}
mock_resolve_include.return_value = ({"network": package_content}, None)
config = {CONF_PACKAGES: include_file}
result = do_packages_pass(config)
result = merge_packages(result)
assert result == {CONF_WIFI: {CONF_SSID: TEST_PACKAGE_WIFI_SSID}}
@patch("esphome.components.packages.resolve_include")
def test_packages_include_file_resolves_to_invalid_type_raises(
mock_resolve_include,
) -> None:
"""When packages: is an IncludeFile that resolves to an invalid type, cv.Invalid is raised."""
include_file = MagicMock(spec=IncludeFile)
mock_resolve_include.return_value = ("not_a_dict_or_list", None)
config = {CONF_PACKAGES: include_file}
with pytest.raises(
cv.Invalid, match="Packages must be a key to value mapping or list"
) as exc_info:
do_packages_pass(config)
assert exc_info.value.path == [CONF_PACKAGES]
@pytest.mark.parametrize(
"invalid_package",
[
@@ -1107,6 +1197,134 @@ def test_invalid_package_contents_masked_by_deprecation(
do_packages_pass(config)
def test_named_dict_with_include_files_no_false_deprecation_warning(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Package errors in named dicts must not trigger the deprecated fallback."""
good_include = MagicMock(spec=IncludeFile)
bad_include = MagicMock(spec=IncludeFile)
config = {
CONF_PACKAGES: {
"good_pkg": good_include,
"bad_pkg": bad_include,
},
}
call_count = 0
def failing_callback(package_config: dict, context: object) -> dict:
nonlocal call_count
call_count += 1
if call_count == 1:
# First package processes fine
return {CONF_WIFI: {CONF_SSID: "test"}}
# Second package has an error (e.g. jinja syntax error)
raise cv.Invalid("simulated jinja error in bad_pkg")
with (
caplog.at_level(logging.WARNING),
pytest.raises(cv.Invalid, match="simulated jinja error"),
):
_walk_packages(config, failing_callback)
# Must NOT emit the deprecated single-package warning
assert "deprecated" not in caplog.text.lower()
def test_validate_deprecated_false_raises_directly(
caplog: pytest.LogCaptureFixture,
) -> None:
"""With validate_deprecated=False, errors raise directly without fallback.
This is the codepath used for remote packages where _process_remote_package
returns already-resolved dicts that is_package_definition cannot detect.
"""
config = {
CONF_PACKAGES: {
"pkg_a": {CONF_WIFI: {CONF_SSID: "test"}},
"pkg_b": {CONF_WIFI: {CONF_SSID: "test2"}},
},
}
call_count = 0
def failing_callback(package_config: dict, context: object) -> dict:
nonlocal call_count
call_count += 1
if call_count == 1:
return package_config
raise cv.Invalid("nested error")
with (
caplog.at_level(logging.WARNING),
pytest.raises(cv.Invalid, match="nested error"),
):
_walk_packages(config, failing_callback, validate_deprecated=False)
assert "deprecated" not in caplog.text.lower()
def test_error_on_first_declared_package_still_detected() -> None:
"""When the first declared package errors, it's the last processed in reverse.
All other entries are already resolved to dicts, but the failing entry
retains its original IncludeFile value since assignment was skipped.
"""
config = {
CONF_PACKAGES: {
"first_pkg": MagicMock(spec=IncludeFile),
"second_pkg": MagicMock(spec=IncludeFile),
"third_pkg": MagicMock(spec=IncludeFile),
},
}
call_count = 0
def fail_on_last(package_config: dict, context: object) -> dict:
nonlocal call_count
call_count += 1
# Reverse iteration: third_pkg (1), second_pkg (2), first_pkg (3)
if call_count < 3:
return {CONF_WIFI: {CONF_SSID: "test"}}
raise cv.Invalid("error in first_pkg")
with pytest.raises(cv.Invalid, match="error in first_pkg"):
_walk_packages(config, fail_on_last)
def test_deprecated_single_package_fallback_still_works(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The deprecated single-package form still falls back at the top level.
When a dict's values are plain config fragments (not package definitions)
and the callback fails, the deprecated fallback wraps the dict in a list
and retries with a deprecation warning.
"""
config = {
CONF_PACKAGES: {
CONF_WIFI: {CONF_SSID: "test", CONF_PASSWORD: "secret"},
},
}
attempt = 0
def fail_then_succeed(package_config: dict, context: object) -> dict:
nonlocal attempt
attempt += 1
if attempt == 1:
# First attempt: treating as named dict fails
raise cv.Invalid("not a valid package")
# Second attempt: after fallback wraps as list, succeeds
return package_config
with caplog.at_level(logging.WARNING):
_walk_packages(config, fail_then_succeed)
assert "deprecated" in caplog.text.lower()
def test_merge_packages_invalid_nested_type_raises() -> None:
"""Invalid nested packages type during merge raises cv.Invalid."""
config = {

View File

@@ -50,6 +50,13 @@ button:
- platform: template
name: Canbus Actions
on_press:
- canbus.send:
can_id: 0x601
data: [0, 1, 2]
- canbus.send:
can_id: 0x1FFFFFFF
use_extended_id: true
data: [0, 1, 2]
- canbus.send: "abc"
- canbus.send: [0, 1, 2]
- canbus.send: !lambda return {0, 1, 2};

View File

@@ -20,6 +20,7 @@ lvgl:
- id: lvgl_0
default_font: space16
displays: sdl0
rotation: 180
top_layer:
- id: lvgl_1

View File

@@ -84,9 +84,9 @@ def mock_decode_pc() -> Generator[Mock, None, None]:
@pytest.fixture
def mock_run_external_command() -> Generator[Mock, None, None]:
"""Mock run_external_command for platformio_api."""
with patch("esphome.platformio_api.run_external_command") as mock:
def mock_run_external_process() -> Generator[Mock, None, None]:
"""Mock run_external_process for platformio_api."""
with patch("esphome.platformio_api.run_external_process") as mock:
yield mock

View File

@@ -0,0 +1,3 @@
wifi:
password: pkg_password
ssid: main_ssid

View File

@@ -0,0 +1,4 @@
packages: !include 13-packages_list.yaml
wifi:
ssid: main_ssid

View File

@@ -0,0 +1,2 @@
- wifi:
password: pkg_password

View File

@@ -0,0 +1,3 @@
wifi:
password: pkg_password
ssid: main_ssid

View File

@@ -0,0 +1,4 @@
packages: !include 14-packages_dict.yaml
wifi:
ssid: main_ssid

View File

@@ -0,0 +1,3 @@
network:
wifi:
password: pkg_password

View File

@@ -1,7 +1,8 @@
"""Tests for platformio_api.py path functions."""
# pylint: disable=protected-access
import json
import logging
import os
from pathlib import Path
import shutil
@@ -10,7 +11,7 @@ from unittest.mock import MagicMock, Mock, call, patch
import pytest
from esphome import platformio_api
from esphome import platformio_api, platformio_runner
from esphome.core import CORE, EsphomeError
@@ -281,13 +282,13 @@ def test_run_idedata_raises_on_invalid_json(
def test_run_platformio_cli_sets_environment_variables(
setup_core: Path, mock_run_external_command: Mock
setup_core: Path, mock_run_external_process: Mock
) -> None:
"""Test run_platformio_cli sets correct environment variables."""
CORE.build_path = str(setup_core / "build" / "test")
with patch.dict(os.environ, {}, clear=False):
mock_run_external_command.return_value = 0
mock_run_external_process.return_value = 0
platformio_api.run_platformio_cli("test", "arg")
# Check environment variables were set
@@ -300,10 +301,12 @@ def test_run_platformio_cli_sets_environment_variables(
assert "PLATFORMIO_LIBDEPS_DIR" in os.environ
assert "PYTHONWARNINGS" in os.environ
# Check command was called correctly
mock_run_external_command.assert_called_once()
args = mock_run_external_command.call_args[0]
assert "platformio" in args
# Check command was called correctly — runs PlatformIO as a subprocess
# via the esphome.platformio_runner entry point.
mock_run_external_process.assert_called_once()
args = mock_run_external_process.call_args[0]
assert "-m" in args
assert "esphome.platformio_runner" in args
assert "test" in args
assert "arg" in args
@@ -444,7 +447,7 @@ def test_patch_structhash(setup_core: Path) -> None:
},
):
# Call patch_structhash
platformio_api.patch_structhash()
platformio_runner.patch_structhash()
# Verify both modules had clean_build_dir patched
# Check that clean_build_dir was set on both modules
@@ -496,7 +499,7 @@ def test_patched_clean_build_dir_removes_outdated(setup_core: Path) -> None:
},
):
# Call patch_structhash to install the patched function
platformio_api.patch_structhash()
platformio_runner.patch_structhash()
# Call the patched function
mock_helpers.clean_build_dir(str(build_dir), [])
@@ -546,7 +549,7 @@ def test_patched_clean_build_dir_keeps_updated(setup_core: Path) -> None:
},
):
# Call patch_structhash to install the patched function
platformio_api.patch_structhash()
platformio_runner.patch_structhash()
# Call the patched function
mock_helpers.clean_build_dir(str(build_dir), [])
@@ -594,7 +597,7 @@ def test_patched_clean_build_dir_creates_missing(setup_core: Path) -> None:
},
):
# Call patch_structhash to install the patched function
platformio_api.patch_structhash()
platformio_runner.patch_structhash()
# Call the patched function
mock_helpers.clean_build_dir(str(build_dir), [])
@@ -719,7 +722,7 @@ def test_patch_file_downloader_succeeds_first_try() -> None:
),
},
):
platformio_api.patch_file_downloader()
platformio_runner.patch_file_downloader()
from platformio.package.download import FileDownloader
@@ -758,7 +761,7 @@ def test_patch_file_downloader_retries_on_failure() -> None:
),
patch("time.sleep") as mock_sleep,
):
platformio_api.patch_file_downloader()
platformio_runner.patch_file_downloader()
from platformio.package.download import FileDownloader
@@ -799,7 +802,7 @@ def test_patch_file_downloader_raises_after_max_retries() -> None:
),
patch("time.sleep") as mock_sleep,
):
platformio_api.patch_file_downloader()
platformio_runner.patch_file_downloader()
from platformio.package.download import FileDownloader
@@ -847,7 +850,7 @@ def test_patch_file_downloader_closes_session_and_response_between_retries() ->
),
patch("time.sleep"),
):
platformio_api.patch_file_downloader()
platformio_runner.patch_file_downloader()
from platformio.package.download import FileDownloader
@@ -882,9 +885,9 @@ def test_patch_file_downloader_idempotent() -> None:
},
):
# Patch multiple times
platformio_api.patch_file_downloader()
platformio_api.patch_file_downloader()
platformio_api.patch_file_downloader()
platformio_runner.patch_file_downloader()
platformio_runner.patch_file_downloader()
platformio_runner.patch_file_downloader()
from platformio.package.download import FileDownloader
@@ -895,19 +898,18 @@ def test_patch_file_downloader_idempotent() -> None:
assert call_count == 1
def test_platformio_log_filter_allows_non_platformio_messages() -> None:
"""Test that non-platformio logger messages are allowed through."""
log_filter = platformio_api.PlatformioLogFilter()
record = logging.LogRecord(
name="esphome.core",
level=logging.INFO,
pathname="",
lineno=0,
msg="Some esphome message",
args=(),
exc_info=None,
def _filter_through_redirect(line: str) -> str:
"""Write a line through RedirectText with FILTER_PLATFORMIO_LINES and return what passes."""
import io
from esphome.util import RedirectText
captured = io.StringIO()
redirect = RedirectText(
captured, filter_lines=platformio_api.FILTER_PLATFORMIO_LINES
)
assert log_filter.filter(record) is True
redirect.write(line + "\n")
return captured.getvalue()
@pytest.mark.parametrize(
@@ -930,19 +932,9 @@ def test_platformio_log_filter_allows_non_platformio_messages() -> None:
"Memory Usage -> https://bit.ly/pio-memory-usage",
],
)
def test_platformio_log_filter_blocks_noisy_messages(msg: str) -> None:
"""Test that noisy platformio messages are filtered out."""
log_filter = platformio_api.PlatformioLogFilter()
record = logging.LogRecord(
name="platformio.builder",
level=logging.INFO,
pathname="",
lineno=0,
msg=msg,
args=(),
exc_info=None,
)
assert log_filter.filter(record) is False
def test_filter_platformio_lines_blocks_noisy_messages(msg: str) -> None:
"""Test that noisy platformio output lines are filtered out by RedirectText."""
assert _filter_through_redirect(msg) == ""
@pytest.mark.parametrize(
@@ -954,39 +946,6 @@ def test_platformio_log_filter_blocks_noisy_messages(msg: str) -> None:
"warning: unused variable",
],
)
def test_platformio_log_filter_allows_other_platformio_messages(msg: str) -> None:
"""Test that non-noisy platformio messages are allowed through."""
log_filter = platformio_api.PlatformioLogFilter()
record = logging.LogRecord(
name="platformio.builder",
level=logging.INFO,
pathname="",
lineno=0,
msg=msg,
args=(),
exc_info=None,
)
assert log_filter.filter(record) is True
@pytest.mark.parametrize(
"logger_name",
[
"PLATFORMIO.builder",
"PlatformIO.core",
"platformio.run",
],
)
def test_platformio_log_filter_case_insensitive_logger_name(logger_name: str) -> None:
"""Test that platformio logger name matching is case insensitive."""
log_filter = platformio_api.PlatformioLogFilter()
record = logging.LogRecord(
name=logger_name,
level=logging.INFO,
pathname="",
lineno=0,
msg="Found 5 compatible libraries",
args=(),
exc_info=None,
)
assert log_filter.filter(record) is False
def test_filter_platformio_lines_allows_other_messages(msg: str) -> None:
"""Test that non-noisy platformio output lines pass through RedirectText."""
assert _filter_through_redirect(msg) == msg + "\n"