Compare commits

..

23 Commits

Author SHA1 Message Date
J. Nick Koston
cb2e8320f0 [api] Pass pos by value through encode_varint_raw_loop 2026-04-09 22:13:14 -10:00
J. Nick Koston
49ef5f9ad6 [api] Force inline encode_varint_raw_loop so pos stays in a register 2026-04-09 22:07:15 -10:00
J. Nick Koston
707f3f4749 [api] Unroll varint encode loop for compile-time bounded types 2026-04-09 21:59:35 -10:00
J. Nick Koston
ec420d5792 [api] Add (inline_encode) proto option for sub-message inlining (#15599) 2026-04-10 15:33:56 +12:00
J. Nick Koston
17209df7b5 [mcp23016] Add interrupt pin support (#15616) 2026-04-10 15:29:52 +12:00
J. Nick Koston
9cf9b02ba2 [pca6416a] Add interrupt pin support (#15614) 2026-04-10 15:29:26 +12:00
J. Nick Koston
c90fa2378a [tca9555] Add interrupt pin support (#15613) 2026-04-10 15:29:00 +12:00
Jesse Hills
c04dfa922e [hbridge] Move light pin switching to loop (#15615) 2026-04-10 14:02:49 +12:00
Jesse Hills
668007707d [CI] Add org fork detection warning to auto-label PR workflow (#15588) 2026-04-10 12:13:22 +12:00
dependabot[bot]
ab71f5276f Bump ruff from 0.15.9 to 0.15.10 (#15609)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-04-09 19:36:25 +00:00
Jonathan Swoboda
d062f62656 [sx127x][cc1101] Disable loop when packet mode is inactive (#15606) 2026-04-09 15:00:52 -04:00
J. Nick Koston
03db32d045 [core] Add CodSpeed benchmarks for hot helper functions (#15593) 2026-04-09 07:48:32 -10:00
J. Nick Koston
8f6d489a9a [ci] Use --base-only for memory impact builds (#15598) 2026-04-09 11:48:33 -04:00
J. Nick Koston
dd07fba943 [socket] Document ready() contract: callers must drain or track (#15590) 2026-04-09 11:48:18 -04:00
J. Nick Koston
6f5d642a31 [gdk101] Increase reset retries for slow-booting sensor MCU (#15584) 2026-04-09 11:48:10 -04:00
dependabot[bot]
2721f08bcc Bump aioesphomeapi from 44.12.0 to 44.13.1 (#15600)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-09 10:03:58 +00:00
J. Nick Koston
eafc5df3f2 [safe_mode] Combine related OTA rollback log messages (#15592) 2026-04-09 05:30:39 +00:00
J. Nick Koston
46d0c29be5 [safe_mode] Use loop component start time instead of millis() (#15591) 2026-04-09 05:20:32 +00:00
J. Nick Koston
abdbbf4dd2 [api] Fix ListEntitiesRequest not read due to LWIP rcvevent tracking (#15589) 2026-04-09 02:14:01 +00:00
Jesse Hills
4dc0599a7d Merge branch 'beta' into dev 2026-04-09 13:41:27 +12:00
Jesse Hills
52c35ec09c Bump version to 2026.5.0-dev 2026-04-09 11:28:48 +12:00
J. Nick Koston
76490e45bc [ci] Fix status-check-labels workflow flooding CI queue (#15585) 2026-04-08 13:08:29 -10:00
Angel Nunez Mencias
0a8130858c [ade7953_spi] Fix SPI mode on esp-idf (#14824)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-04-08 22:57:53 +00:00
81 changed files with 818 additions and 1264 deletions

View File

@@ -1 +1 @@
10c432ae818f9ed7fd4a0176a04467b1f2634363f5ec985045a6d72747f60b90
f31f13994768b5b07e29624406c9b053bf4bb26e1623ac2bc1e9d4a9477502d6

View File

@@ -4,6 +4,7 @@ module.exports = {
CODEOWNERS_MARKER: '<!-- codeowners-request -->',
TOO_BIG_MARKER: '<!-- too-big-request -->',
DEPRECATED_COMPONENT_MARKER: '<!-- deprecated-component-request -->',
ORG_FORK_MARKER: '<!-- maintainer-access-warning -->',
MANAGED_LABELS: [
'new-component',

View File

@@ -281,6 +281,24 @@ async function detectDeprecatedComponents(github, context, changedFiles) {
return { labels, deprecatedInfo };
}
// Strategy: Detect when maintainers cannot modify the PR branch
function detectMaintainerAccess(context) {
const pr = context.payload.pull_request;
// Only relevant for cross-repo PRs (forks)
if (!pr.head.repo || pr.head.repo.full_name === pr.base.repo.full_name) {
return null;
}
if (pr.maintainer_can_modify) {
return null;
}
const isOrgFork = pr.head.repo.owner.type === 'Organization';
console.log(`Maintainer cannot modify PR branch (${isOrgFork ? 'org fork: ' + pr.head.repo.owner.login : 'user disabled'})`);
return { isOrgFork, orgName: pr.head.repo.owner.login };
}
// Strategy: Requirements detection
async function detectRequirements(allLabels, prFiles, context) {
const labels = new Set();
@@ -329,5 +347,6 @@ module.exports = {
detectTests,
detectPRTemplateCheckboxes,
detectDeprecatedComponents,
detectMaintainerAccess,
detectRequirements
};

View File

@@ -12,9 +12,10 @@ const {
detectTests,
detectPRTemplateCheckboxes,
detectDeprecatedComponents,
detectMaintainerAccess,
detectRequirements
} = require('./detectors');
const { handleReviews } = require('./reviews');
const { handleReviews, handleMaintainerAccessComment } = require('./reviews');
const { applyLabels, removeOldLabels } = require('./labels');
// Fetch API data
@@ -114,7 +115,8 @@ module.exports = async ({ github, context }) => {
codeOwnerLabels,
testLabels,
checkboxLabels,
deprecatedResult
deprecatedResult,
maintainerAccess
] = await Promise.all([
detectMergeBranch(context),
detectComponentPlatforms(changedFiles, apiData),
@@ -127,7 +129,8 @@ module.exports = async ({ github, context }) => {
detectCodeOwner(github, context, changedFiles),
detectTests(changedFiles),
detectPRTemplateCheckboxes(context),
detectDeprecatedComponents(github, context, changedFiles)
detectDeprecatedComponents(github, context, changedFiles),
detectMaintainerAccess(context)
]);
// Extract deprecated component info
@@ -177,8 +180,11 @@ module.exports = async ({ github, context }) => {
console.log('Computed labels:', finalLabels.join(', '));
// Handle reviews
await handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD);
// Handle reviews and org fork comment
await Promise.all([
handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD),
handleMaintainerAccessComment(github, context, maintainerAccess)
]);
// Apply labels
await applyLabels(github, context, finalLabels);

View File

@@ -2,7 +2,8 @@ const {
BOT_COMMENT_MARKER,
CODEOWNERS_MARKER,
TOO_BIG_MARKER,
DEPRECATED_COMPONENT_MARKER
DEPRECATED_COMPONENT_MARKER,
ORG_FORK_MARKER
} = require('./constants');
// Generate review messages
@@ -136,6 +137,63 @@ async function handleReviews(github, context, finalLabels, originalLabelCount, d
}
}
// Handle maintainer access warning comment
async function handleMaintainerAccessComment(github, context, maintainerAccess) {
if (!maintainerAccess) {
return;
}
const { owner, repo } = context.repo;
const pr_number = context.issue.number;
const prAuthor = context.payload.pull_request.user.login;
// Check if we already posted the warning (iterate pages to exit early)
let existingComment;
for await (const { data: comments } of github.paginate.iterator(
github.rest.issues.listComments,
{ owner, repo, issue_number: pr_number }
)) {
existingComment = comments.find(comment =>
comment.user.type === 'Bot' &&
comment.body && comment.body.includes(ORG_FORK_MARKER)
);
if (existingComment) {
break;
}
}
if (existingComment) {
console.log('Maintainer access warning comment already exists, skipping');
return;
}
let body;
if (maintainerAccess.isOrgFork) {
body = `${ORG_FORK_MARKER}\n### ⚠️ Organization Fork Detected\n\n` +
`Hey there @${prAuthor},\n` +
`It looks like this PR was submitted from a fork owned by the **${maintainerAccess.orgName}** organization. ` +
`GitHub does not allow maintainers to push changes to pull request branches when the fork is owned by an organization. ` +
`This means we won't be able to make small adjustments or fixups to your PR directly.\n\n` +
`To allow maintainer collaboration, please re-submit this PR from a personal fork instead.\n\n` +
`See: [Setting up the local repository](https://developers.esphome.io/contributing/development-environment/?h=org#set-up-the-local-repository) for more details.`;
} else {
body = `${ORG_FORK_MARKER}\n### ⚠️ Maintainer Access Disabled\n\n` +
`Hey there @${prAuthor},\n` +
`It looks like this PR does not have the "Allow edits from maintainers" option enabled. ` +
`This means we won't be able to make small adjustments or fixups to your PR directly.\n\n` +
`Please enable this option in the PR sidebar to allow maintainer collaboration.`;
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: pr_number,
body
});
console.log('Created maintainer access warning comment');
}
module.exports = {
handleReviews
handleReviews,
handleMaintainerAccessComment
};

View File

@@ -868,7 +868,8 @@ jobs:
python script/test_build_components.py \
-e compile \
-c "$component_list" \
-t "$platform" 2>&1 | \
-t "$platform" \
--base-only 2>&1 | \
tee /dev/stderr | \
python script/ci_memory_impact_extract.py \
--output-env \
@@ -954,7 +955,8 @@ jobs:
python script/test_build_components.py \
-e compile \
-c "$component_list" \
-t "$platform" 2>&1 | \
-t "$platform" \
--base-only 2>&1 | \
tee /dev/stderr | \
python script/ci_memory_impact_extract.py \
--output-env \

View File

@@ -2,30 +2,29 @@ name: Status check labels
on:
pull_request:
types: [labeled, unlabeled]
types: [opened, reopened, labeled, unlabeled, synchronize]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
check:
name: Check ${{ matrix.label }}
name: Check blocking labels
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
label:
- needs-docs
- merge-after-release
- chained-pr
steps:
- name: Check for ${{ matrix.label }} label
- name: Check for blocking labels
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const blockingLabels = ['needs-docs', 'merge-after-release', 'chained-pr'];
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number
});
const hasLabel = labels.find(label => label.name === '${{ matrix.label }}');
if (hasLabel) {
core.setFailed('Pull request cannot be merged, it is labeled as ${{ matrix.label }}');
const labelNames = labels.map(l => l.name);
const found = blockingLabels.filter(bl => labelNames.includes(bl));
if (found.length > 0) {
core.setFailed(`Pull request cannot be merged, it has blocking label(s): ${found.join(', ')}`);
}

View File

@@ -11,7 +11,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.9
rev: v0.15.10
hooks:
# Run the linter.
- id: ruff

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 = 2026.4.0
PROJECT_NUMBER = 2026.5.0-dev
# 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

@@ -750,15 +750,8 @@ def upload_using_esptool(
platformio_api.FlashImage(
path=idedata.firmware_bin_path, offset=firmware_offset
),
*idedata.extra_flash_images,
]
for image in idedata.extra_flash_images:
if not image.path.is_file():
_LOGGER.warning(
"Skipping missing flash image declared by platform: %s",
image.path,
)
continue
flash_images.append(image)
mcu = "esp8266"
if CORE.is_esp32:

View File

@@ -2,11 +2,7 @@ import logging
import esphome.codegen as cg
from esphome.components import sensor, voltage_sampler
from esphome.components.esp32 import (
get_esp32_variant,
include_builtin_idf_component,
require_adc_oneshot_iram,
)
from esphome.components.esp32 import get_esp32_variant, include_builtin_idf_component
from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC
from esphome.components.zephyr import (
zephyr_add_overlay,
@@ -28,7 +24,6 @@ from esphome.const import (
PlatformFramework,
)
from esphome.core import CORE
from esphome.types import ConfigType
from . import (
ATTENUATION_MODES,
@@ -70,13 +65,6 @@ def validate_config(config):
return config
def _require_adc_iram(config: ConfigType) -> ConfigType:
"""Register ADC oneshot IRAM requirement during config validation."""
if CORE.is_esp32:
require_adc_oneshot_iram()
return config
ADCSensor = adc_ns.class_(
"ADCSensor", sensor.Sensor, cg.PollingComponent, voltage_sampler.VoltageSampler
)
@@ -107,7 +95,6 @@ CONFIG_SCHEMA = cv.All(
)
.extend(cv.polling_component_schema("60s")),
validate_config,
_require_adc_iram,
)
CONF_ADC_CHANNEL_ID = "adc_channel_id"

View File

@@ -8,6 +8,9 @@ namespace ade7953_base {
static const char *const TAG = "ade7953";
constexpr uint16_t CONFIG_DEFAULT = 0x8004u;
constexpr uint16_t CONFIG_LOCK_BIT = 0x8000u;
static const float ADE_POWER_FACTOR = 154.0f;
static const float ADE_WATTSEC_POWER_FACTOR = ADE_POWER_FACTOR * ADE_POWER_FACTOR / 3600;
@@ -18,7 +21,12 @@ void ADE7953::setup() {
// The chip might take up to 100ms to initialise
this->set_timeout(100, [this]() {
// this->ade_write_8(0x0010, 0x04);
// Lock communication interface (SPI or I2C)
uint16_t config_v = CONFIG_DEFAULT;
this->ade_read_16(CONFIG_16, &config_v);
config_v &= static_cast<uint16_t>(~CONFIG_LOCK_BIT); // Clear the lock bit
this->ade_write_16(CONFIG_16, config_v);
// Configure optimum settings according to datasheet
this->ade_write_8(0x00FE, 0xAD);
this->ade_write_16(0x0120, 0x0030);
// Set gains

View File

@@ -9,31 +9,35 @@
namespace esphome {
namespace ade7953_base {
static const uint8_t PGA_V_8 =
static constexpr uint8_t PGA_V_8 =
0x007; // PGA_V, (R/W) Default: 0x00, Unsigned, Voltage channel gain configuration (Bits[2:0])
static const uint8_t PGA_IA_8 =
static constexpr uint8_t PGA_IA_8 =
0x008; // PGA_IA, (R/W) Default: 0x00, Unsigned, Current Channel A gain configuration (Bits[2:0])
static const uint8_t PGA_IB_8 =
static constexpr uint8_t PGA_IB_8 =
0x009; // PGA_IB, (R/W) Default: 0x00, Unsigned, Current Channel B gain configuration (Bits[2:0])
static const uint32_t AIGAIN_32 =
static constexpr uint16_t CONFIG_16 = 0x102; // CONFIG, (R/W) Default: 0x8004, Unsigned, Configuration register
static constexpr uint16_t AIGAIN_32 =
0x380; // AIGAIN, (R/W) Default: 0x400000, Unsigned,Current channel gain (Current Channel A)(32 bit)
static const uint32_t AVGAIN_32 = 0x381; // AVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit)
static const uint32_t AWGAIN_32 =
static constexpr uint16_t AVGAIN_32 =
0x381; // AVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit)
static constexpr uint16_t AWGAIN_32 =
0x382; // AWGAIN, (R/W) Default: 0x400000, Unsigned,Active power gain (Current Channel A)(32 bit)
static const uint32_t AVARGAIN_32 =
static constexpr uint16_t AVARGAIN_32 =
0x383; // AVARGAIN, (R/W) Default: 0x400000, Unsigned, Reactive power gain (Current Channel A)(32 bit)
static const uint32_t AVAGAIN_32 =
static constexpr uint16_t AVAGAIN_32 =
0x384; // AVAGAIN, (R/W) Default: 0x400000, Unsigned,Apparent power gain (Current Channel A)(32 bit)
static const uint32_t BIGAIN_32 =
static constexpr uint16_t BIGAIN_32 =
0x38C; // BIGAIN, (R/W) Default: 0x400000, Unsigned,Current channel gain (Current Channel B)(32 bit)
static const uint32_t BVGAIN_32 = 0x38D; // BVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit)
static const uint32_t BWGAIN_32 =
static constexpr uint16_t BVGAIN_32 =
0x38D; // BVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit)
static constexpr uint16_t BWGAIN_32 =
0x38E; // BWGAIN, (R/W) Default: 0x400000, Unsigned,Active power gain (Current Channel B)(32 bit)
static const uint32_t BVARGAIN_32 =
static constexpr uint16_t BVARGAIN_32 =
0x38F; // BVARGAIN, (R/W) Default: 0x400000, Unsigned, Reactive power gain (Current Channel B)(32 bit)
static const uint32_t BVAGAIN_32 =
static constexpr uint16_t BVAGAIN_32 =
0x390; // BVAGAIN, (R/W) Default: 0x400000, Unsigned,Apparent power gain (Current Channel B)(32 bit)
class ADE7953 : public PollingComponent, public sensor::Sensor {

View File

@@ -7,6 +7,9 @@ namespace ade7953_spi {
static const char *const TAG = "ade7953";
// Datasheet requires at least 1.2µs after clearing CONFIG LOCK_BIT before raising CS
constexpr uint8_t CONFIG_LOCK_SETTLE_US = 2;
void AdE7953Spi::setup() {
this->spi_setup();
ade7953_base::ADE7953::setup();
@@ -32,6 +35,9 @@ bool AdE7953Spi::ade_write_16(uint16_t reg, uint16_t value) {
this->write_byte16(reg);
this->transfer_byte(0);
this->write_byte16(value);
if (reg == ade7953_base::CONFIG_16) {
delayMicroseconds(CONFIG_LOCK_SETTLE_US);
}
this->disable();
return false;
}

View File

@@ -12,7 +12,7 @@ namespace esphome {
namespace ade7953_spi {
class AdE7953Spi : public ade7953_base::ADE7953,
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_HIGH, spi::CLOCK_PHASE_LEADING,
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_HIGH, spi::CLOCK_PHASE_TRAILING,
spi::DATA_RATE_1MHZ> {
public:
void setup() override;

View File

@@ -671,7 +671,6 @@ 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;
@@ -778,10 +777,9 @@ message SubscribeLogsResponse {
option (source) = SOURCE_SERVER;
option (log) = false;
option (no_delay) = false;
option (speed_optimized) = true;
LogLevel level = 1 [(force) = true];
bytes message = 3 [(force) = true];
LogLevel level = 1;
bytes message = 3;
}
// ==================== NOISE ENCRYPTION ====================
@@ -1640,7 +1638,6 @@ 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

@@ -195,7 +195,10 @@ class APIFrameHelper {
}
// Get the frame footer size required by this protocol
uint8_t frame_footer_size() const { return frame_footer_size_; }
// Check if socket has data ready to read
// Check if socket has buffered data ready to read.
// Contract: callers must read until it would block (EAGAIN/EWOULDBLOCK)
// or track that they stopped early and retry without this check.
// See Socket::ready() for details.
bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); }
// Release excess memory from internal buffers after initial sync
void release_buffers() {

View File

@@ -23,7 +23,6 @@ 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,9 +745,7 @@ uint32_t ListEntitiesSensorResponse::calculate_size() const {
#endif
return size;
}
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
uint8_t *
SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
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);
@@ -757,9 +755,7 @@ SensorStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) c
#endif
return pos;
}
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
uint32_t
SensorStateResponse::calculate_size() const {
uint32_t SensorStateResponse::calculate_size() const {
uint32_t size = 0;
size += 5;
size += ProtoSize::calc_float(1, this->state);
@@ -916,22 +912,16 @@ bool SubscribeLogsRequest::decode_varint(uint32_t field_id, proto_varint_value_t
}
return true;
}
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
uint8_t *
SubscribeLogsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *SubscribeLogsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->level), true);
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 26);
ProtoEncode::encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_len_);
ProtoEncode::encode_raw(pos PROTO_ENCODE_DEBUG_ARG, this->message_ptr_, this->message_len_);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->level));
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->message_ptr_, this->message_len_);
return pos;
}
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
uint32_t
SubscribeLogsResponse::calculate_size() const {
uint32_t SubscribeLogsResponse::calculate_size() const {
uint32_t size = 0;
size += 2;
size += ProtoSize::calc_length_force(1, this->message_len_);
size += this->level ? 2 : 0;
size += ProtoSize::calc_length(1, this->message_len_);
return size;
}
#ifdef USE_API_NOISE
@@ -2338,9 +2328,7 @@ bool SubscribeBluetoothLEAdvertisementsRequest::decode_varint(uint32_t field_id,
}
return true;
}
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
uint8_t *
BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
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];
@@ -2362,9 +2350,7 @@ BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCO
}
return pos;
}
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
uint32_t
BluetoothLERawAdvertisementsResponse::calculate_size() const {
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

@@ -298,15 +298,33 @@ constexpr uint32_t VARINT_MAX_2_BYTE = 1 << 14; // 16384
class ProtoEncode {
public:
/// Write a multi-byte varint directly through a pos pointer.
/// Unrolled based on the compile-time max varint length for T
/// (5 bytes for uint32_t, 10 bytes for uint64_t). The explicit unroll gives
/// the compiler straight-line code with early exits instead of a
/// data-dependent back-edge branch, which measurably speeds up BLE raw
/// advertisement MAC encoding (always 7-byte varints) among other hot paths.
///
/// Takes/returns pos by value so this function can remain out-of-line without
/// forcing the caller to spill pos to memory on every byte write. Callers
/// (which are ALWAYS_INLINE) should assign the returned pos back to their
/// local. Code size win vs. inlining the whole unroll at every call site.
template<typename T>
static inline void encode_varint_raw_loop(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM, T value) {
do {
static uint8_t *encode_varint_raw_loop(uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM, T value) {
constexpr int MAX_VARINT_BYTES = (sizeof(T) * 8 + 6) / 7; // 5 for u32, 10 for u64
#pragma GCC unroll 10
for (int i = 0; i < MAX_VARINT_BYTES - 1; i++) {
PROTO_ENCODE_CHECK_BOUNDS(pos, 1);
*pos++ = static_cast<uint8_t>(value | 0x80);
*pos++ = static_cast<uint8_t>(value) | 0x80;
value >>= 7;
} while (value > 0x7F);
if (value <= 0x7F) {
PROTO_ENCODE_CHECK_BOUNDS(pos, 1);
*pos++ = static_cast<uint8_t>(value);
return pos;
}
}
PROTO_ENCODE_CHECK_BOUNDS(pos, 1);
*pos++ = static_cast<uint8_t>(value);
return pos;
}
static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM,
uint32_t value) {
@@ -315,7 +333,7 @@ class ProtoEncode {
*pos++ = static_cast<uint8_t>(value);
return;
}
encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value);
pos = encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value);
}
/// Encode a varint that is expected to be 1-2 bytes (e.g. zigzag RSSI, small lengths).
static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw_short(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM,
@@ -331,7 +349,7 @@ class ProtoEncode {
*pos++ = static_cast<uint8_t>(value >> 7);
return;
}
encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value);
pos = encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value);
}
static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw_64(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM,
uint64_t value) {
@@ -340,7 +358,7 @@ class ProtoEncode {
*pos++ = static_cast<uint8_t>(value);
return;
}
encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value);
pos = encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value);
}
static inline void ESPHOME_ALWAYS_INLINE encode_field_raw(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM,
uint32_t field_id, uint32_t type) {
@@ -402,7 +420,7 @@ class ProtoEncode {
PROTO_ENCODE_CHECK_BOUNDS(pos, 1 + len);
*pos++ = static_cast<uint8_t>(len);
} else {
encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, len);
pos = encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, len);
PROTO_ENCODE_CHECK_BOUNDS(pos, len);
}
std::memcpy(pos, string, len);

View File

@@ -111,14 +111,14 @@ class ATM90E32Component : public PollingComponent,
#endif
float get_reference_voltage(uint8_t phase) {
#ifdef USE_NUMBER
return (phase < 3 && ref_voltages_[phase]) ? ref_voltages_[phase]->state : 120.0; // Default voltage
return (phase >= 0 && phase < 3 && ref_voltages_[phase]) ? ref_voltages_[phase]->state : 120.0; // Default voltage
#else
return 120.0; // Default voltage
#endif
}
float get_reference_current(uint8_t phase) {
#ifdef USE_NUMBER
return (phase < 3 && ref_currents_[phase]) ? ref_currents_[phase]->state : 5.0f; // Default current
return (phase >= 0 && phase < 3 && ref_currents_[phase]) ? ref_currents_[phase]->state : 5.0f; // Default current
#else
return 5.0f; // Default current
#endif

View File

@@ -162,6 +162,7 @@ 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,6 @@ 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();
this->cs_->digital_write(true);
@@ -150,12 +148,11 @@ void CC1101Component::setup() {
// 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);
}
});
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();
}
}
@@ -167,7 +164,6 @@ 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;
@@ -248,7 +244,6 @@ 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
@@ -678,12 +673,10 @@ void CC1101Component::set_packet_mode(bool value) {
this->state_.GDO0_CFG = 0x0D;
}
if (this->initialized_) {
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();
}
if (value) {
this->enable_loop();
} else {
this->disable_loop();
}
this->write_(Register::PKTCTRL0);
this->write_(Register::PKTCTRL1);

View File

@@ -93,7 +93,6 @@ 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

@@ -671,12 +671,11 @@ 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, 8),
"latest": cv.Version(3, 3, 8),
"dev": cv.Version(3, 3, 8),
"recommended": cv.Version(3, 3, 7),
"latest": cv.Version(3, 3, 7),
"dev": cv.Version(3, 3, 7),
}
ARDUINO_PLATFORM_VERSION_LOOKUP = {
cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"),
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),
@@ -696,7 +695,6 @@ 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),
@@ -716,15 +714,17 @@ 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, 4),
"latest": cv.Version(5, 5, 4),
"recommended": cv.Version(5, 5, 3, "1"),
"latest": cv.Version(5, 5, 3, "1"),
"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): cv.Version(55, 3, 38, "1"),
cv.Version(
5, 5, 4
): "https://github.com/pioarduino/platform-espressif32.git#develop",
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, 38, "1"),
"latest": cv.Version(55, 3, 38, "1"),
"recommended": cv.Version(55, 3, 37),
"latest": cv.Version(55, 3, 37),
"dev": "https://github.com/pioarduino/platform-espressif32.git#develop",
}
@@ -1058,7 +1058,6 @@ CONF_DISABLE_MBEDTLS_PEER_CERT = "disable_mbedtls_peer_cert"
CONF_DISABLE_MBEDTLS_PKCS7 = "disable_mbedtls_pkcs7"
CONF_DISABLE_REGI2C_IN_IRAM = "disable_regi2c_in_iram"
CONF_DISABLE_FATFS = "disable_fatfs"
CONF_ADC_ONESHOT_IN_IRAM = "adc_oneshot_in_iram"
# VFS requirement tracking
# Components that need VFS features can call require_vfs_*() functions
@@ -1072,7 +1071,6 @@ KEY_MBEDTLS_PEER_CERT_REQUIRED = "mbedtls_peer_cert_required"
KEY_MBEDTLS_PKCS7_REQUIRED = "mbedtls_pkcs7_required"
KEY_FATFS_REQUIRED = "fatfs_required"
KEY_MBEDTLS_SHA512_REQUIRED = "mbedtls_sha512_required"
KEY_ADC_ONESHOT_IRAM_REQUIRED = "adc_oneshot_iram_required"
def require_vfs_select() -> None:
@@ -1170,17 +1168,6 @@ def require_fatfs() -> None:
CORE.data[KEY_ESP32][KEY_FATFS_REQUIRED] = True
def require_adc_oneshot_iram() -> None:
"""Mark that ADC oneshot IRAM safety is required by a component.
Call this from components that use the ADC oneshot driver. When flash cache is
disabled (e.g., during NVS writes by WiFi, BLE, Zigbee, or power management),
the ADC oneshot read function must be in IRAM to avoid crashes.
This sets CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM.
"""
CORE.data[KEY_ESP32][KEY_ADC_ONESHOT_IRAM_REQUIRED] = True
def _parse_idf_component(value: str) -> ConfigType:
"""Parse IDF component shorthand syntax like 'owner/component^version'"""
# Match operator followed by version-like string (digit or *)
@@ -1281,7 +1268,6 @@ FRAMEWORK_SCHEMA = cv.Schema(
cv.Optional(CONF_DISABLE_MBEDTLS_PEER_CERT, default=True): cv.boolean,
cv.Optional(CONF_DISABLE_MBEDTLS_PKCS7, default=True): cv.boolean,
cv.Optional(CONF_DISABLE_REGI2C_IN_IRAM, default=True): cv.boolean,
cv.Optional(CONF_ADC_ONESHOT_IN_IRAM, default=False): cv.boolean,
cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean,
}
),
@@ -2082,16 +2068,6 @@ async def to_code(config):
if advanced[CONF_DISABLE_REGI2C_IN_IRAM]:
add_idf_sdkconfig_option("CONFIG_ESP_REGI2C_CTRL_FUNC_IN_IRAM", False)
# Place ADC oneshot control functions in IRAM for cache safety
# When flash cache is disabled (during NVS writes by WiFi, BLE, Zigbee, Thread,
# power management, etc.), ADC reads will crash if these functions are in flash.
# Components using ADC call require_adc_oneshot_iram() to force this.
if (
CORE.data[KEY_ESP32].get(KEY_ADC_ONESHOT_IRAM_REQUIRED, False)
or advanced[CONF_ADC_ONESHOT_IN_IRAM]
):
add_idf_sdkconfig_option("CONFIG_ADC_ONESHOT_CTRL_FUNC_IN_IRAM", True)
# Disable FATFS support
# Components that need FATFS (SD card, etc.) can call require_fatfs()
if CORE.data[KEY_ESP32].get(KEY_FATFS_REQUIRED, False):

View File

@@ -1960,10 +1960,6 @@ 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,
@@ -2032,10 +2028,6 @@ 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,
@@ -2297,18 +2289,10 @@ 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

@@ -108,13 +108,8 @@ async def globals_set_to_code(config, action_id, template_arg, args):
full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID])
template_arg = cg.TemplateArguments(full_id.type, *template_arg)
var = cg.new_Pvariable(action_id, template_arg, paren)
# Use the global's value_type alias as the lambda return type so
# TemplatableFn stores a direct function pointer instead of going through
# the deprecated converting trampoline when the value expression deduces
# to a different type (e.g. int literal assigned to a float global).
value_type = cg.RawExpression(f"{full_id.type}::value_type")
templ = await cg.templatable(
config[CONF_VALUE], args, value_type, to_exp=cg.RawExpression
config[CONF_VALUE], args, None, to_exp=cg.RawExpression, wrap_constant=True
)
cg.add(var.set_value(templ))
return var

View File

@@ -36,7 +36,7 @@ I2SAudioMicrophone = i2s_audio_ns.class_(
)
INTERNAL_ADC_VARIANTS = [esp32.VARIANT_ESP32]
PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3, esp32.VARIANT_ESP32P4]
PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3]
def _validate_esp32_variant(config):

View File

@@ -58,12 +58,6 @@ void AddressableLightTransformer::start() {
// our transition will handle brightness, disable brightness in correction.
this->light_.correction_.set_local_brightness(255);
this->target_color_ *= to_uint8_scale(end_values.get_brightness() * end_values.get_state());
// Uniformity scan is deferred to the first apply() call. start() can run before the underlying
// LED output's setup() has allocated its frame buffer (e.g. on_boot at priority > HARDWARE
// triggering a transition), and reading through ESPColorView would deref a null buffer.
this->uniform_start_scanned_ = false;
this->uniform_start_is_uniform_ = false;
}
inline constexpr uint8_t subtract_scaled_difference(uint8_t a, uint8_t b, int32_t scale) {
@@ -103,57 +97,12 @@ optional<LightColorValues> AddressableLightTransformer::apply() {
// non-linear when applying small deltas.
if (smoothed_progress > this->last_transition_progress_ && this->last_transition_progress_ < 1.f) {
// Lazy uniformity scan: deferred from start() so the LED output's setup() has run and the
// frame buffer is valid. When every LED already has the same color (the common case: plain
// turn_on/turn_off on a uniform strip), interpolate math-only against a single start color.
// Avoiding the per-step read-back through the 8-bit stored byte prevents gamma round-trip
// quantization from stalling the fade at low values (e.g. gamma 2.8 pre-gamma values <27
// round to stored 0, freezing progress).
if (!this->uniform_start_scanned_) {
this->uniform_start_scanned_ = true;
if (this->light_.size() > 0) {
Color first = this->light_[0].get();
bool uniform = true;
for (int32_t i = 1; i < this->light_.size(); i++) {
if (this->light_[i].get() != first) {
uniform = false;
break;
}
}
if (uniform) {
this->uniform_start_color_ = first;
this->uniform_start_is_uniform_ = true;
}
}
}
if (this->uniform_start_is_uniform_) {
// All LEDs started at the same color: compute the interpolated value once and write it to
// every LED. No read-back, so each LED's stored byte advances through every gamma threshold
// as smoothed_progress crosses it, instead of stalling at 0 for low pre-gamma values.
//
// Trade-off: any mid-transition writes to individual LEDs (e.g. from a user lambda) will be
// overwritten on the next apply() here. The fallback path below would have respected them
// via its read-back. Concurrent per-LED mutation during a transition isn't a pattern we
// support, so this is acceptable.
// lerp(start, target, progress) via existing helper: target - (target-start)*(1-progress).
const Color &start = this->uniform_start_color_;
int32_t remaining = int32_t(256.f * (1.f - smoothed_progress));
uint8_t r = subtract_scaled_difference(this->target_color_.red, start.red, remaining);
uint8_t g = subtract_scaled_difference(this->target_color_.green, start.green, remaining);
uint8_t b = subtract_scaled_difference(this->target_color_.blue, start.blue, remaining);
uint8_t w = subtract_scaled_difference(this->target_color_.white, start.white, remaining);
for (auto led : this->light_) {
led.set_rgbw(r, g, b, w);
}
} else {
int32_t scale =
int32_t(256.f * std::max((1.f - smoothed_progress) / (1.f - this->last_transition_progress_), 0.f));
for (auto led : this->light_) {
led.set_rgbw(subtract_scaled_difference(this->target_color_.red, led.get_red(), scale),
subtract_scaled_difference(this->target_color_.green, led.get_green(), scale),
subtract_scaled_difference(this->target_color_.blue, led.get_blue(), scale),
subtract_scaled_difference(this->target_color_.white, led.get_white(), scale));
}
int32_t scale = int32_t(256.f * std::max((1.f - smoothed_progress) / (1.f - this->last_transition_progress_), 0.f));
for (auto led : this->light_) {
led.set_rgbw(subtract_scaled_difference(this->target_color_.red, led.get_red(), scale),
subtract_scaled_difference(this->target_color_.green, led.get_green(), scale),
subtract_scaled_difference(this->target_color_.blue, led.get_blue(), scale),
subtract_scaled_difference(this->target_color_.white, led.get_white(), scale));
}
this->last_transition_progress_ = smoothed_progress;
this->light_.schedule_show();

View File

@@ -115,9 +115,6 @@ class AddressableLightTransformer : public LightTransformer {
AddressableLight &light_;
float last_transition_progress_{0.0f};
Color target_color_{};
Color uniform_start_color_{};
bool uniform_start_scanned_{false};
bool uniform_start_is_uniform_{false};
};
} // namespace esphome::light

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 CORE.is_esp32 and get_esp32_variant() == VARIANT_ESP32P4:
if 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.11.0")
add_idf_component(name="espressif/mdns", ref="1.10.0")
cg.add_define("USE_MDNS")

View File

@@ -451,8 +451,6 @@ 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.1.2")
cg.add_build_flag("-DTF_LITE_STATIC_MEMORY")
cg.add_build_flag("-DTF_LITE_DISABLE_X86_NEON")

View File

@@ -29,6 +29,14 @@ 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) {
@@ -45,26 +53,6 @@ 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_,
@@ -106,70 +94,6 @@ 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,10 +63,6 @@ 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);
@@ -74,7 +70,6 @@ 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

@@ -28,8 +28,7 @@ void AirConditioner::on_status_change() {
if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK &&
this->base_.getCapabilities().supportFrostProtectionPreset() && !this->frost_protection_set_) {
// Read existing presets (set by codegen), append frost protection, write back
auto traits = this->get_traits();
const auto &existing = traits.get_supported_custom_presets();
const auto &existing = this->get_traits().get_supported_custom_presets();
bool found = false;
for (const char *p : existing) {
if (strcmp(p, Constants::FREEZE_PROTECTION) == 0) {

View File

@@ -234,9 +234,9 @@ class MipiSpi : public display::Display,
}
void dump_config() override {
internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT,
(uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_,
this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE,
internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT, MADCTL,
this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, this->cs_,
this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE,
HAS_HARDWARE_ROTATION);
}
@@ -305,7 +305,7 @@ class MipiSpi : public display::Display,
this->write_command_(BRIGHTNESS, this->brightness_.value());
// calculate new madctl value from base value adjusted for rotation
uint8_t madctl = (uint8_t) MADCTL; // lower 8 bits only
uint8_t madctl = MADCTL; // lower 8 bits only
constexpr bool use_flips = (MADCTL & MADCTL_FLIP_FLAG) != 0;
constexpr uint8_t x_mask = use_flips ? MADCTL_XFLIP : MADCTL_MX;
constexpr uint8_t y_mask = use_flips ? MADCTL_YFLIP : MADCTL_MY;

View File

@@ -36,9 +36,8 @@ bool Nextion::send_command_(const std::string &command) {
}
#ifdef USE_NEXTION_COMMAND_SPACING
const uint32_t now = App.get_loop_component_start_time();
if (!this->connection_state_.ignore_is_setup_ && !this->command_pacer_.can_send(now)) {
ESP_LOGN(TAG, "Command spacing: delaying '%s'", command.c_str());
if (!this->connection_state_.ignore_is_setup_ && !this->command_pacer_.can_send()) {
ESP_LOGN(TAG, "Command spacing: delaying command '%s'", command.c_str());
return false;
}
#endif // USE_NEXTION_COMMAND_SPACING
@@ -49,16 +48,6 @@ bool Nextion::send_command_(const std::string &command) {
const uint8_t to_send[3] = {0xFF, 0xFF, 0xFF};
this->write_array(to_send, sizeof(to_send));
#ifdef USE_NEXTION_COMMAND_SPACING
// Mark sent immediately after writing to UART. The pacer enforces inter-command
// spacing from the transmit side. Marking on ACK (0x01) would leave last_command_time_
// at zero indefinitely, making can_send() always return true and spacing a no-op.
// ignore_is_setup_ commands (setup/init sequence) bypass spacing intentionally.
if (!this->connection_state_.ignore_is_setup_) {
this->command_pacer_.mark_sent(now);
}
#endif // USE_NEXTION_COMMAND_SPACING
return true;
}
@@ -264,8 +253,11 @@ bool Nextion::send_command(const char *command) {
if ((!this->is_setup() && !this->connection_state_.ignore_is_setup_) || this->is_sleeping())
return false;
this->add_no_result_to_queue_with_command_("command", command);
return true;
if (this->send_command_(command)) {
this->add_no_result_to_queue_("command");
return true;
}
return false;
}
bool Nextion::send_command_printf(const char *format, ...) {
@@ -282,8 +274,11 @@ bool Nextion::send_command_printf(const char *format, ...) {
return false;
}
this->add_no_result_to_queue_with_command_("command_printf", buffer);
return true;
if (this->send_command_(buffer)) {
this->add_no_result_to_queue_("command_printf");
return true;
}
return false;
}
#ifdef NEXTION_PROTOCOL_LOG
@@ -354,43 +349,25 @@ void Nextion::loop() {
}
#ifdef USE_NEXTION_COMMAND_SPACING
// Try to send any pending commands if spacing allows
this->process_pending_in_queue_();
#ifdef USE_NEXTION_WAVEFORM
if (!this->waveform_queue_.empty()) {
this->check_pending_waveform_();
}
#endif // USE_NEXTION_WAVEFORM
#endif // USE_NEXTION_COMMAND_SPACING
}
#ifdef USE_NEXTION_COMMAND_SPACING
void Nextion::process_pending_in_queue_() {
#ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP
size_t commands_sent = 0;
#endif // USE_NEXTION_MAX_COMMANDS_PER_LOOP
if (this->nextion_queue_.empty() || !this->command_pacer_.can_send()) {
return;
}
for (auto *item : this->nextion_queue_) {
if (item == nullptr || item->pending_command.empty()) {
continue; // Already sent, waiting for ACK — skip, don't stop
// Check if first item in queue has a pending command
auto *front_item = this->nextion_queue_.front();
if (front_item && !front_item->pending_command.empty()) {
if (this->send_command_(front_item->pending_command)) {
// Command sent successfully, clear the pending command
front_item->pending_command.clear();
ESP_LOGVV(TAG, "Pending command sent: %s", front_item->component->get_variable_name().c_str());
}
#ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP
if (++commands_sent > this->max_commands_per_loop_) {
ESP_LOGV(TAG, "Pending cmds: loop limit reached, deferring");
break;
}
#endif // USE_NEXTION_MAX_COMMANDS_PER_LOOP
const uint32_t now = App.get_loop_component_start_time();
if (!this->command_pacer_.can_send(now)) {
break; // Spacing not elapsed, stop for this loop iteration
}
if (!this->send_command_(item->pending_command)) {
break; // Unexpected send failure, stop
}
item->pending_command.clear();
ESP_LOGVV(TAG, "Pending cmd sent: %s", item->component->get_variable_name().c_str());
}
}
#endif // USE_NEXTION_COMMAND_SPACING
@@ -493,6 +470,10 @@ void Nextion::process_nextion_commands_() {
this->setup_callback_.call();
}
}
#ifdef USE_NEXTION_COMMAND_SPACING
this->command_pacer_.mark_sent(); // Here is where we should mark the command as sent
ESP_LOGN(TAG, "Command spacing: marked command sent");
#endif
break;
case 0x02: // invalid Component ID or name was used
ESP_LOGW(TAG, "Invalid component ID/name");
@@ -1098,18 +1079,10 @@ void Nextion::add_no_result_to_queue_(const std::string &variable_name) {
}
/**
* @brief Send a command and enqueue it for response tracking.
* @brief
*
* Callers are responsible for checking is_sleeping() before calling this
* method. The sleep guard is deliberately absent here because some callers
* (e.g. add_no_result_to_queue_with_ignore_sleep_printf_()) are explicitly
* sleep-safe and must bypass it.
*
* If USE_NEXTION_COMMAND_SPACING is enabled and the pacer is not ready,
* the command is saved in the queue entry for retry rather than dropped.
*
* @param variable_name Name of the variable or component associated with the command.
* @param command The raw command string to send.
* @param variable_name Variable name for the queue
* @param command
*/
void Nextion::add_no_result_to_queue_with_command_(const std::string &variable_name, const std::string &command) {
if ((!this->is_setup() && !this->connection_state_.ignore_is_setup_) || command.empty())
@@ -1290,22 +1263,9 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) {
std::string command = "get " + component->get_variable_name_to_send();
#ifdef USE_NEXTION_COMMAND_SPACING
// Always enqueue first so the response handler is present when the command
// is eventually sent. Store the command for retry if spacing blocked it;
// process_pending_in_queue_() will transmit it when the pacer allows.
nextion_queue->pending_command = command;
this->nextion_queue_.push_back(nextion_queue);
if (this->send_command_(command)) {
nextion_queue->pending_command.clear();
}
#else // USE_NEXTION_COMMAND_SPACING
if (this->send_command_(command)) {
this->nextion_queue_.push_back(nextion_queue);
} else {
delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory)
}
#endif // USE_NEXTION_COMMAND_SPACING
}
#ifdef USE_NEXTION_WAVEFORM
@@ -1349,10 +1309,10 @@ void Nextion::check_pending_waveform_() {
char command[24]; // "addt " + uint8 + "," + uint8 + "," + uint8 + null = max 17 chars
buf_append_printf(command, sizeof(command), 0, "addt %u,%u,%zu", component->get_component_id(),
component->get_wave_channel_id(), buffer_to_send);
// If spacing or setup state blocks the send, leave the entry at the front
// of waveform_queue_ for retry on the next loop iteration via
// check_pending_waveform_(). Only pop on a successful send.
this->send_command_(command);
if (!this->send_command_(command)) {
delete nb; // NOLINT(cppcoreguidelines-owning-memory)
this->waveform_queue_.pop();
}
}
#endif // USE_NEXTION_WAVEFORM

View File

@@ -55,20 +55,15 @@ class NextionCommandPacer {
uint8_t get_spacing() const { return spacing_ms_; }
/**
* @brief Check if enough time has passed to send the next command.
* @param now Current timestamp in milliseconds (use App.get_loop_component_start_time()
* for consistency with the rest of the queue timing).
* @return true if the spacing interval has elapsed since the last command was sent.
* @brief Check if enough time has passed to send next command
* @return true if enough time has passed since last command
*/
bool can_send(uint32_t now) const { return (now - last_command_time_) >= spacing_ms_; }
bool can_send() const { return (millis() - last_command_time_) >= spacing_ms_; }
/**
* @brief Record the transmit timestamp for the most recently sent command.
* @param now Current timestamp in milliseconds, as returned by
* App.get_loop_component_start_time(). Must use the same clock
* source as can_send() to avoid unsigned underflow.
* @brief Mark a command as sent, updating the timing
*/
void mark_sent(uint32_t now) { last_command_time_ = now; }
void mark_sent() { last_command_time_ = millis(); }
private:
uint8_t spacing_ms_;

View File

@@ -45,18 +45,6 @@ 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.
@@ -321,23 +309,20 @@ def _walk_packages(
return config
packages = config[CONF_PACKAGES]
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, 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 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 or any(
is_package_definition(v) for v in packages.values()
):
if not validate_deprecated:
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]
@@ -476,9 +461,6 @@ 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)
@@ -488,18 +470,7 @@ 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)
# 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,
)
return _walk_packages(package_config, self.process_package, context_vars)
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 <lwip_wrap.h> // For LWIPMutex — LwIPLock mirrors its semantics (see below)
#include <LwipEthernet.h> // For ethernet_arch_lwip_begin/end (LwIPLock)
#include "esphome/components/ethernet/ethernet_component.h"
#endif
#include <hardware/structs/rosc.h>
@@ -43,18 +43,9 @@ 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.
//
// 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.
// 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.
//
// 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.
@@ -62,18 +53,8 @@ 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() {
__inLWIP++;
ethernet_arch_lwip_begin();
}
LwIPLock::~LwIPLock() {
ethernet_arch_lwip_end();
__inLWIP--;
if (__needsIRQEN && !__inLWIP) {
__needsIRQEN = false;
ethernet_arch_lwip_gpio_unmask();
}
}
LwIPLock::LwIPLock() { ethernet_arch_lwip_begin(); }
LwIPLock::~LwIPLock() { ethernet_arch_lwip_end(); }
#else
LwIPLock::LwIPLock() {}
LwIPLock::~LwIPLock() {}

View File

@@ -55,11 +55,13 @@ void SafeModeComponent::dump_config() {
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
const esp_partition_t *last_invalid = esp_ota_get_last_invalid_partition();
if (last_invalid != nullptr) {
ESP_LOGW(TAG, "OTA rollback detected! Rolled back from partition '%s'", last_invalid->label);
ESP_LOGW(TAG, "The device reset before the boot was marked successful");
ESP_LOGW(TAG,
"OTA rollback detected! Rolled back from partition '%s'\n"
" The device reset before the boot was marked successful",
last_invalid->label);
if (esp_reset_reason() == ESP_RST_BROWNOUT) {
ESP_LOGW(TAG, "Last reset was due to brownout - check your power supply!");
ESP_LOGW(TAG, "See https://esphome.io/guides/faq.html#brownout-detector-was-triggered");
ESP_LOGW(TAG, "Last reset was due to brownout - check your power supply!\n"
" See https://esphome.io/guides/faq.html#brownout-detector-was-triggered");
}
}
#endif
@@ -86,7 +88,8 @@ void SafeModeComponent::mark_successful() {
}
void SafeModeComponent::loop() {
if (!this->boot_successful_ && (millis() - this->safe_mode_start_time_) > this->safe_mode_boot_is_good_after_) {
if (!this->boot_successful_ &&
(App.get_loop_component_start_time() - this->safe_mode_start_time_) > this->safe_mode_boot_is_good_after_) {
// successful boot, reset counter
ESP_LOGI(TAG, "Boot seems successful; resetting boot loop counter");
this->mark_successful();

View File

@@ -112,6 +112,8 @@ class BSDSocketImpl {
int setblocking(bool blocking);
int loop() { return 0; }
/// Check if the socket has buffered data ready to read.
/// See the ready() contract in socket.h — callers must drain or track remaining data.
bool ready() const;
int get_fd() const { return this->fd_; }

View File

@@ -96,6 +96,8 @@ class LWIPRawImpl : public LWIPRawCommon {
errno = ENOSYS;
return -1;
}
// Check if the socket has buffered data ready to read.
// See the ready() contract in socket.h — callers must drain or track remaining data.
// Intentionally unlocked — this is a polling check called every loop iteration.
// A stale read at worst delays processing by one loop tick; the actual I/O in
// read() holds the lwip lock and re-checks properly. See esphome#10681.

View File

@@ -78,6 +78,8 @@ class LwIPSocketImpl {
int setblocking(bool blocking);
int loop() { return 0; }
/// Check if the socket has buffered data ready to read.
/// See the ready() contract in socket.h — callers must drain or track remaining data.
bool ready() const;
int get_fd() const { return this->fd_; }

View File

@@ -53,6 +53,19 @@ bool socket_ready_fd(int fd, bool loop_monitored);
// Inline ready() — defined here because it depends on socket_ready/socket_ready_fd
// declared above, while the impl headers are included before those declarations.
//
// Contract (applies to ALL socket implementations — each platform implements
// ready() differently, but this contract holds regardless of the mechanism):
// ready() checks if the socket has buffered data ready to read. When it returns
// true, the caller MUST read until it would block (EAGAIN/EWOULDBLOCK), or until
// read() returns 0 to indicate EOF / connection closed, or track that it stopped
// early and retry without calling ready(). The next call to ready() will only
// report new data correctly if all callers fulfill this contract. Failing to
// drain the socket may cause ready() to return false while data remains readable.
//
// In practice each socket is owned by a single component, so this contract is
// straightforward to fulfill — but the owning component must be aware of it,
// especially if it limits how many messages it processes per loop iteration.
#if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS)
inline bool Socket::ready() const {
#ifdef USE_LWIP_FAST_SELECT

View File

@@ -104,17 +104,11 @@ 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();
@@ -354,9 +348,6 @@ 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,7 +3,6 @@
#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>
@@ -101,7 +100,6 @@ 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,8 +53,6 @@ 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();
@@ -62,7 +60,6 @@ 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
@@ -316,7 +313,6 @@ 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;
}
@@ -390,6 +386,11 @@ 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,7 +4,6 @@
#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 {
@@ -87,7 +86,6 @@ 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

@@ -315,7 +315,7 @@ void TCS34725Component::set_integration_time(TCS34725IntegrationTime integration
my_integration_time_regval = integration_time;
this->integration_time_auto_ = false;
}
this->integration_time_ = (256.f - (float) my_integration_time_regval) * 2.4f;
this->integration_time_ = (256.f - my_integration_time_regval) * 2.4f;
ESP_LOGI(TAG, "TCS34725I Integration time set to: %.1fms", this->integration_time_);
}
void TCS34725Component::set_gain(TCS34725Gain gain) {

View File

@@ -114,25 +114,7 @@ void OTARequestHandler::handleUpload(AsyncWebServerRequest *request, const Platf
uint8_t *data, size_t len, bool final) {
ota::OTAResponseTypes error_code = ota::OTA_RESPONSE_OK;
// First byte of a new upload: index==0 with actual data. (web_server_idf
// fires a separate start-marker call with data==nullptr/len==0 before the
// first real chunk; gate on len>0 so we only trigger once per upload.)
if (index == 0 && len > 0) {
// If a previous upload was interrupted (e.g. client closed the tab, TCP
// reset) the backend from that session may still be open. Tear it down
// so flash state doesn't get concatenated with the new image (which can
// produce a technically-valid-sized but corrupted firmware that bricks
// the device once it reboots).
if (this->ota_backend_) {
ESP_LOGW(TAG, "New OTA upload received while previous session was still open; aborting previous session");
this->ota_backend_->abort();
#ifdef USE_OTA_STATE_LISTENER
// Notify listeners that the previous session was aborted before the new one starts.
this->parent_->notify_state_deferred_(ota::OTA_ABORT, 0.0f, 0);
#endif
this->ota_backend_.reset();
}
if (index == 0 && !this->ota_backend_) {
// Initialize OTA on first call
this->ota_init_(filename.c_str());

View File

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

View File

@@ -113,8 +113,7 @@ def _generate_source_table_code(
entries = ", ".join(var_names)
lines.append(f"static const char *const {table_var}[] PROGMEM = {{{entries}}};")
lines.append(f"const LogString *{lookup_fn}(uint8_t index) {{")
cond = "index == 0" if count >= 255 else f"index == 0 || index > {count}"
lines.append(f' if ({cond}) return LOG_STR("<unknown>");')
lines.append(f' if (index == 0 || index > {count}) return LOG_STR("<unknown>");')
lines.append(" return reinterpret_cast<const LogString *>(")
lines.append(f" progmem_read_ptr(&{table_var}[index - 1]));")
lines.append("}")

View File

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

View File

@@ -5,15 +5,104 @@ import os
from pathlib import Path
import re
import subprocess
import sys
import time
from typing import Any
from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE
from esphome.core import CORE, EsphomeError
from esphome.util import run_external_process
from esphome.util import run_external_command, 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.*",
@@ -53,6 +142,20 @@ 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())
@@ -63,9 +166,30 @@ 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 = [sys.executable, "-m", "esphome.platformio_runner"] + list(args)
cmd = ["platformio"] + list(args)
return run_external_process(*cmd, **kwargs)
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)
def run_platformio_cli_run(config, verbose, *args, **kwargs) -> str | int:

View File

@@ -1,144 +0,0 @@
"""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()
# Wrap stdout/stderr with RedirectText before PlatformIO runs:
#
# 1. RedirectText.isatty() unconditionally returns True. Click, tqdm, and
# PlatformIO's own progress-bar code check ``stream.isatty()`` to
# decide whether to emit TTY-format output (``\r`` cursor moves, ANSI
# colors, fancy progress bars). With the wrapper in place they always
# emit TTY format, even when our real stdout is a pipe to the parent
# process. Downstream consumers (local terminals and the Home
# Assistant dashboard log viewer) render the TTY control sequences
# correctly, so the user sees real progress bars.
#
# 2. FILTER_PLATFORMIO_LINES is applied inside RedirectText.write() in
# this subprocess, so noisy PlatformIO output is dropped before it
# ever leaves the runner. This replaces the parent-side filtering
# that was lost when we switched from in-process to subprocess — the
# parent's ``subprocess.run`` uses ``.fileno()`` on RedirectText and
# bypasses its ``write()`` path entirely.
#
# Filtering is disabled when the user passed -v / --verbose to
# ``esphome compile``, preserving the previous in-process behavior where
# verbose mode let all PlatformIO output through unfiltered.
from esphome.platformio_api import FILTER_PLATFORMIO_LINES
from esphome.util import RedirectText
is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[1:])
filter_lines = None if is_verbose else FILTER_PLATFORMIO_LINES
sys.stdout = RedirectText(sys.stdout, filter_lines=filter_lines)
sys.stderr = RedirectText(sys.stderr, filter_lines=filter_lines)
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.38-1/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip
platform_packages =
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
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
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.38-1/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip
platform_packages =
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.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
framework = espidf
lib_deps =

View File

@@ -20,6 +20,7 @@ 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,7 +12,7 @@ platformio==6.1.19
esptool==5.2.0
click==8.3.2
esphome-dashboard==20260408.1
aioesphomeapi==44.15.0
aioesphomeapi==44.13.1
zeroconf==0.148.0
puremagic==1.30
ruamel.yaml==0.19.1 # dashboard_import

View File

@@ -1,6 +1,6 @@
pylint==4.0.5
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
ruff==0.15.9 # also change in .pre-commit-config.yaml when updating
ruff==0.15.10 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
pre-commit

View File

@@ -1028,8 +1028,7 @@ class BytesType(TypeInfo):
)
def get_size_calculation(self, name: str, force: bool = False) -> str:
calc_fn = "calc_length_force" if force else "calc_length"
return f"size += ProtoSize::{calc_fn}({self.calculate_field_id_size()}, this->{self.field_name}_len_);"
return f"size += ProtoSize::calc_length({self.calculate_field_id_size()}, this->{self.field_name}_len_);"
def get_estimated_size(self) -> int:
return self.calculate_field_id_size() + 8 # field ID + 8 bytes typical bytes
@@ -1110,8 +1109,7 @@ class PointerToBytesBufferType(PointerToBufferTypeBase):
)
def get_size_calculation(self, name: str, force: bool = False) -> str:
calc_fn = "calc_length_force" if force else "calc_length"
return f"size += ProtoSize::{calc_fn}({self.calculate_field_id_size()}, this->{self.field_name}_len);"
return f"size += ProtoSize::calc_length({self.calculate_field_id_size()}, this->{self.field_name}_len);"
class PointerToStringBufferType(PointerToBufferTypeBase):
@@ -2681,16 +2679,6 @@ 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.
is_speed_optimized = get_opt(desc, pb.speed_optimized, False)
speed_attr = (
'__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)\n'
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
@@ -2700,7 +2688,7 @@ def build_message_type(
)
for line in encode
]
o = f"{speed_attr}uint8_t *{desc.name}::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {{\n"
o = f"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"
@@ -2714,7 +2702,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"{speed_attr}uint32_t {desc.name}::calculate_size() const {{\n"
o = f"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

@@ -10,7 +10,6 @@ namespace esphome::benchmarks {
static constexpr int kInnerIterations = 2000;
// --- random_float() ---
// Ported from ol.yaml:148 "Random Float Benchmark"
static void RandomFloat(benchmark::State &state) {
for (auto _ : state) {
@@ -38,4 +37,274 @@ static void RandomUint32(benchmark::State &state) {
}
BENCHMARK(RandomUint32);
// --- format_hex_to() - 6 bytes (MAC address sized) ---
static void FormatHexTo_6Bytes(benchmark::State &state) {
const uint8_t data[] = {0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45};
char buffer[13]; // 6 * 2 + 1
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
format_hex_to(buffer, data, 6);
}
benchmark::DoNotOptimize(buffer);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(FormatHexTo_6Bytes);
// --- format_hex_to() - 16 bytes (UUID sized) ---
static void FormatHexTo_16Bytes(benchmark::State &state) {
const uint8_t data[] = {0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45, 0x67, 0x89,
0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10};
char buffer[33]; // 16 * 2 + 1
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
format_hex_to(buffer, data, 16);
}
benchmark::DoNotOptimize(buffer);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(FormatHexTo_16Bytes);
// --- format_hex_to() - 100 bytes (large payload) ---
static void FormatHexTo_100Bytes(benchmark::State &state) {
uint8_t data[100];
for (int i = 0; i < 100; i++) {
data[i] = static_cast<uint8_t>(i);
}
char buffer[201]; // 100 * 2 + 1
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
format_hex_to(buffer, data, 100);
}
benchmark::DoNotOptimize(buffer);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(FormatHexTo_100Bytes);
// --- format_hex_pretty_to() - 6 bytes with ':' separator ---
static void FormatHexPrettyTo_6Bytes(benchmark::State &state) {
const uint8_t data[] = {0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45};
char buffer[18]; // 6 * 3
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
format_hex_pretty_to(buffer, data, 6);
}
benchmark::DoNotOptimize(buffer);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(FormatHexPrettyTo_6Bytes);
// --- format_mac_addr_upper() ---
static void FormatMacAddrUpper(benchmark::State &state) {
const uint8_t mac[] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF};
char buffer[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
format_mac_addr_upper(mac, buffer);
}
benchmark::DoNotOptimize(buffer);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(FormatMacAddrUpper);
// --- fnv1_hash() - short string ---
static void Fnv1Hash_Short(benchmark::State &state) {
const char *str = "sensor.temperature";
for (auto _ : state) {
uint32_t result = 0;
for (int i = 0; i < kInnerIterations; i++) {
result ^= fnv1_hash(str);
}
benchmark::DoNotOptimize(result);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Fnv1Hash_Short);
// --- fnv1_hash() - long string ---
static void Fnv1Hash_Long(benchmark::State &state) {
const char *str = "binary_sensor.living_room_motion_sensor_occupancy_detected";
for (auto _ : state) {
uint32_t result = 0;
for (int i = 0; i < kInnerIterations; i++) {
result ^= fnv1_hash(str);
}
benchmark::DoNotOptimize(result);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Fnv1Hash_Long);
// --- fnv1a_hash() - short string ---
// Use DoNotOptimize on the input pointer to prevent constexpr evaluation
static void Fnv1aHash_Short(benchmark::State &state) {
const char *str = "sensor.temperature";
benchmark::DoNotOptimize(str);
for (auto _ : state) {
uint32_t result = 0;
for (int i = 0; i < kInnerIterations; i++) {
result ^= fnv1a_hash(str);
benchmark::ClobberMemory();
}
benchmark::DoNotOptimize(result);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Fnv1aHash_Short);
// --- fnv1a_hash() - long string ---
static void Fnv1aHash_Long(benchmark::State &state) {
const char *str = "binary_sensor.living_room_motion_sensor_occupancy_detected";
benchmark::DoNotOptimize(str);
for (auto _ : state) {
uint32_t result = 0;
for (int i = 0; i < kInnerIterations; i++) {
result ^= fnv1a_hash(str);
benchmark::ClobberMemory();
}
benchmark::DoNotOptimize(result);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Fnv1aHash_Long);
// --- fnv1_hash_object_id() - typical entity name ---
static void Fnv1HashObjectId(benchmark::State &state) {
char name[] = "Living Room Temperature Sensor";
size_t len = sizeof(name) - 1;
benchmark::DoNotOptimize(name);
for (auto _ : state) {
uint32_t result = 0;
for (int i = 0; i < kInnerIterations; i++) {
result ^= fnv1_hash_object_id(name, len);
benchmark::ClobberMemory();
}
benchmark::DoNotOptimize(result);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Fnv1HashObjectId);
// --- parse_hex() - 6 bytes from string ---
static void ParseHex_6Bytes(benchmark::State &state) {
const char *hex_str = "ABCDEF012345";
uint8_t data[6];
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
parse_hex(hex_str, data, 6);
}
benchmark::DoNotOptimize(data);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(ParseHex_6Bytes);
// --- parse_hex() - 16 bytes from string ---
static void ParseHex_16Bytes(benchmark::State &state) {
const char *hex_str = "ABCDEF0123456789FEDCBA9876543210";
uint8_t data[16];
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
parse_hex(hex_str, data, 16);
}
benchmark::DoNotOptimize(data);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(ParseHex_16Bytes);
// --- crc8() - 8 bytes ---
static void CRC8_8Bytes(benchmark::State &state) {
const uint8_t data[] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08};
for (auto _ : state) {
uint8_t result = 0;
for (int i = 0; i < kInnerIterations; i++) {
result ^= crc8(data, 8);
}
benchmark::DoNotOptimize(result);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(CRC8_8Bytes);
// --- crc16() - 8 bytes ---
static void CRC16_8Bytes(benchmark::State &state) {
const uint8_t data[] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08};
for (auto _ : state) {
uint16_t result = 0;
for (int i = 0; i < kInnerIterations; i++) {
result ^= crc16(data, 8);
}
benchmark::DoNotOptimize(result);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(CRC16_8Bytes);
// --- value_accuracy_to_buf() - typical sensor value ---
static void ValueAccuracyToBuf(benchmark::State &state) {
char raw_buf[VALUE_ACCURACY_MAX_LEN] = {};
std::span<char, VALUE_ACCURACY_MAX_LEN> buf(raw_buf);
float value = 23.456f;
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
value_accuracy_to_buf(buf, value, 2);
}
benchmark::DoNotOptimize(raw_buf);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(ValueAccuracyToBuf);
// --- int8_to_str() ---
static void Int8ToStr(benchmark::State &state) {
char buffer[5] = {};
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
int8_to_str(buffer, static_cast<int8_t>(i & 0xFF));
benchmark::DoNotOptimize(buffer);
benchmark::ClobberMemory();
}
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Int8ToStr);
// --- base64_decode() - into pre-allocated buffer ---
static void Base64Decode_32Bytes(benchmark::State &state) {
// 32 bytes encoded = 44 base64 chars
const uint8_t encoded[] = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGx0eHw==";
size_t encoded_len = 44;
uint8_t output[32];
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
base64_decode(encoded, encoded_len, output, sizeof(output));
}
benchmark::DoNotOptimize(output);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Base64Decode_32Bytes);
} // namespace esphome::benchmarks

View File

@@ -1,18 +1,11 @@
"""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,
_walk_packages,
do_packages_pass,
is_package_definition,
merge_packages,
)
from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass, merge_packages
from esphome.components.substitutions import do_substitution_pass
import esphome.config as config_module
from esphome.config import resolve_extend_remove
@@ -44,7 +37,7 @@ from esphome.const import (
)
from esphome.core import CORE
from esphome.util import OrderedDict
from esphome.yaml_util import IncludeFile, add_context
from esphome.yaml_util import add_context
# Test strings
TEST_DEVICE_NAME = "test_device_name"
@@ -86,44 +79,6 @@ 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.
@@ -1106,51 +1061,6 @@ 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",
[
@@ -1197,134 +1107,6 @@ 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,13 +50,6 @@ 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

@@ -4,14 +4,6 @@ esphome:
- globals.set:
id: glob_int
value: "10"
# Set a float global with an integer literal - must emit the correct
# return type so TemplatableFn stores a direct function pointer.
- globals.set:
id: glob_float
value: "102"
- globals.set:
id: glob_float
value: !lambda "return 42;"
globals:
- id: glob_int

View File

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

View File

@@ -1,29 +0,0 @@
esphome:
name: addr-light-transition
host:
api:
logger:
level: DEBUG
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
light:
- platform: mock_addressable_light
output_id: strip_output
id: strip
name: "Test Strip"
num_leds: 4
gamma_correct: 2.8
default_transition_length: 0s
sensor:
- platform: template
name: "led0_red_raw"
id: led0_red_raw
update_interval: 10ms
accuracy_decimals: 0
lambda: |-
return (float) id(strip_output).get_raw_red(0);

View File

@@ -1 +0,0 @@
CODEOWNERS = ["@esphome/tests"]

View File

@@ -1,23 +0,0 @@
import esphome.codegen as cg
from esphome.components import light
import esphome.config_validation as cv
from esphome.const import CONF_NUM_LEDS, CONF_OUTPUT_ID
from esphome.types import ConfigType
mock_addressable_light_ns = cg.esphome_ns.namespace("mock_addressable_light")
MockAddressableLight = mock_addressable_light_ns.class_(
"MockAddressableLight", light.AddressableLight
)
CONFIG_SCHEMA = light.ADDRESSABLE_LIGHT_SCHEMA.extend(
{
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(MockAddressableLight),
cv.Optional(CONF_NUM_LEDS, default=4): cv.positive_not_null_int,
}
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_OUTPUT_ID], config[CONF_NUM_LEDS])
await light.register_light(var, config)
await cg.register_component(var, config)

View File

@@ -1,52 +0,0 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <memory>
#include "esphome/components/light/addressable_light.h"
#include "esphome/core/component.h"
namespace esphome::mock_addressable_light {
// In-memory addressable light for host-mode integration tests. Exposes the raw
// per-LED byte buffer (post-gamma-correction, as the hardware would see it)
// so tests can observe transition behavior without real hardware.
class MockAddressableLight : public light::AddressableLight {
public:
explicit MockAddressableLight(uint16_t num_leds)
: num_leds_(num_leds), buf_(new uint8_t[num_leds * 4]()), effect_data_(new uint8_t[num_leds]()) {}
void setup() override {}
void write_state(light::LightState *state) override {}
int32_t size() const override { return this->num_leds_; }
void clear_effect_data() override {
for (uint16_t i = 0; i < this->num_leds_; i++)
this->effect_data_[i] = 0;
}
light::LightTraits get_traits() override {
auto traits = light::LightTraits();
traits.set_supported_color_modes({light::ColorMode::RGB});
return traits;
}
// Accessors for tests: return the raw stored byte (post gamma correction),
// which is what actual LED hardware would receive.
uint8_t get_raw_red(uint16_t index) const { return this->buf_[index * 4 + 0]; }
uint8_t get_raw_green(uint16_t index) const { return this->buf_[index * 4 + 1]; }
uint8_t get_raw_blue(uint16_t index) const { return this->buf_[index * 4 + 2]; }
uint8_t get_raw_white(uint16_t index) const { return this->buf_[index * 4 + 3]; }
protected:
light::ESPColorView get_view_internal(int32_t index) const override {
size_t pos = index * 4;
return {this->buf_.get() + pos + 0, this->buf_.get() + pos + 1, this->buf_.get() + pos + 2,
this->buf_.get() + pos + 3, this->effect_data_.get() + index, &this->correction_};
}
uint16_t num_leds_;
std::unique_ptr<uint8_t[]> buf_;
std::unique_ptr<uint8_t[]> effect_data_;
};
} // namespace esphome::mock_addressable_light

View File

@@ -1,119 +0,0 @@
"""Integration test for addressable light transitions with gamma correction.
Regression test for a bug where a long turn-on transition on an addressable
light with gamma correction (e.g. gamma_correct: 2.8) produced no visible
output for ~90% of the transition duration, then jumped to the target in the
final ~10%. Root cause: the transition algorithm read each LED's current value
back through the 8-bit stored byte every step; at gamma 2.8 any pre-gamma value
below ~27 rounds to stored byte 0, so the stored byte stalled at 0 until
progress was high enough for a single step to produce a large-enough pre-gamma
value to clear the gamma threshold.
The fix interpolates against a cached start color when all LEDs started at the
same value (the common case for plain turn_on/turn_off), avoiding the round-trip.
This test uses a host-only mock addressable light that exposes the raw stored
byte of each LED, so we can observe the transition directly.
"""
from __future__ import annotations
import asyncio
from aioesphomeapi import LightInfo, SensorInfo, SensorState
import pytest
from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_addressable_light_transition(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""With gamma 2.8, the stored raw byte must rise visibly well before the end."""
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
light = require_entity(entities, "test_strip", LightInfo)
sensor = require_entity(entities, "led0_red_raw", SensorInfo)
# Track the raw-byte sensor. It polls every 10ms in the fixture, and
# ESPHome sensors publish on every change, so we collect a time series.
# Samples are stored as absolute (loop_time, value); we rebase to the
# command-issue time after the run so pre-command samples are strictly
# negative and reliably excluded.
loop = asyncio.get_running_loop()
samples: list[tuple[float, float]] = []
def on_state(state: object) -> None:
if not isinstance(state, SensorState) or state.key != sensor.key:
return
samples.append((loop.time(), state.state))
# InitialStateHelper swallows the first state ESPHome sends per entity
# on subscribe, so on_state only sees real post-subscribe updates.
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
# Start transition: off -> full white over 1 second. This is the
# scenario from the bug report, compressed in time.
transition_s = 1.0
command_time = loop.time()
client.light_command(
key=light.key,
state=True,
rgb=(1.0, 1.0, 1.0),
brightness=1.0,
transition_length=transition_s,
)
# Let the full transition run, plus margin for the final sample.
await asyncio.sleep(transition_s + 0.2)
# Rebase to command-issue time. Pre-command samples have t < 0 and are
# excluded; everything else is in seconds since the command was issued.
post_command = [
(t - command_time, v) for (t, v) in samples if t >= command_time
]
assert post_command, "no sensor samples received after command was issued"
# Assertion 1: the transition is not stalled. With the bug, the raw
# byte stays at 0 until ~90% of the transition duration. With the fix,
# it becomes nonzero in the first ~30% (for gamma 2.8, pre-gamma 76
# clears the gamma threshold at progress ~0.30). Require the first
# nonzero sample to land well before 50% of the transition duration,
# measured from the command-issue time. The 50% bound (rather than
# 70%) leaves headroom for assertion 2's mid-window check.
first_nonzero = next(((t, v) for (t, v) in post_command if v > 0), None)
assert first_nonzero is not None, (
"raw byte never rose above 0 during the transition — the fade stalled"
)
assert first_nonzero[0] < transition_s * 0.5, (
f"raw byte only rose above 0 at t={first_nonzero[0]:.3f}s "
f"(>{transition_s * 0.5:.3f}s after command) — transition is stalling"
)
# Assertion 2: by mid-late transition, the raw byte should have reached
# a substantial fraction of its final value. Bound the window to
# [50%, 90%] of the transition so the post-transition settled value
# (which always reaches 255) can't satisfy this assertion — that would
# let "stays at 0 then jumps at 99%" regressions slip through.
mid_window = [
v
for (t, v) in post_command
if transition_s * 0.5 <= t <= transition_s * 0.9
]
assert mid_window, "no samples captured in mid-transition window"
assert max(mid_window) >= 100, (
f"raw byte peaked at only {max(mid_window)} between 50%90% of "
"transition (expected >= 100 for white target at gamma 2.8)"
)
# Assertion 3: final value reaches target. Gamma 2.8 of 255 is 255.
final_samples = [v for (_, v) in post_command[-5:]]
assert max(final_samples) >= 250, (
f"final raw byte was {max(final_samples)}, expected >= 250"
)

View File

@@ -84,9 +84,9 @@ def mock_decode_pc() -> Generator[Mock, None, None]:
@pytest.fixture
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:
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:
yield mock

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1231,48 +1231,6 @@ def test_upload_using_esptool_path_conversion(
assert partitions_path.endswith("partitions.bin")
def test_upload_using_esptool_skips_missing_extra_flash_images(
tmp_path: Path,
mock_run_external_command_main: Mock,
mock_get_idedata: Mock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A non-existent path in extra_flash_images must be filtered out with a
warning, and must not appear in the esptool command line. Only the valid
images are flashed. Regression test for
https://github.com/esphome/esphome/issues/15634.
"""
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test")
CORE.data[KEY_ESP32] = {KEY_VARIANT: VARIANT_ESP32}
missing_path = tmp_path / "variants" / "tasmota" / "tinyuf2.bin"
mock_idedata = MagicMock(spec=platformio_api.IDEData)
mock_idedata.firmware_bin_path = tmp_path / "firmware.bin"
mock_idedata.extra_flash_images = [
platformio_api.FlashImage(path=tmp_path / "bootloader.bin", offset="0x1000"),
platformio_api.FlashImage(path=missing_path, offset="0x2d0000"),
]
mock_get_idedata.return_value = mock_idedata
(tmp_path / "firmware.bin").touch()
(tmp_path / "bootloader.bin").touch()
# Intentionally do NOT create missing_path
config = {CONF_ESPHOME: {"platformio_options": {}}}
with caplog.at_level(logging.WARNING, logger="esphome.__main__"):
result = upload_using_esptool(config, "/dev/ttyUSB0", None, None)
assert result == 0
assert "Skipping missing flash image" in caplog.text
assert str(missing_path) in caplog.text
cmd_list = list(mock_run_external_command_main.call_args[0][1:])
assert str(missing_path) not in cmd_list
assert "0x2d0000" not in cmd_list
def test_upload_using_esptool_with_file_path(
tmp_path: Path,
mock_run_external_command_main: Mock,

View File

@@ -1,8 +1,7 @@
"""Tests for platformio_api.py path functions."""
# pylint: disable=protected-access
import json
import logging
import os
from pathlib import Path
import shutil
@@ -11,7 +10,7 @@ from unittest.mock import MagicMock, Mock, call, patch
import pytest
from esphome import platformio_api, platformio_runner
from esphome import platformio_api
from esphome.core import CORE, EsphomeError
@@ -282,13 +281,13 @@ def test_run_idedata_raises_on_invalid_json(
def test_run_platformio_cli_sets_environment_variables(
setup_core: Path, mock_run_external_process: Mock
setup_core: Path, mock_run_external_command: 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_process.return_value = 0
mock_run_external_command.return_value = 0
platformio_api.run_platformio_cli("test", "arg")
# Check environment variables were set
@@ -301,12 +300,10 @@ 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 — 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
# Check command was called correctly
mock_run_external_command.assert_called_once()
args = mock_run_external_command.call_args[0]
assert "platformio" in args
assert "test" in args
assert "arg" in args
@@ -447,7 +444,7 @@ def test_patch_structhash(setup_core: Path) -> None:
},
):
# Call patch_structhash
platformio_runner.patch_structhash()
platformio_api.patch_structhash()
# Verify both modules had clean_build_dir patched
# Check that clean_build_dir was set on both modules
@@ -499,7 +496,7 @@ def test_patched_clean_build_dir_removes_outdated(setup_core: Path) -> None:
},
):
# Call patch_structhash to install the patched function
platformio_runner.patch_structhash()
platformio_api.patch_structhash()
# Call the patched function
mock_helpers.clean_build_dir(str(build_dir), [])
@@ -549,7 +546,7 @@ def test_patched_clean_build_dir_keeps_updated(setup_core: Path) -> None:
},
):
# Call patch_structhash to install the patched function
platformio_runner.patch_structhash()
platformio_api.patch_structhash()
# Call the patched function
mock_helpers.clean_build_dir(str(build_dir), [])
@@ -597,7 +594,7 @@ def test_patched_clean_build_dir_creates_missing(setup_core: Path) -> None:
},
):
# Call patch_structhash to install the patched function
platformio_runner.patch_structhash()
platformio_api.patch_structhash()
# Call the patched function
mock_helpers.clean_build_dir(str(build_dir), [])
@@ -722,7 +719,7 @@ def test_patch_file_downloader_succeeds_first_try() -> None:
),
},
):
platformio_runner.patch_file_downloader()
platformio_api.patch_file_downloader()
from platformio.package.download import FileDownloader
@@ -761,7 +758,7 @@ def test_patch_file_downloader_retries_on_failure() -> None:
),
patch("time.sleep") as mock_sleep,
):
platformio_runner.patch_file_downloader()
platformio_api.patch_file_downloader()
from platformio.package.download import FileDownloader
@@ -802,7 +799,7 @@ def test_patch_file_downloader_raises_after_max_retries() -> None:
),
patch("time.sleep") as mock_sleep,
):
platformio_runner.patch_file_downloader()
platformio_api.patch_file_downloader()
from platformio.package.download import FileDownloader
@@ -850,7 +847,7 @@ def test_patch_file_downloader_closes_session_and_response_between_retries() ->
),
patch("time.sleep"),
):
platformio_runner.patch_file_downloader()
platformio_api.patch_file_downloader()
from platformio.package.download import FileDownloader
@@ -885,9 +882,9 @@ def test_patch_file_downloader_idempotent() -> None:
},
):
# Patch multiple times
platformio_runner.patch_file_downloader()
platformio_runner.patch_file_downloader()
platformio_runner.patch_file_downloader()
platformio_api.patch_file_downloader()
platformio_api.patch_file_downloader()
platformio_api.patch_file_downloader()
from platformio.package.download import FileDownloader
@@ -898,18 +895,19 @@ def test_patch_file_downloader_idempotent() -> None:
assert call_count == 1
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
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,
)
redirect.write(line + "\n")
return captured.getvalue()
assert log_filter.filter(record) is True
@pytest.mark.parametrize(
@@ -932,9 +930,19 @@ def _filter_through_redirect(line: str) -> str:
"Memory Usage -> https://bit.ly/pio-memory-usage",
],
)
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) == ""
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
@pytest.mark.parametrize(
@@ -946,6 +954,39 @@ def test_filter_platformio_lines_blocks_noisy_messages(msg: str) -> None:
"warning: unused variable",
],
)
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"
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