Compare commits

..

4 Commits

Author SHA1 Message Date
J. Nick Koston c37ce25fb7 [core] fast_select stats: spin-poll to bucket load-bearing hits by µs
Replaces the single load_bearing counter with three buckets measured
via a bounded (100µs) spin-poll of the task notification value after
the scan finds data:

  race  (<10µs)  — notification arrived within ~10µs: callback-ordering
                   race between rcvevent write and xTaskNotifyGive a few
                   instructions later. Scan is noise.
  micro (<100µs) — notification arrived within 100µs: still noise at
                   loop_interval scale.
  stall (>=100µs) — notification did not arrive within the poll window.
                    Only this case could represent a real latency spike
                    that the scan is rescuing.

The 100µs spin cap is intentional: if we are wrong and this IS a real
stall, we only add 100µs to that one unlucky loop iteration.

The immediate ESP_LOGW on each hit now includes gap_us and the bucket
label so individual events can be investigated.
2026-04-10 20:15:15 -10:00
J. Nick Koston 8921a9dd60 [core] fast_select stats: log details on load-bearing hit
Adds a non-inline helper note_fast_select_load_bearing_() invoked only
when the scan found data and the task notification counter was 0.
Logs: hit sequence number, lwip_sock pointer, index in
monitored_sockets_, raw rcvevent value, delay_ms, and how many other
sockets also had data at that instant.

Hot path is unchanged — the helper is out-of-line so yield_with_select_
still inlines to the same code as before for the zero-hit path.
2026-04-10 20:11:34 -10:00
J. Nick Koston 14b804f3e1 [core] fast_select stats: skip notify peek on LibreTiny (pre-10.4 FreeRTOS)
LibreTiny's FreeRTOS port predates ulTaskNotifyValueClear (added in
FreeRTOS 10.4.0). Fall back to a pessimistic 0 on non-ESP32 so
load_bearing becomes an upper bound == found_data on LibreTiny. Zero
there is still a valid proof that the scan is unused.
2026-04-10 20:02:27 -10:00
J. Nick Koston d15a9597d7 [core] Instrument fast_select pre-sleep socket scan to prove it is unused
Adds three debug atomic counters around the pre-sleep socket scan in
yield_with_select_():

- fast_select_scan_total_        every scan
- fast_select_scan_found_data_   scan saw a socket with pending data
- fast_select_scan_load_bearing_ scan saw pending data AND the task
                                 notification counter was 0 at scan start

Only the third counter represents a case the scan actually rescues: had
the scan not been present, ulTaskNotifyTake would have stalled up to
loop_interval ms. The other two cases are harmless (Take would have
returned immediately).

The notification value is peeked with ulTaskNotifyValueClear(nullptr, 0)
(a pure read — zero bits cleared, state untouched) BEFORE the scan loop.
Peeking before the scan makes the measurement TOCTOU-free: the value we
compare against is the value at the moment Take would have been called,
exactly the counterfactual we want to measure. Peeking after has_data
would race with the lwip callback firing during the scan.

Stats are logged via ESP_LOGD every 30s from Application::loop().

Background: PR #14475 removed the scan and was reverted because the API
connection's MAX_MESSAGES_PER_LOOP=5 throttle violated the ready()
contract (see #15589, #15590). With #15590 the contract is now
documented and honored, so the scan may now be removable. This PR
gathers evidence; if load_bearing stays 0 across ESP32/LibreTiny under
real workloads, the scan and these counters will be removed in a
follow-up.
2026-04-10 19:55:56 -10:00
78 changed files with 671 additions and 1842 deletions
+1 -1
View File
@@ -1 +1 @@
dc8ad5472d9fb44ce1ca29a0601afd65705642799a2819704dfc8459fbaf9815
f31f13994768b5b07e29624406c9b053bf4bb26e1623ac2bc1e9d4a9477502d6
+1 -1
View File
@@ -22,7 +22,7 @@ runs:
python-version: ${{ inputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
# yamllint disable-line rule:line-length
+2 -2
View File
@@ -20,14 +20,14 @@ env:
jobs:
label:
runs-on: ubuntu-latest
if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot')
if: github.event.action != 'labeled' || github.event.sender.type != 'Bot'
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v2
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
with:
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
+15 -15
View File
@@ -47,7 +47,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
# yamllint disable-line rule:line-length
@@ -159,7 +159,7 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Save Python virtual environment cache
if: github.ref == 'refs/heads/dev'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
@@ -198,7 +198,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Restore components graph cache
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: .temp/components_graph.json
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
@@ -231,7 +231,7 @@ jobs:
echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT
- name: Save components graph cache
if: github.ref == 'refs/heads/dev'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: .temp/components_graph.json
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
@@ -253,7 +253,7 @@ jobs:
python-version: "3.13"
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: venv
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
@@ -387,14 +387,14 @@ jobs:
- name: Cache platformio
if: github.ref == 'refs/heads/dev'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev'
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
@@ -466,14 +466,14 @@ jobs:
- name: Cache platformio
if: github.ref == 'refs/heads/dev'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev'
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
@@ -555,14 +555,14 @@ jobs:
- name: Cache platformio
if: github.ref == 'refs/heads/dev'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
if: github.ref != 'refs/heads/dev'
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: ~/.platformio
key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
@@ -817,7 +817,7 @@ jobs:
- name: Restore cached memory analysis
id: cache-memory-analysis
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true'
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: memory-analysis-target.json
key: ${{ steps.cache-key.outputs.cache-key }}
@@ -841,7 +841,7 @@ jobs:
- name: Cache platformio
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: ~/.platformio
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}
@@ -883,7 +883,7 @@ jobs:
- name: Save memory analysis to cache
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: memory-analysis-target.json
key: ${{ steps.cache-key.outputs.cache-key }}
@@ -930,7 +930,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache platformio
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: ~/.platformio
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}
+3 -3
View File
@@ -221,7 +221,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
with:
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
@@ -256,7 +256,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
with:
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
@@ -287,7 +287,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
with:
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
+1 -8
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:
+1 -14
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"
+2 -5
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"];
}
-1
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 {
+10 -24
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];
+2 -2
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
+1 -24
View File
@@ -1,11 +1,7 @@
from dataclasses import dataclass
import esphome.codegen as cg
from esphome.components.esp32 import (
add_idf_component,
add_idf_sdkconfig_option,
include_builtin_idf_component,
)
from esphome.components.esp32 import add_idf_component, include_builtin_idf_component
import esphome.config_validation as cv
from esphome.const import CONF_BITS_PER_SAMPLE, CONF_NUM_CHANNELS, CONF_SAMPLE_RATE
from esphome.core import CORE
@@ -31,7 +27,6 @@ class AudioData:
flac_support: bool = False
mp3_support: bool = False
opus_support: bool = False
micro_decoder_support: bool = False
def _get_data() -> AudioData:
@@ -55,11 +50,6 @@ def request_opus_support() -> None:
_get_data().opus_support = True
def request_micro_decoder_support() -> None:
"""Request micro-decoder library support for audio decoding."""
_get_data().micro_decoder_support = True
CONF_MIN_BITS_PER_SAMPLE = "min_bits_per_sample"
CONF_MAX_BITS_PER_SAMPLE = "max_bits_per_sample"
CONF_MIN_CHANNELS = "min_channels"
@@ -218,19 +208,6 @@ async def to_code(config):
)
data = _get_data()
if data.micro_decoder_support:
add_idf_component(name="esphome/micro-decoder", ref="0.1.1")
# All codecs are enabled by default in micro-decoder, so disable the ones that aren't requested to save flash
if not data.flac_support:
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_FLAC", False)
if not data.mp3_support:
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_MP3", False)
if not data.opus_support:
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_OPUS", False)
# Legacy audio_decoder.cpp support defines and components
if data.flac_support:
cg.add_define("USE_AUDIO_FLAC_SUPPORT")
add_idf_component(name="esphome/micro-flac", ref="0.1.1")
+10 -26
View File
@@ -17,7 +17,6 @@ CODEOWNERS = ["@neffs", "@kbx81"]
DOMAIN = "bme68x_bsec2"
BSEC2_LIBRARY_VERSION = "1.10.2610"
BME68x_LIBRARY_VERSION = "v1.3.40408"
CONF_ALGORITHM_OUTPUT = "algorithm_output"
CONF_BME68X_BSEC2_ID = "bme68x_bsec2_id"
@@ -185,31 +184,16 @@ async def to_code_base(config):
if core.CORE.using_arduino:
cg.add_library("Wire", None)
cg.add_library("SPI", None)
if core.CORE.is_esp32:
from esphome.components.esp32 import add_idf_component
add_idf_component(
name="boschsensortec/Bosch-BME68x-Library",
repo="https://github.com/esphome-libs/Bosch-BME68x-Library",
ref=BME68x_LIBRARY_VERSION,
)
add_idf_component(
name="boschsensortec/Bosch-BSEC2-Library",
repo="https://github.com/esphome-libs/Bosch-BSEC2-Library",
ref=BSEC2_LIBRARY_VERSION,
)
else:
cg.add_library(
"BME68x Sensor library",
None,
f"https://github.com/boschsensortec/Bosch-BME68x-Library#{BME68x_LIBRARY_VERSION}",
)
cg.add_library(
"BSEC2 Software Library",
None,
f"https://github.com/boschsensortec/Bosch-BSEC2-Library.git#{BSEC2_LIBRARY_VERSION}",
)
cg.add_library(
"BME68x Sensor library",
None,
"https://github.com/boschsensortec/Bosch-BME68x-Library#v1.3.40408",
)
cg.add_library(
"BSEC2 Software Library",
None,
f"https://github.com/boschsensortec/Bosch-BSEC2-Library.git#{BSEC2_LIBRARY_VERSION}",
)
cg.add_define("USE_BSEC2")
+1
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]))
+17 -26
View File
@@ -106,30 +106,6 @@ void IRAM_ATTR CC1101Component::gpio_intr(CC1101Component *arg) { arg->enable_lo
void CC1101Component::setup() {
this->spi_setup();
if (this->gdo0_pin_ != nullptr) {
this->gdo0_pin_->setup();
}
this->configure();
if (this->is_failed()) {
return;
}
// Defer pin mode setup until after all components have completed setup()
// This handles the case where remote_transmitter runs after CC1101 and changes pin mode
if (this->gdo0_pin_ != nullptr) {
this->defer([this]() {
this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT);
if (this->state_.PKT_FORMAT == static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO)) {
this->gdo0_pin_->attach_interrupt(&CC1101Component::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE);
}
});
}
}
void CC1101Component::configure() {
// Manual reset sequence per CC1101 datasheet section 19.1.2
this->cs_->digital_write(true);
delayMicroseconds(1);
this->cs_->digital_write(false);
@@ -152,6 +128,11 @@ void CC1101Component::configure() {
return;
}
// Setup GDO0 pin if configured
if (this->gdo0_pin_ != nullptr) {
this->gdo0_pin_->setup();
}
this->initialized_ = true;
for (uint8_t i = 0; i <= static_cast<uint8_t>(Register::TEST0); i++) {
@@ -161,11 +142,21 @@ void CC1101Component::configure() {
this->write_(static_cast<Register>(i));
}
this->set_output_power(this->output_power_requested_);
if (!this->enter_rx_()) {
this->mark_failed();
return;
}
// Defer pin mode setup until after all components have completed setup()
// This handles the case where remote_transmitter runs after CC1101 and changes pin mode
if (this->gdo0_pin_ != nullptr) {
this->defer([this]() {
this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT);
if (this->state_.PKT_FORMAT == static_cast<uint8_t>(PacketFormat::PACKET_FORMAT_FIFO)) {
this->gdo0_pin_->attach_interrupt(&CC1101Component::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE);
}
});
}
}
void CC1101Component::call_listeners_(const std::vector<uint8_t> &packet, float freq_offset, float rssi, uint8_t lqi) {
@@ -282,7 +273,7 @@ void CC1101Component::begin_rx() {
void CC1101Component::reset() {
this->strobe_(Command::RES);
this->configure();
this->setup();
}
void CC1101Component::set_idle() {
-1
View File
@@ -25,7 +25,6 @@ class CC1101Component : public Component,
void setup() override;
void loop() override;
void dump_config() override;
void configure();
// Actions
void begin_tx();
@@ -43,11 +43,3 @@ wave_4_26.extend(
},
},
)
ssd1677.extend(
"waveshare-3.97in",
width=800,
height=480,
mirror_x=True,
)
+10 -34
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):
-16
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,
+2 -7
View File
@@ -61,9 +61,6 @@ uint32_t arch_get_cpu_freq_hz() {
}
TaskHandle_t loop_task_handle = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static StaticTask_t loop_task_tcb; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static StackType_t
loop_task_stack[ESPHOME_LOOP_TASK_STACK_SIZE]; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
void loop_task(void *pv_params) {
setup();
@@ -76,11 +73,9 @@ extern "C" void app_main() {
initArduino();
esp32::setup_preferences();
#if CONFIG_FREERTOS_UNICORE
loop_task_handle = xTaskCreateStatic(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, loop_task_stack,
&loop_task_tcb);
xTaskCreate(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, &loop_task_handle);
#else
loop_task_handle = xTaskCreateStaticPinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1,
loop_task_stack, &loop_task_tcb, 1);
xTaskCreatePinnedToCore(loop_task, "loopTask", ESPHOME_LOOP_TASK_STACK_SIZE, nullptr, 1, &loop_task_handle, 1);
#endif
}
+8 -4
View File
@@ -4,6 +4,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <nvs_flash.h>
#include <cinttypes>
#include <cstring>
#include <vector>
@@ -11,6 +12,9 @@ namespace esphome::esp32 {
static const char *const TAG = "preferences";
// Buffer size for converting uint32_t to string: max "4294967295" (10 chars) + null terminator + 1 padding
static constexpr size_t KEY_BUFFER_SIZE = 12;
struct NVSData {
uint32_t key;
SmallInlineBuffer<8> data; // Most prefs fit in 8 bytes (covers fan, cover, select, etc.)
@@ -47,8 +51,8 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) {
}
}
char key_str[UINT32_MAX_STR_SIZE];
uint32_to_str(key_str, this->key);
char key_str[KEY_BUFFER_SIZE];
snprintf(key_str, sizeof(key_str), "%" PRIu32, this->key);
size_t actual_len;
esp_err_t err = nvs_get_blob(this->nvs_handle, key_str, nullptr, &actual_len);
if (err != 0) {
@@ -104,8 +108,8 @@ bool ESP32Preferences::sync() {
uint32_t last_key = 0;
for (const auto &save : s_pending_save) {
char key_str[UINT32_MAX_STR_SIZE];
uint32_to_str(key_str, save.key);
char key_str[KEY_BUFFER_SIZE];
snprintf(key_str, sizeof(key_str), "%" PRIu32, save.key);
ESP_LOGVV(TAG, "Checking if NVS data %s has changed", key_str);
if (this->is_changed_(this->nvs_handle, save, key_str)) {
esp_err_t err = nvs_set_blob(this->nvs_handle, key_str, save.data.data(), save.data.size());
+1 -6
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
+1 -1
View File
@@ -127,6 +127,6 @@ async def to_code(config):
cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE]))
cg.add_build_flag("-Wno-error=overloaded-virtual")
cg.add_library("tonia/HeatpumpIR", "1.0.41")
cg.add_library("tonia/HeatpumpIR", "1.0.40")
if CORE.is_libretiny or CORE.is_esp32:
CORE.add_platformio_option("lib_ignore", ["IRremoteESP8266"])
+11 -10
View File
@@ -360,8 +360,8 @@ void LD2410Component::handle_periodic_data_() {
*/
#ifdef USE_SENSOR
SAFE_PUBLISH_SENSOR(this->moving_target_distance_sensor_,
encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW]));
SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY]);
encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW]))
SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY])
SAFE_PUBLISH_SENSOR(this->still_target_distance_sensor_,
encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW]));
SAFE_PUBLISH_SENSOR(this->still_target_energy_sensor_, this->buffer_data_[STILL_ENERGY]);
@@ -375,26 +375,26 @@ void LD2410Component::handle_periodic_data_() {
Moving energy: 20~28th bytes
*/
for (uint8_t i = 0; i < TOTAL_GATES; i++) {
SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i]);
SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i])
}
/*
Still energy: 29~37th bytes
*/
for (uint8_t i = 0; i < TOTAL_GATES; i++) {
SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i]);
SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i])
}
/*
Light sensor: 38th bytes
*/
SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR]);
SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR])
} else {
for (auto &gate_move_sensor : this->gate_move_sensors_) {
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor);
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor)
}
for (auto &gate_still_sensor : this->gate_still_sensors_) {
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor);
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor)
}
SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_);
SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_)
}
#endif
#ifdef USE_BINARY_SENSOR
@@ -786,12 +786,13 @@ void LD2410Component::set_light_out_control() {
}
#ifdef USE_SENSOR
// These could leak memory, but they are only set once prior to 'setup()' and should never be used again.
void LD2410Component::set_gate_move_sensor(uint8_t gate, sensor::Sensor *s) {
this->gate_move_sensors_[gate].set_sensor(s);
this->gate_move_sensors_[gate] = new SensorWithDedup<uint8_t>(s);
}
void LD2410Component::set_gate_still_sensor(uint8_t gate, sensor::Sensor *s) {
this->gate_still_sensors_[gate].set_sensor(s);
this->gate_still_sensors_[gate] = new SensorWithDedup<uint8_t>(s);
}
#endif
+2 -2
View File
@@ -129,8 +129,8 @@ class LD2410Component : public Component, public uart::UARTDevice {
std::array<number::Number *, TOTAL_GATES> gate_still_threshold_numbers_{};
#endif
#ifdef USE_SENSOR
std::array<SensorWithDedup<uint8_t>, TOTAL_GATES> gate_move_sensors_{};
std::array<SensorWithDedup<uint8_t>, TOTAL_GATES> gate_still_sensors_{};
std::array<SensorWithDedup<uint8_t> *, TOTAL_GATES> gate_move_sensors_{};
std::array<SensorWithDedup<uint8_t> *, TOTAL_GATES> gate_still_sensors_{};
#endif
};
+15 -14
View File
@@ -397,12 +397,12 @@ void LD2412Component::handle_periodic_data_() {
*/
#ifdef USE_SENSOR
SAFE_PUBLISH_SENSOR(this->moving_target_distance_sensor_,
encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW]));
SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY]);
encode_uint16(this->buffer_data_[MOVING_TARGET_HIGH], this->buffer_data_[MOVING_TARGET_LOW]))
SAFE_PUBLISH_SENSOR(this->moving_target_energy_sensor_, this->buffer_data_[MOVING_ENERGY])
SAFE_PUBLISH_SENSOR(this->still_target_distance_sensor_,
encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW]));
SAFE_PUBLISH_SENSOR(this->still_target_energy_sensor_, this->buffer_data_[STILL_ENERGY]);
if (this->detection_distance_sensor_.has_sensor()) {
encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW]))
SAFE_PUBLISH_SENSOR(this->still_target_energy_sensor_, this->buffer_data_[STILL_ENERGY])
if (this->detection_distance_sensor_ != nullptr) {
int new_detect_distance = 0;
if (target_state != 0x00 && (target_state & MOVE_BITMASK)) {
new_detect_distance =
@@ -410,7 +410,7 @@ void LD2412Component::handle_periodic_data_() {
} else if (target_state != 0x00) {
new_detect_distance = encode_uint16(this->buffer_data_[STILL_TARGET_HIGH], this->buffer_data_[STILL_TARGET_LOW]);
}
this->detection_distance_sensor_.publish_state_if_not_dup(new_detect_distance);
this->detection_distance_sensor_->publish_state_if_not_dup(new_detect_distance);
}
if (engineering_mode) {
// Engineering mode needs at least LIGHT_SENSOR + 1 bytes
@@ -423,27 +423,27 @@ void LD2412Component::handle_periodic_data_() {
Moving energy: 20~28th bytes
*/
for (uint8_t i = 0; i < TOTAL_GATES; i++) {
SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i]);
SAFE_PUBLISH_SENSOR(this->gate_move_sensors_[i], this->buffer_data_[MOVING_SENSOR_START + i])
}
/*
Still energy: 29~37th bytes
*/
for (uint8_t i = 0; i < TOTAL_GATES; i++) {
SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i]);
SAFE_PUBLISH_SENSOR(this->gate_still_sensors_[i], this->buffer_data_[STILL_SENSOR_START + i])
}
/*
Light sensor value
*/
SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR]);
SAFE_PUBLISH_SENSOR(this->light_sensor_, this->buffer_data_[LIGHT_SENSOR])
}
} else {
for (auto &gate_move_sensor : this->gate_move_sensors_) {
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor);
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_move_sensor)
}
for (auto &gate_still_sensor : this->gate_still_sensors_) {
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor);
SAFE_PUBLISH_SENSOR_UNKNOWN(gate_still_sensor)
}
SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_);
SAFE_PUBLISH_SENSOR_UNKNOWN(this->light_sensor_)
}
#endif
// the radar module won't tell us when it's done, so we just have to keep polling...
@@ -846,11 +846,12 @@ void LD2412Component::set_light_out_control() {
}
#ifdef USE_SENSOR
// These could leak memory, but they are only set once prior to 'setup()' and should never be used again.
void LD2412Component::set_gate_move_sensor(uint8_t gate, sensor::Sensor *s) {
this->gate_move_sensors_[gate].set_sensor(s);
this->gate_move_sensors_[gate] = new SensorWithDedup<uint8_t>(s);
}
void LD2412Component::set_gate_still_sensor(uint8_t gate, sensor::Sensor *s) {
this->gate_still_sensors_[gate].set_sensor(s);
this->gate_still_sensors_[gate] = new SensorWithDedup<uint8_t>(s);
}
#endif
+2 -2
View File
@@ -133,8 +133,8 @@ class LD2412Component : public Component, public uart::UARTDevice {
std::array<number::Number *, TOTAL_GATES> gate_still_threshold_numbers_{};
#endif
#ifdef USE_SENSOR
std::array<SensorWithDedup<uint8_t>, TOTAL_GATES> gate_move_sensors_{};
std::array<SensorWithDedup<uint8_t>, TOTAL_GATES> gate_still_sensors_{};
std::array<SensorWithDedup<uint8_t> *, TOTAL_GATES> gate_move_sensors_{};
std::array<SensorWithDedup<uint8_t> *, TOTAL_GATES> gate_still_sensors_{};
#endif
};
+10 -10
View File
@@ -565,7 +565,6 @@ void LD2450Component::handle_periodic_data_() {
SAFE_PUBLISH_SENSOR(this->still_target_count_sensor_, still_target_count);
// Moving Target Count
SAFE_PUBLISH_SENSOR(this->moving_target_count_sensor_, moving_target_count);
#endif
#ifdef USE_BINARY_SENSOR
@@ -873,32 +872,33 @@ void LD2450Component::query_target_tracking_mode_() { this->send_command_(CMD_QU
void LD2450Component::query_zone_() { this->send_command_(CMD_QUERY_ZONE, nullptr, 0); }
#ifdef USE_SENSOR
// These could leak memory, but they are only set once prior to 'setup()' and should never be used again.
void LD2450Component::set_move_x_sensor(uint8_t target, sensor::Sensor *s) {
this->move_x_sensors_[target].set_sensor(s);
this->move_x_sensors_[target] = new SensorWithDedup<int16_t>(s);
}
void LD2450Component::set_move_y_sensor(uint8_t target, sensor::Sensor *s) {
this->move_y_sensors_[target].set_sensor(s);
this->move_y_sensors_[target] = new SensorWithDedup<int16_t>(s);
}
void LD2450Component::set_move_speed_sensor(uint8_t target, sensor::Sensor *s) {
this->move_speed_sensors_[target].set_sensor(s);
this->move_speed_sensors_[target] = new SensorWithDedup<int16_t>(s);
}
void LD2450Component::set_move_angle_sensor(uint8_t target, sensor::Sensor *s) {
this->move_angle_sensors_[target].set_sensor(s);
this->move_angle_sensors_[target] = new SensorWithDedup<float>(s);
}
void LD2450Component::set_move_distance_sensor(uint8_t target, sensor::Sensor *s) {
this->move_distance_sensors_[target].set_sensor(s);
this->move_distance_sensors_[target] = new SensorWithDedup<uint16_t>(s);
}
void LD2450Component::set_move_resolution_sensor(uint8_t target, sensor::Sensor *s) {
this->move_resolution_sensors_[target].set_sensor(s);
this->move_resolution_sensors_[target] = new SensorWithDedup<uint16_t>(s);
}
void LD2450Component::set_zone_target_count_sensor(uint8_t zone, sensor::Sensor *s) {
this->zone_target_count_sensors_[zone].set_sensor(s);
this->zone_target_count_sensors_[zone] = new SensorWithDedup<uint8_t>(s);
}
void LD2450Component::set_zone_still_target_count_sensor(uint8_t zone, sensor::Sensor *s) {
this->zone_still_target_count_sensors_[zone].set_sensor(s);
this->zone_still_target_count_sensors_[zone] = new SensorWithDedup<uint8_t>(s);
}
void LD2450Component::set_zone_moving_target_count_sensor(uint8_t zone, sensor::Sensor *s) {
this->zone_moving_target_count_sensors_[zone].set_sensor(s);
this->zone_moving_target_count_sensors_[zone] = new SensorWithDedup<uint8_t>(s);
}
#endif
#ifdef USE_TEXT_SENSOR
+9 -9
View File
@@ -182,15 +182,15 @@ class LD2450Component : public Component, public uart::UARTDevice {
ZoneOfNumbers zone_numbers_[MAX_ZONES];
#endif
#ifdef USE_SENSOR
std::array<SensorWithDedup<int16_t>, MAX_TARGETS> move_x_sensors_{};
std::array<SensorWithDedup<int16_t>, MAX_TARGETS> move_y_sensors_{};
std::array<SensorWithDedup<int16_t>, MAX_TARGETS> move_speed_sensors_{};
std::array<SensorWithDedup<float>, MAX_TARGETS> move_angle_sensors_{};
std::array<SensorWithDedup<uint16_t>, MAX_TARGETS> move_distance_sensors_{};
std::array<SensorWithDedup<uint16_t>, MAX_TARGETS> move_resolution_sensors_{};
std::array<SensorWithDedup<uint8_t>, MAX_ZONES> zone_target_count_sensors_{};
std::array<SensorWithDedup<uint8_t>, MAX_ZONES> zone_still_target_count_sensors_{};
std::array<SensorWithDedup<uint8_t>, MAX_ZONES> zone_moving_target_count_sensors_{};
std::array<SensorWithDedup<int16_t> *, MAX_TARGETS> move_x_sensors_{};
std::array<SensorWithDedup<int16_t> *, MAX_TARGETS> move_y_sensors_{};
std::array<SensorWithDedup<int16_t> *, MAX_TARGETS> move_speed_sensors_{};
std::array<SensorWithDedup<float> *, MAX_TARGETS> move_angle_sensors_{};
std::array<SensorWithDedup<uint16_t> *, MAX_TARGETS> move_distance_sensors_{};
std::array<SensorWithDedup<uint16_t> *, MAX_TARGETS> move_resolution_sensors_{};
std::array<SensorWithDedup<uint8_t> *, MAX_ZONES> zone_target_count_sensors_{};
std::array<SensorWithDedup<uint8_t> *, MAX_ZONES> zone_still_target_count_sensors_{};
std::array<SensorWithDedup<uint8_t> *, MAX_ZONES> zone_moving_target_count_sensors_{};
#endif
#ifdef USE_TEXT_SENSOR
std::array<text_sensor::TextSensor *, MAX_TARGETS> direction_text_sensors_{};
+22 -22
View File
@@ -11,20 +11,28 @@
#define SUB_SENSOR_WITH_DEDUP(name, dedup_type) \
protected: \
ld24xx::SensorWithDedup<dedup_type> name##_sensor_{}; \
ld24xx::SensorWithDedup<dedup_type> *name##_sensor_{nullptr}; \
\
public: \
void set_##name##_sensor(sensor::Sensor *sensor) { this->name##_sensor_.set_sensor(sensor); }
void set_##name##_sensor(sensor::Sensor *sensor) { \
this->name##_sensor_ = new ld24xx::SensorWithDedup<dedup_type>(sensor); \
}
#endif
#define LOG_SENSOR_WITH_DEDUP_SAFE(tag, name, sensor) \
if ((sensor).has_sensor()) { \
LOG_SENSOR(tag, name, (sensor).get_sensor()); \
if ((sensor) != nullptr) { \
LOG_SENSOR(tag, name, (sensor)->sens); \
}
#define SAFE_PUBLISH_SENSOR(sensor, value) (sensor).publish_state_if_not_dup(value)
#define SAFE_PUBLISH_SENSOR(sensor, value) \
if ((sensor) != nullptr) { \
(sensor)->publish_state_if_not_dup(value); \
}
#define SAFE_PUBLISH_SENSOR_UNKNOWN(sensor) (sensor).publish_state_unknown()
#define SAFE_PUBLISH_SENSOR_UNKNOWN(sensor) \
if ((sensor) != nullptr) { \
(sensor)->publish_state_unknown(); \
}
#define highbyte(val) (uint8_t)((val) >> 8)
#define lowbyte(val) (uint8_t)((val) &0xff)
@@ -62,33 +70,25 @@ inline void format_version_str(const uint8_t *version, std::span<char, 20> buffe
}
#ifdef USE_SENSOR
/// Sensor with deduplication — sensor may be null, null check is internal.
/// Stored inline, no heap allocation. Does nothing when no sensor is set.
// Helper class to store a sensor with a deduplicator & publish state only when the value changes
template<typename T> class SensorWithDedup {
public:
void set_sensor(sensor::Sensor *sens) {
this->sens_ = sens;
this->dedup_ = {};
}
SensorWithDedup(sensor::Sensor *sens) : sens(sens) {}
void publish_state_if_not_dup(T state) {
if (this->sens_ != nullptr && this->dedup_.next(state)) {
this->sens_->publish_state(static_cast<float>(state));
if (this->publish_dedup.next(state)) {
this->sens->publish_state(static_cast<float>(state));
}
}
void publish_state_unknown() {
if (this->sens_ != nullptr && this->dedup_.next_unknown()) {
this->sens_->publish_state(NAN);
if (this->publish_dedup.next_unknown()) {
this->sens->publish_state(NAN);
}
}
bool has_sensor() const { return this->sens_ != nullptr; }
sensor::Sensor *get_sensor() const { return this->sens_; }
protected:
sensor::Sensor *sens_{nullptr};
Deduplicator<T> dedup_;
sensor::Sensor *sens;
Deduplicator<T> publish_dedup;
};
#endif
} // namespace esphome::ld24xx
+8 -4
View File
@@ -3,6 +3,7 @@
#include "preferences.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cinttypes>
#include <cstring>
#include <vector>
@@ -10,6 +11,9 @@ namespace esphome::libretiny {
static const char *const TAG = "preferences";
// Buffer size for converting uint32_t to string: max "4294967295" (10 chars) + null terminator + 1 padding
static constexpr size_t KEY_BUFFER_SIZE = 12;
struct NVSData {
uint32_t key;
SmallInlineBuffer<8> data; // Most prefs fit in 8 bytes (covers fan, cover, select, etc.)
@@ -46,8 +50,8 @@ bool LibreTinyPreferenceBackend::load(uint8_t *data, size_t len) {
}
}
char key_str[UINT32_MAX_STR_SIZE];
uint32_to_str(key_str, this->key);
char key_str[KEY_BUFFER_SIZE];
snprintf(key_str, sizeof(key_str), "%" PRIu32, this->key);
fdb_blob_make(this->blob, data, len);
size_t actual_len = fdb_kv_get_blob(this->db, key_str, this->blob);
if (actual_len != len) {
@@ -88,8 +92,8 @@ bool LibreTinyPreferences::sync() {
uint32_t last_key = 0;
for (const auto &save : s_pending_save) {
char key_str[UINT32_MAX_STR_SIZE];
uint32_to_str(key_str, save.key);
char key_str[KEY_BUFFER_SIZE];
snprintf(key_str, sizeof(key_str), "%" PRIu32, save.key);
ESP_LOGVV(TAG, "Checking if FDB data %s has changed", key_str);
if (this->is_changed_(&this->db, save, key_str)) {
ESP_LOGV(TAG, "sync: key: %s, len: %zu", key_str, save.data.size());
@@ -22,20 +22,4 @@ uint8_t ESPColorCorrection::gamma_uncorrect_(uint8_t value) const {
return (target - a <= b - target) ? lo : lo + 1;
}
Color ESPColorCorrection::color_uncorrect(Color color) const {
// uncorrected = corrected^(1/gamma) / (max_brightness * local_brightness)
return Color(this->color_uncorrect_red(color.red), this->color_uncorrect_green(color.green),
this->color_uncorrect_blue(color.blue), this->color_uncorrect_white(color.white));
}
uint8_t ESPColorCorrection::color_uncorrect_channel_(uint8_t value, uint8_t max_brightness) const {
if (max_brightness == 0 || this->local_brightness_ == 0)
return 0;
// Use 32-bit intermediates: when max_brightness and local_brightness_ are small but non-zero,
// (uncorrected / max_brightness) * 255 can exceed 65535 before the std::min(255) clamp runs.
uint32_t uncorrected = this->gamma_uncorrect_(value) * 255UL;
uint32_t res = ((uncorrected / max_brightness) * 255UL) / this->local_brightness_;
return static_cast<uint8_t>(std::min(res, uint32_t(255)));
}
} // namespace esphome::light
@@ -46,18 +46,38 @@ class ESPColorCorrection {
uint8_t res = esp_scale8_twice(white, this->max_brightness_.white, this->local_brightness_);
return this->gamma_correct_(res);
}
Color color_uncorrect(Color color) const;
inline Color color_uncorrect(Color color) const ESPHOME_ALWAYS_INLINE {
// uncorrected = corrected^(1/gamma) / (max_brightness * local_brightness)
return Color(this->color_uncorrect_red(color.red), this->color_uncorrect_green(color.green),
this->color_uncorrect_blue(color.blue), this->color_uncorrect_white(color.white));
}
inline uint8_t color_uncorrect_red(uint8_t red) const ESPHOME_ALWAYS_INLINE {
return this->color_uncorrect_channel_(red, this->max_brightness_.red);
if (this->max_brightness_.red == 0 || this->local_brightness_ == 0)
return 0;
uint16_t uncorrected = this->gamma_uncorrect_(red) * 255UL;
uint16_t res = ((uncorrected / this->max_brightness_.red) * 255UL) / this->local_brightness_;
return (uint8_t) std::min(res, uint16_t(255));
}
inline uint8_t color_uncorrect_green(uint8_t green) const ESPHOME_ALWAYS_INLINE {
return this->color_uncorrect_channel_(green, this->max_brightness_.green);
if (this->max_brightness_.green == 0 || this->local_brightness_ == 0)
return 0;
uint16_t uncorrected = this->gamma_uncorrect_(green) * 255UL;
uint16_t res = ((uncorrected / this->max_brightness_.green) * 255UL) / this->local_brightness_;
return (uint8_t) std::min(res, uint16_t(255));
}
inline uint8_t color_uncorrect_blue(uint8_t blue) const ESPHOME_ALWAYS_INLINE {
return this->color_uncorrect_channel_(blue, this->max_brightness_.blue);
if (this->max_brightness_.blue == 0 || this->local_brightness_ == 0)
return 0;
uint16_t uncorrected = this->gamma_uncorrect_(blue) * 255UL;
uint16_t res = ((uncorrected / this->max_brightness_.blue) * 255UL) / this->local_brightness_;
return (uint8_t) std::min(res, uint16_t(255));
}
inline uint8_t color_uncorrect_white(uint8_t white) const ESPHOME_ALWAYS_INLINE {
return this->color_uncorrect_channel_(white, this->max_brightness_.white);
if (this->max_brightness_.white == 0 || this->local_brightness_ == 0)
return 0;
uint16_t uncorrected = this->gamma_uncorrect_(white) * 255UL;
uint16_t res = ((uncorrected / this->max_brightness_.white) * 255UL) / this->local_brightness_;
return (uint8_t) std::min(res, uint16_t(255));
}
protected:
@@ -65,9 +85,6 @@ class ESPColorCorrection {
uint8_t gamma_correct_(uint8_t value) const;
/// Reverse gamma: binary search the forward PROGMEM table
uint8_t gamma_uncorrect_(uint8_t value) const;
/// Shared body of color_uncorrect_{red,green,blue,white}. Kept out-of-line
/// to avoid duplicating two 16-bit divides at every call site.
uint8_t color_uncorrect_channel_(uint8_t value, uint8_t max_brightness) const;
const uint16_t *gamma_table_{nullptr};
Color max_brightness_{255, 255, 255, 255};
+1 -1
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")
+1 -1
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")
@@ -452,7 +452,7 @@ async def to_code(config):
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")
esp32.add_idf_component(name="espressif/esp-nn", ref="1.2.1")
cg.add_build_flag("-DTF_LITE_STATIC_MEMORY")
cg.add_build_flag("-DTF_LITE_DISABLE_X86_NEON")
+1 -2
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) {
+4 -4
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;
+9 -38
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(
+1 -1
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) {
@@ -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());
+1
View File
@@ -99,6 +99,7 @@ int main() {
setup();
while (true) {
loop();
esphome::yield();
}
return 0;
}
+109 -48
View File
@@ -85,12 +85,8 @@ void Application::setup() {
if (component->can_proceed())
continue;
// Force the status LED to blink WARNING while we wait for a slow
// component to come up. Cleared after setup() finishes if no real
// component has warning set.
this->app_state_ |= STATUS_LED_WARNING;
do {
uint8_t new_app_state = STATUS_LED_WARNING;
uint32_t now = millis();
// Process pending loop enables to handle GPIO interrupts during setup
@@ -100,26 +96,17 @@ void Application::setup() {
// Update loop_component_start_time_ right before calling each component
this->loop_component_start_time_ = millis();
this->components_[j]->call();
new_app_state |= this->components_[j]->get_component_state();
this->app_state_ |= new_app_state;
this->feed_wdt();
}
this->after_loop_tasks_();
this->app_state_ = new_app_state;
yield();
} while (!component->can_proceed() && !component->is_failed());
}
// Setup is complete. Reconcile STATUS_LED_WARNING: the slow-setup path
// above may have forced it on, and any status_clear_warning() calls
// from components during setup were intentional no-ops (gated by
// APP_STATE_SETUP_COMPLETE). Walk components once here to pick up the
// real state. STATUS_LED_ERROR is never artificially forced, so its
// clear path always works and needs no reconciliation. Finally, set
// APP_STATE_SETUP_COMPLETE so subsequent warning clears go through
// the normal walk-and-clear path.
if (!this->any_component_has_status_flag_(STATUS_LED_WARNING))
this->app_state_ &= ~STATUS_LED_WARNING;
this->app_state_ |= APP_STATE_SETUP_COMPLETE;
ESP_LOGI(TAG, "setup() finished successfully!");
#ifdef USE_SETUP_PRIORITY_OVERRIDE
@@ -209,40 +196,21 @@ void Application::process_dump_config_() {
this->dump_config_at_++;
}
void Application::feed_wdt() {
// Cold entry: callers without a millis() timestamp in hand. Fetches the
// time and takes the same rate-limit path as feed_wdt_with_time().
uint32_t now = millis();
if (now - this->last_wdt_feed_ > WDT_FEED_INTERVAL_MS) {
this->feed_wdt_slow_(now);
}
}
void HOT Application::feed_wdt_slow_(uint32_t time) {
// Callers (both feed_wdt() and feed_wdt_with_time()) have already
// confirmed the WDT_FEED_INTERVAL_MS rate limit was exceeded.
arch_feed_wdt();
this->last_wdt_feed_ = time;
void HOT Application::feed_wdt(uint32_t time) {
static uint32_t last_feed = 0;
// Use provided time if available, otherwise get current time
uint32_t now = time ? time : millis();
// Compare in milliseconds (3ms threshold)
if (now - last_feed > 3) {
arch_feed_wdt();
last_feed = now;
#ifdef USE_STATUS_LED
if (status_led::global_status_led != nullptr) {
status_led::global_status_led->call();
}
if (status_led::global_status_led != nullptr) {
status_led::global_status_led->call();
}
#endif
}
bool Application::any_component_has_status_flag_(uint8_t flag) const {
// Walk all components (not just looping ones) so non-looping components'
// status bits are respected. Only called from the slow-path clear helpers
// (status_clear_warning_slow_path_ / status_clear_error_slow_path_) on an
// actual set→clear transition, so walking O(N) here is paid once per
// transition — not once per loop iteration.
for (auto *component : this->components_) {
if ((component->get_component_state() & flag) != 0)
return true;
}
return false;
}
void Application::reboot() {
ESP_LOGI(TAG, "Forcing a reboot");
for (auto &component : std::ranges::reverse_view(this->components_)) {
@@ -331,7 +299,7 @@ void Application::teardown_components(uint32_t timeout_ms) {
while (pending_count > 0 && (now - start_time) < timeout_ms) {
// Feed watchdog during teardown to prevent triggering
this->feed_wdt_with_time(now);
this->feed_wdt(now);
// Process components and compact the array, keeping only those still pending
size_t still_pending = 0;
@@ -482,6 +450,99 @@ void Application::enable_pending_loops_() {
}
#ifdef USE_LWIP_FAST_SELECT
std::atomic<uint32_t> Application::fast_select_scan_total_{0};
std::atomic<uint32_t> Application::fast_select_scan_found_data_{0};
std::atomic<uint32_t> Application::fast_select_scan_load_bearing_{0};
std::atomic<uint32_t> Application::fast_select_scan_load_bearing_race_{0};
std::atomic<uint32_t> Application::fast_select_scan_load_bearing_micro_{0};
std::atomic<uint32_t> Application::fast_select_scan_load_bearing_stall_{0};
void Application::log_fast_select_scan_stats_() {
uint32_t total = fast_select_scan_total_.load(std::memory_order_relaxed);
uint32_t found = fast_select_scan_found_data_.load(std::memory_order_relaxed);
uint32_t load_bearing = fast_select_scan_load_bearing_.load(std::memory_order_relaxed);
uint32_t lb_race = fast_select_scan_load_bearing_race_.load(std::memory_order_relaxed);
uint32_t lb_micro = fast_select_scan_load_bearing_micro_.load(std::memory_order_relaxed);
uint32_t lb_stall = fast_select_scan_load_bearing_stall_.load(std::memory_order_relaxed);
ESP_LOGD(TAG,
"fast_select scan: total=%" PRIu32 " found_data=%" PRIu32 " load_bearing=%" PRIu32 " (race<10us=%" PRIu32
" micro<100us=%" PRIu32 " stall>100us=%" PRIu32 ")",
total, found, load_bearing, lb_race, lb_micro, lb_stall);
}
void Application::note_fast_select_load_bearing_(struct lwip_sock *sock, uint32_t delay_ms) {
uint32_t load_bearing = fast_select_scan_load_bearing_.fetch_add(1, std::memory_order_relaxed) + 1;
// Spin-poll the task notification value for a short bounded window to measure how long
// the counterfactual ulTaskNotifyTake would actually have blocked. This distinguishes
// three cases:
// race (<10µs) — notification arrived within ~10µs of scan start: callback-ordering
// race between the lwip event_callback writing rcvevent and calling
// xTaskNotifyGive a few instructions later. Scan is noise.
// micro (<100µs) — notification arrived within 100µs: still noise at loop_interval scale.
// stall (≥100µs) — notification did not arrive within our polling window. This is the
// only case where the scan could be rescuing a real latency spike.
// Cap the spin at 100µs so that if we're wrong and this IS a real stall, we only add
// 100µs of extra work to that one unlucky loop iteration.
uint32_t t_start = micros();
uint32_t gap_us = UINT32_MAX;
while (true) {
if (ulTaskNotifyValueClear(nullptr, 0) != 0) {
gap_us = micros() - t_start;
break;
}
uint32_t elapsed = micros() - t_start;
if (elapsed >= 100) {
break;
}
}
const char *bucket;
if (gap_us == UINT32_MAX) {
fast_select_scan_load_bearing_stall_.fetch_add(1, std::memory_order_relaxed);
bucket = "STALL";
} else if (gap_us < 10) {
fast_select_scan_load_bearing_race_.fetch_add(1, std::memory_order_relaxed);
bucket = "race";
} else {
fast_select_scan_load_bearing_micro_.fetch_add(1, std::memory_order_relaxed);
bucket = "micro";
}
// Find the socket's index in monitored_sockets_ for easier correlation with registration order.
int index = -1;
for (size_t i = 0; i < this->monitored_sockets_.size(); i++) {
if (this->monitored_sockets_[i] == sock) {
index = static_cast<int>(i);
break;
}
}
// Read the rcvevent value directly. This is the same offset-based read used by
// esphome_lwip_socket_has_data(); value > 0 means unread data is queued.
int16_t rcvevent =
*reinterpret_cast<volatile int16_t *>(reinterpret_cast<char *>(sock) + ESPHOME_LWIP_SOCK_RCVEVENT_OFFSET);
// Count how many other sockets also had data at this scan (could reveal whether it's always
// the same socket or a burst across multiple).
size_t sockets_with_data = 0;
for (struct lwip_sock *s : this->monitored_sockets_) {
if (esphome_lwip_socket_has_data(s))
sockets_with_data++;
}
if (gap_us == UINT32_MAX) {
ESP_LOGW(TAG,
"fast_select LOAD-BEARING #%" PRIu32 " [%s]: sock=%p idx=%d/%u rcvevent=%d delay_ms=%" PRIu32
" sockets_with_data=%u gap_us=>100",
load_bearing, bucket, sock, index, static_cast<unsigned>(this->monitored_sockets_.size()), rcvevent,
delay_ms, static_cast<unsigned>(sockets_with_data));
} else {
ESP_LOGW(TAG,
"fast_select LOAD-BEARING #%" PRIu32 " [%s]: sock=%p idx=%d/%u rcvevent=%d delay_ms=%" PRIu32
" sockets_with_data=%u gap_us=%" PRIu32,
load_bearing, bucket, sock, index, static_cast<unsigned>(this->monitored_sockets_.size()), rcvevent,
delay_ms, static_cast<unsigned>(sockets_with_data), gap_us);
}
}
bool Application::register_socket(struct lwip_sock *sock) {
// It modifies monitored_sockets_ without locking — must only be called from the main loop.
if (sock == nullptr)
+63 -47
View File
@@ -1,6 +1,7 @@
#pragma once
#include <algorithm>
#include <atomic>
#include <ctime>
#include <limits>
#include <span>
@@ -385,24 +386,7 @@ class Application {
void schedule_dump_config() { this->dump_config_at_ = 0; }
/// Minimum interval between real arch_feed_wdt() calls. Chosen to keep the
/// rate of HAL pokes low while still being small enough that any plausible
/// watchdog timeout (seconds) has orders of magnitude of safety margin.
static constexpr uint32_t WDT_FEED_INTERVAL_MS = 3;
/// Feed the task watchdog. Cold entry — callers without a millis()
/// timestamp in hand. Out of line to keep call sites tiny.
void feed_wdt();
/// Feed the task watchdog, hot entry. Callers that already have a
/// millis() timestamp pay only a load + sub + branch on the common
/// (no-op) path. The actual arch feed + status LED update live in
/// feed_wdt_slow_.
void ESPHOME_ALWAYS_INLINE feed_wdt_with_time(uint32_t time) {
if (static_cast<uint32_t>(time - this->last_wdt_feed_) > WDT_FEED_INTERVAL_MS) [[unlikely]] {
this->feed_wdt_slow_(time);
}
}
void feed_wdt(uint32_t time = 0);
void reboot();
@@ -418,18 +402,7 @@ class Application {
*/
void teardown_components(uint32_t timeout_ms);
/// Return the public app state status bits (STATUS_LED_* only).
/// Internal bookkeeping bits like APP_STATE_SETUP_COMPLETE are masked
/// out so external readers (status_led components, etc.) never see them.
uint8_t get_app_state() const { return this->app_state_ & ~APP_STATE_SETUP_COMPLETE; }
/// True once Application::setup() has finished walking all components
/// and finalized the initial status flags. Before this point, the
/// slow-setup busy-wait may be forcing STATUS_LED_WARNING on, and
/// status_clear_* intentionally skips its walk-and-clear step so the
/// forced bit doesn't get wiped. Stored as a free bit on app_state_
/// (bit 6) to avoid costing additional RAM.
bool is_setup_complete() const { return (this->app_state_ & APP_STATE_SETUP_COMPLETE) != 0; }
uint8_t get_app_state() const { return this->app_state_; }
// Helper macro for entity getter method declarations
#ifdef USE_DEVICES
@@ -605,12 +578,6 @@ class Application {
bool is_socket_ready_(int fd) const { return FD_ISSET(fd, &this->read_fds_); }
#endif
/// Walk all registered components looking for any whose component_state_
/// has the given flag set. Used by Component::status_clear_*_slow_path_()
/// (which is a friend) to decide whether to clear the corresponding bit on
/// this->app_state_ (the app-wide "any component has this status" indicator).
bool any_component_has_status_flag_(uint8_t flag) const;
/// Register a component, detecting loop() override at compile time.
/// Uses HasLoopOverride<T> which handles ambiguous &T::loop from multiple inheritance.
template<typename T> void register_component_(T *comp) {
@@ -649,10 +616,7 @@ class Application {
/// Caller must ensure dump_config_at_ < components_.size().
void __attribute__((noinline)) process_dump_config_();
/// Slow path for feed_wdt(): actually calls arch_feed_wdt(), updates
/// last_wdt_feed_, and re-dispatches the status LED. Out of line so the
/// inline wrapper stays tiny.
void feed_wdt_slow_(uint32_t time);
void feed_wdt_arch_();
/// Perform a delay while also monitoring socket file descriptors for readiness
#ifdef USE_HOST
@@ -692,6 +656,25 @@ class Application {
FixedVector<Component *> looping_components_{};
#ifdef USE_LWIP_FAST_SELECT
std::vector<struct lwip_sock *> monitored_sockets_; // Cached lwip_sock pointers for direct rcvevent read
// Stats to verify whether the pre-sleep socket scan in yield_with_select_() is ever load-bearing.
// If fast_select_scan_load_bearing_ stays 0 under real workloads, the scan can be removed.
// These are static because yield_with_select_() is inlined at every call site.
static std::atomic<uint32_t> fast_select_scan_total_;
static std::atomic<uint32_t> fast_select_scan_found_data_;
// Umbrella counter: pre-scan notify peek was 0 and scan found data.
// Broken down into three buckets based on the post-scan spin-poll result:
// _race_ — notify arrived in < 10µs (callback-ordering race, scan is noise)
// _micro_ — notify arrived in 10..100µs (still noise at loop_interval scale)
// _stall_ — notify did not arrive within 100µs (the only case that could be a real stall)
// If _stall_ stays 0, the scan is provably irrelevant under this workload.
static std::atomic<uint32_t> fast_select_scan_load_bearing_;
static std::atomic<uint32_t> fast_select_scan_load_bearing_race_;
static std::atomic<uint32_t> fast_select_scan_load_bearing_micro_;
static std::atomic<uint32_t> fast_select_scan_load_bearing_stall_;
uint32_t fast_select_scan_stats_last_log_{0};
void log_fast_select_scan_stats_();
// Non-inline, called only on the rare load-bearing event so the hot path stays unchanged.
void note_fast_select_load_bearing_(struct lwip_sock *sock, uint32_t delay_ms);
#elif defined(USE_HOST)
std::vector<int> socket_fds_; // Vector of all monitored socket file descriptors
#endif
@@ -706,7 +689,6 @@ class Application {
// 4-byte members
uint32_t last_loop_{0};
uint32_t loop_component_start_time_{0};
uint32_t last_wdt_feed_{0}; // millis() of most recent arch_feed_wdt(); rate-limits feed_wdt() hot path
#ifdef USE_HOST
int max_fd_{-1}; // Highest file descriptor number for select()
@@ -851,13 +833,12 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_
this->drain_wake_notifications_();
#endif
// Process scheduled tasks. Scheduler::call now feeds the watchdog itself
// after each scheduled item that actually runs, so we no longer need an
// unconditional feed here — when Scheduler::call has no work to do, the
// only elapsed time is a sleep wake + a few instructions, and when it does
// have work, it fed the wdt as it went.
// Process scheduled tasks
this->scheduler.call(loop_start_time);
// Feed the watchdog timer
this->feed_wdt(loop_start_time);
// Process any pending enable_loop requests from ISRs
// This must be done before marking in_loop_ = true to avoid race conditions
if (this->has_pending_enable_loop_requests_) {
@@ -877,6 +858,8 @@ inline void ESPHOME_ALWAYS_INLINE Application::before_loop_tasks_(uint32_t loop_
}
inline void ESPHOME_ALWAYS_INLINE Application::loop() {
uint8_t new_app_state = 0;
// Get the initial loop time at the start
uint32_t last_op_end_time = millis();
@@ -896,10 +879,13 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
// Use the finish method to get the current time as the end time
last_op_end_time = guard.finish();
}
this->feed_wdt_with_time(last_op_end_time);
new_app_state |= component->get_component_state();
this->app_state_ |= new_app_state;
this->feed_wdt(last_op_end_time);
}
this->after_loop_tasks_();
this->app_state_ = new_app_state;
#ifdef USE_RUNTIME_STATS
// Process any pending runtime stats printing after all components have run
@@ -923,6 +909,14 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
this->yield_with_select_(delay_time);
this->last_loop_ = last_op_end_time;
#ifdef USE_LWIP_FAST_SELECT
// Periodic fast-select scan stats (debug). Remove once the scan is proven unneeded.
if (last_op_end_time - this->fast_select_scan_stats_last_log_ >= 30000) {
this->fast_select_scan_stats_last_log_ = last_op_end_time;
this->log_fast_select_scan_stats_();
}
#endif
if (this->dump_config_at_ < this->components_.size()) {
this->process_dump_config_();
}
@@ -943,8 +937,30 @@ inline void ESPHOME_ALWAYS_INLINE Application::yield_with_select_(uint32_t delay
// If a socket still has unread data (rcvevent > 0) but the task notification was already
// consumed, ulTaskNotifyTake would block until timeout — adding up to delay_ms latency.
// This scan preserves select() semantics: return immediately when any fd is ready.
//
// Debug stats: peek the task notification value BEFORE scanning. This answers the
// counterfactual "if the scan did not exist and we called ulTaskNotifyTake right now,
// would it stall?". ulTaskNotifyValueClear(nullptr, 0) is a pure read — it returns the
// current value and clears zero bits, leaving the notification state untouched. Reading
// before the loop (rather than after finding data) makes the answer TOCTOU-free: the
// value we compare against is the value at the moment Take would have been called.
// LibreTiny's FreeRTOS port predates ulTaskNotifyValueClear (added in FreeRTOS 10.4.0),
// so we fall back to a pessimistic 0, which makes load_bearing an upper bound == found_data
// on that platform. Zero there is still a valid proof that the scan is unused.
#ifdef USE_ESP32
uint32_t fast_select_notify_value_before_scan = ulTaskNotifyValueClear(nullptr, 0);
#else
uint32_t fast_select_notify_value_before_scan = 0;
#endif
fast_select_scan_total_.fetch_add(1, std::memory_order_relaxed);
for (struct lwip_sock *sock : this->monitored_sockets_) {
if (esphome_lwip_socket_has_data(sock)) {
fast_select_scan_found_data_.fetch_add(1, std::memory_order_relaxed);
if (fast_select_notify_value_before_scan == 0) {
// Scan was load-bearing: no notification pending, so Take would have stalled.
// Delegate to a non-inline helper so the hot path stays the same size.
this->note_fast_select_load_bearing_(sock, delay_ms);
}
yield();
return;
}
-13
View File
@@ -411,23 +411,10 @@ void Component::status_set_error(const LogString *message) {
}
void Component::status_clear_warning_slow_path_() {
this->component_state_ &= ~STATUS_LED_WARNING;
// Clear the app-wide STATUS_LED_WARNING bit only if setup has finished
// AND no other component still has it set. During setup the forced
// STATUS_LED_WARNING (from the slow-setup busy-wait) must not be wiped
// by a transient component clear — Application::setup() reconciles
// the warning bit once at the end before setting APP_STATE_SETUP_COMPLETE.
// The set path is unchanged (set_status_flag_ still writes directly).
if (App.is_setup_complete() && !App.any_component_has_status_flag_(STATUS_LED_WARNING))
App.app_state_ &= ~STATUS_LED_WARNING;
ESP_LOGW(TAG, "%s cleared Warning flag", LOG_STR_ARG(this->get_component_log_str()));
}
void Component::status_clear_error_slow_path_() {
this->component_state_ &= ~STATUS_LED_ERROR;
// STATUS_LED_ERROR is never artificially forced — it only ever lands
// in app_state_ via a real set_status_flag_ call. So the walk-and-clear
// path is always safe, including during setup.
if (!App.any_component_has_status_flag_(STATUS_LED_ERROR))
App.app_state_ &= ~STATUS_LED_ERROR;
ESP_LOGE(TAG, "%s cleared Error flag", LOG_STR_ARG(this->get_component_log_str()));
}
void Component::status_momentary_warning(const char *name, uint32_t length) {
-5
View File
@@ -89,11 +89,6 @@ inline constexpr uint8_t STATUS_LED_WARNING = 0x08;
inline constexpr uint8_t STATUS_LED_ERROR = 0x10;
// Component loop override flag uses bit 5 (set at registration time)
inline constexpr uint8_t COMPONENT_HAS_LOOP = 0x20;
// Bit 6 on Application::app_state_ (ONLY) — set at the end of
// Application::setup(). Component::status_clear_*_slow_path_() uses this to
// decide whether to propagate clears to App.app_state_. Never set on a
// Component's component_state_.
inline constexpr uint8_t APP_STATE_SETUP_COMPLETE = 0x40;
// Remove before 2026.8.0
enum class RetryResult { DONE, RETRY };
+7 -24
View File
@@ -347,18 +347,17 @@ std::string format_mac_address_pretty(const uint8_t *mac) {
return std::string(buf);
}
// Internal helper for hex formatting - base is 'a' for lowercase or 'A' for uppercase.
// When separator is set, it is written unconditionally after each byte and the last
// one is overwritten with '\0', eliminating the per-byte `i < length - 1` check.
// Internal helper for hex formatting - base is 'a' for lowercase or 'A' for uppercase
static char *format_hex_internal(char *buffer, size_t buffer_size, const uint8_t *data, size_t length, char separator,
char base) {
if (length == 0 || buffer_size == 0) {
if (buffer_size > 0)
buffer[0] = '\0';
if (length == 0) {
buffer[0] = '\0';
return buffer;
}
// With separator: total length is 3*length (2*length hex chars, (length-1) separators, 1 null terminator)
// Without separator: total length is 2*length + 1 (2*length hex chars, 1 null terminator)
uint8_t stride = separator ? 3 : 2;
size_t max_bytes = separator ? (buffer_size / 3) : ((buffer_size - 1) / 2);
size_t max_bytes = separator ? (buffer_size / stride) : ((buffer_size - 1) / stride);
if (max_bytes == 0) {
buffer[0] = '\0';
return buffer;
@@ -370,30 +369,14 @@ static char *format_hex_internal(char *buffer, size_t buffer_size, const uint8_t
size_t pos = i * stride;
buffer[pos] = format_hex_char(data[i] >> 4, base);
buffer[pos + 1] = format_hex_char(data[i] & 0x0F, base);
if (separator) {
if (separator && i < length - 1) {
buffer[pos + 2] = separator;
}
}
// With separator: overwrite last separator with '\0'
// Without: write '\0' after last hex char
buffer[length * stride - (separator ? 1 : 0)] = '\0';
return buffer;
}
char *uint32_to_str_unchecked(char *buf, uint32_t val) {
if (val == 0) {
*buf++ = '0';
return buf;
}
char *start = buf;
while (val > 0) {
*buf++ = '0' + (val % 10);
val /= 10;
}
std::reverse(start, buf);
return buf;
}
char *format_hex_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length) {
return format_hex_internal(buffer, buffer_size, data, length, 0, 'a');
}
+3 -18
View File
@@ -1263,13 +1263,13 @@ constexpr uint8_t parse_hex_char(char c) {
}
/// Convert a nibble (0-15) to hex char with specified base ('a' for lowercase, 'A' for uppercase)
ESPHOME_ALWAYS_INLINE inline char format_hex_char(uint8_t v, char base) { return v >= 10 ? base + (v - 10) : '0' + v; }
inline char format_hex_char(uint8_t v, char base) { return v >= 10 ? base + (v - 10) : '0' + v; }
/// Convert a nibble (0-15) to lowercase hex char
ESPHOME_ALWAYS_INLINE inline char format_hex_char(uint8_t v) { return format_hex_char(v, 'a'); }
inline char format_hex_char(uint8_t v) { return format_hex_char(v, 'a'); }
/// Convert a nibble (0-15) to uppercase hex char (used for pretty printing)
ESPHOME_ALWAYS_INLINE inline char format_hex_pretty_char(uint8_t v) { return format_hex_char(v, 'A'); }
inline char format_hex_pretty_char(uint8_t v) { return format_hex_char(v, 'A'); }
/// Write int8 value to buffer without modulo operations.
/// Buffer must have at least 4 bytes free. Returns pointer past last char written.
@@ -1295,21 +1295,6 @@ inline char *int8_to_str(char *buf, int8_t val) {
return buf;
}
/// Minimum buffer size for uint32_to_str: 10 digits + null terminator.
static constexpr size_t UINT32_MAX_STR_SIZE = 11;
/// Write unsigned 32-bit integer to buffer (internal, no size check).
/// Buffer must have at least 10 bytes free. Returns pointer past last char written.
char *uint32_to_str_unchecked(char *buf, uint32_t val);
/// Write unsigned 32-bit integer to buffer with compile-time size check.
/// Null-terminates the output. Returns number of chars written (excluding null).
inline size_t uint32_to_str(std::span<char, UINT32_MAX_STR_SIZE> buf, uint32_t val) {
char *end = uint32_to_str_unchecked(buf.data(), val);
*end = '\0';
return static_cast<size_t>(end - buf.data());
}
/// Format byte array as lowercase hex to buffer (base implementation).
char *format_hex_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length);
+1 -7
View File
@@ -739,13 +739,7 @@ uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) {
App.set_current_component(item->component);
WarnIfComponentBlockingGuard guard{item->component, now};
item->callback();
uint32_t end = guard.finish();
// Feed the watchdog after each scheduled item (both main heap and defer
// queue paths go through here). A run of back-to-back callbacks cannot
// starve the wdt. The inline fast path is a load + sub + branch — nearly
// free when the 3 ms rate limit hasn't elapsed.
App.feed_wdt_with_time(end);
return end;
return guard.finish();
}
// Common implementation for cancel operations - handles locking
+3 -3
View File
@@ -138,7 +138,7 @@ class Scheduler {
// (single-threaded). This is safe because the main loop is the only thread
// that reads to_add_ without holding lock_; other threads may read it only
// while holding the mutex (e.g. cancel_item_locked_).
inline void ESPHOME_ALWAYS_INLINE HOT process_to_add() {
inline void HOT process_to_add() {
if (this->to_add_empty_())
return;
this->process_to_add_slow_path_();
@@ -302,7 +302,7 @@ class Scheduler {
// loop thread structurally modifies items_ (push/pop/erase). Other threads may
// iterate items_ and mark items removed under lock_, but never change the
// vector's size or data pointer.
inline bool ESPHOME_ALWAYS_INLINE HOT cleanup_() {
inline bool HOT cleanup_() {
if (this->to_remove_empty_())
return !this->items_.empty();
return this->cleanup_slow_path_();
@@ -407,7 +407,7 @@ class Scheduler {
// Process defer queue for FIFO execution of deferred items.
// IMPORTANT: This method should only be called from the main thread (loop task).
// Inlined: the fast path (nothing deferred) is just an atomic load check.
inline void ESPHOME_ALWAYS_INLINE HOT process_defer_queue_(uint32_t &now) {
inline void HOT process_defer_queue_(uint32_t &now) {
// Fast path: nothing to process, avoid lock entirely.
// Worst case is a one-loop-iteration delay before newly deferred items are processed.
if (this->defer_empty_())
+1 -2
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("}")
+1 -3
View File
@@ -3,8 +3,6 @@ dependencies:
version: "7.4.2"
esphome/esp-audio-libs:
version: 2.0.4
esphome/micro-decoder:
version: 0.1.1
esphome/micro-flac:
version: 0.1.1
esphome/micro-opus:
@@ -16,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:
+167 -4
View File
@@ -5,15 +5,157 @@ 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.*",
r"CONFIGURATION: https://docs.platformio.org/.*",
r"DEBUG: Current.*",
r"LDF Modes:.*",
r"LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf.*",
f"Looking for {IGNORE_LIB_WARNINGS} library in registry",
f"Warning! Library `.*'{IGNORE_LIB_WARNINGS}.*` has not been found in PlatformIO Registry.",
f"You can ignore this message, if `.*{IGNORE_LIB_WARNINGS}.*` is a built-in library.*",
r"Scanning dependencies...",
r"Found \d+ compatible libraries",
r"Memory Usage -> https://bit.ly/pio-memory-usage",
r"Found: https://platformio.org/lib/show/.*",
r"Using cache: .*",
r"Installing dependencies",
r"Library Manager: Already installed, built-in library",
r"Building in .* mode",
r"Advanced Memory Usage is available via .*",
r"Merged .* ELF section",
r"esptool.py v.*",
r"esptool v.*",
r"Checking size .*",
r"Retrieving maximum program size .*",
r"PLATFORM: .*",
r"PACKAGES:.*",
r" - framework-arduinoespressif.* \(.*\)",
r" - tool-esptool.* \(.*\)",
r" - toolchain-.* \(.*\)",
r"Creating BIN file .*",
r"Warning! Could not find file \".*.crt\"",
r"Warning! Arduino framework as an ESP-IDF component doesn't handle the `variant` field! The default `esp32` variant will be used.",
r"Warning: DEPRECATED: 'esptool.py' is deprecated. Please use 'esptool' instead. The '.py' suffix will be removed in a future major release.",
r"Warning: esp-idf-size exited with code 2",
r"esp_idf_size: error: unrecognized arguments: --ng",
r"Package configuration completed successfully",
]
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())
@@ -24,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:
-187
View File
@@ -1,187 +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
_IGNORE_LIB_WARNINGS = f"(?:{'|'.join(['Hash', 'Update'])})"
# Regex patterns matched against each line of PlatformIO output. Lines that
# match are dropped by RedirectText before they reach the parent process.
# Patterns are anchored at the start of the line (RedirectText uses
# ``re.match``). Disabled when the user passes ``-v`` / ``--verbose`` to
# ``esphome compile``.
FILTER_PLATFORMIO_LINES = [
r"Verbose mode can be enabled via `-v, --verbose` option.*",
r"CONFIGURATION: https://docs.platformio.org/.*",
r"DEBUG: Current.*",
r"LDF Modes:.*",
r"LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf.*",
f"Looking for {_IGNORE_LIB_WARNINGS} library in registry",
f"Warning! Library `.*'{_IGNORE_LIB_WARNINGS}.*` has not been found in PlatformIO Registry.",
f"You can ignore this message, if `.*{_IGNORE_LIB_WARNINGS}.*` is a built-in library.*",
r"Scanning dependencies...",
r"Found \d+ compatible libraries",
r"Memory Usage -> https://bit.ly/pio-memory-usage",
r"Found: https://platformio.org/lib/show/.*",
r"Using cache: .*",
r"Installing dependencies",
r"Library Manager: Already installed, built-in library",
r"Building in .* mode",
r"Advanced Memory Usage is available via .*",
r"Merged .* ELF section",
r"esptool.py v.*",
r"esptool v.*",
r"Checking size .*",
r"Retrieving maximum program size .*",
r"PLATFORM: .*",
r"PACKAGES:.*",
r" - framework-arduinoespressif.* \(.*\)",
r" - tool-esptool.* \(.*\)",
r" - toolchain-.* \(.*\)",
r"Creating BIN file .*",
r"Warning! Could not find file \".*.crt\"",
r"Warning! Arduino framework as an ESP-IDF component doesn't handle the `variant` field! The default `esp32` variant will be used.",
r"Warning: DEPRECATED: 'esptool.py' is deprecated. Please use 'esptool' instead. The '.py' suffix will be removed in a future major release.",
r"Warning: esp-idf-size exited with code 2",
r"esp_idf_size: error: unrecognized arguments: --ng",
r"Package configuration completed successfully",
]
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.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())
+7 -7
View File
@@ -83,7 +83,7 @@ lib_deps =
fastled/FastLED@3.9.16 ; fastled_base
freekode/TM1651@1.0.1 ; tm1651
dudanov/MideaUART@1.1.9 ; midea
tonia/HeatpumpIR@1.0.41 ; heatpumpir
tonia/HeatpumpIR@1.0.40 ; heatpumpir
build_flags =
${common.build_flags}
-DUSE_ARDUINO
@@ -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,16 +169,16 @@ 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 =
${common:idf.lib_deps}
droscy/esp_wireguard@0.4.4 ; wireguard
kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word
tonia/HeatpumpIR@1.0.41 ; heatpumpir
tonia/HeatpumpIR@1.0.40 ; heatpumpir
build_flags =
${common:idf.build_flags}
-Wno-nonnull-compare
+1
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"]
+2 -2
View File
@@ -12,14 +12,14 @@ platformio==6.1.19
esptool==5.2.0
click==8.3.2
esphome-dashboard==20260408.1
aioesphomeapi==44.15.0
aioesphomeapi==44.13.2
zeroconf==0.148.0
puremagic==1.30
ruamel.yaml==0.19.1 # dashboard_import
ruamel.yaml.clib==0.2.15 # dashboard_import
esphome-glyphsets==0.2.0
pillow==12.2.0
resvg-py==0.3.1
resvg-py==0.3.0
freetype-py==2.5.1
jinja2==3.1.6
bleak==2.1.1
+4 -16
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"
+4 -3
View File
@@ -26,11 +26,12 @@ CORE_BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "core"
STUBS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "stubs"
PLATFORMIO_OPTIONS = {
"build_unflags": [
"-Os", # remove default size-opt
],
"build_flags": [
"-Os", # match firmware optimization level (detects inlining regressions)
"-O2", # optimize for speed (CodSpeed recommends RelWithDebInfo)
"-g", # debug symbols for profiling
"-ffunction-sections", # required for dead-code stripping with -Os
"-fdata-sections", # required for dead-code stripping with -Os
"-DUSE_BENCHMARK", # disable WarnIfComponentBlockingGuard in finish()
f"-I{STUBS_DIR}", # stub headers for ESP32-only components
],
@@ -1,118 +0,0 @@
#include <benchmark/benchmark.h>
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/api/api_buffer.h"
namespace esphome::api::benchmarks {
// Inner iteration count to amortize CodSpeed instrumentation overhead.
static constexpr int kInnerIterations = 2000;
// Typical log line: "[12:34:56][D][sensor:094]: 'Temperature': Sending state 23.50000 with 1 decimals of accuracy"
static constexpr const char *kTypicalLogLine =
"[12:34:56][D][sensor:094]: 'Temperature': Sending state 23.50000 with 1 decimals of accuracy";
// Short log line: "[12:34:56][I][app:029]: Running..."
static constexpr const char *kShortLogLine = "[12:34:56][I][app:029]: Running...";
// --- Encode ---
static void Encode_LogResponse_Typical(benchmark::State &state) {
APIBuffer buffer;
SubscribeLogsResponse msg;
msg.level = enums::LOG_LEVEL_DEBUG;
msg.set_message(reinterpret_cast<const uint8_t *>(kTypicalLogLine), strlen(kTypicalLogLine));
uint32_t size = msg.calculate_size();
buffer.resize(size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
benchmark::DoNotOptimize(buffer.data());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Encode_LogResponse_Typical);
static void Encode_LogResponse_Short(benchmark::State &state) {
APIBuffer buffer;
SubscribeLogsResponse msg;
msg.level = enums::LOG_LEVEL_INFO;
msg.set_message(reinterpret_cast<const uint8_t *>(kShortLogLine), strlen(kShortLogLine));
uint32_t size = msg.calculate_size();
buffer.resize(size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
benchmark::DoNotOptimize(buffer.data());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Encode_LogResponse_Short);
// --- Calculate Size ---
static void CalculateSize_LogResponse_Typical(benchmark::State &state) {
SubscribeLogsResponse msg;
msg.level = enums::LOG_LEVEL_DEBUG;
msg.set_message(reinterpret_cast<const uint8_t *>(kTypicalLogLine), strlen(kTypicalLogLine));
for (auto _ : state) {
uint32_t result = 0;
for (int i = 0; i < kInnerIterations; i++) {
result += msg.calculate_size();
}
benchmark::DoNotOptimize(result);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(CalculateSize_LogResponse_Typical);
// --- Calc + Encode (steady state) ---
static void CalcAndEncode_LogResponse_Typical(benchmark::State &state) {
APIBuffer buffer;
SubscribeLogsResponse msg;
msg.level = enums::LOG_LEVEL_DEBUG;
msg.set_message(reinterpret_cast<const uint8_t *>(kTypicalLogLine), strlen(kTypicalLogLine));
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
uint32_t size = msg.calculate_size();
buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
benchmark::DoNotOptimize(buffer.data());
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(CalcAndEncode_LogResponse_Typical);
// --- Calc + Encode (fresh allocation each time) ---
static void CalcAndEncode_LogResponse_Typical_Fresh(benchmark::State &state) {
SubscribeLogsResponse msg;
msg.level = enums::LOG_LEVEL_DEBUG;
msg.set_message(reinterpret_cast<const uint8_t *>(kTypicalLogLine), strlen(kTypicalLogLine));
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
benchmark::DoNotOptimize(buffer.data());
}
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(CalcAndEncode_LogResponse_Typical_Fresh);
} // namespace esphome::api::benchmarks
-56
View File
@@ -1,6 +1,4 @@
#include <benchmark/benchmark.h>
#include <cinttypes>
#include <cstdio>
#include "esphome/core/helpers.h"
@@ -309,58 +307,4 @@ static void Base64Decode_32Bytes(benchmark::State &state) {
}
BENCHMARK(Base64Decode_32Bytes);
// --- uint32_to_str() vs snprintf ---
static void Uint32ToStr_Small(benchmark::State &state) {
char buf[UINT32_MAX_STR_SIZE];
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
uint32_to_str(buf, 12345);
benchmark::DoNotOptimize(buf);
benchmark::ClobberMemory();
}
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Uint32ToStr_Small);
static void Snprintf_Uint32_Small(benchmark::State &state) {
char buf[UINT32_MAX_STR_SIZE];
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
snprintf(buf, sizeof(buf), "%" PRIu32, static_cast<uint32_t>(12345));
benchmark::DoNotOptimize(buf);
benchmark::ClobberMemory();
}
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Snprintf_Uint32_Small);
static void Uint32ToStr_Large(benchmark::State &state) {
char buf[UINT32_MAX_STR_SIZE];
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
uint32_to_str(buf, 4294967295u);
benchmark::DoNotOptimize(buf);
benchmark::ClobberMemory();
}
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Uint32ToStr_Large);
static void Snprintf_Uint32_Large(benchmark::State &state) {
char buf[UINT32_MAX_STR_SIZE];
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
snprintf(buf, sizeof(buf), "%" PRIu32, static_cast<uint32_t>(4294967295u));
benchmark::DoNotOptimize(buf);
benchmark::ClobberMemory();
}
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Snprintf_Uint32_Large);
} // namespace esphome::benchmarks
+2 -220
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 = {
-7
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};
-120
View File
@@ -1,120 +0,0 @@
#include <gtest/gtest.h>
#include <cstring>
#include "esphome/core/helpers.h"
namespace esphome::core::testing {
// --- format_hex_to() ---
TEST(FormatHexTo, Basic) {
const uint8_t data[] = {0xAB, 0xCD, 0xEF};
char buffer[7]; // 3 * 2 + 1
format_hex_to(buffer, data, 3);
EXPECT_STREQ(buffer, "abcdef");
}
TEST(FormatHexTo, SingleByte) {
const uint8_t data[] = {0x0F};
char buffer[3];
format_hex_to(buffer, data, 1);
EXPECT_STREQ(buffer, "0f");
}
TEST(FormatHexTo, ZeroLength) {
char buffer[4] = "xxx";
format_hex_to(buffer, static_cast<size_t>(sizeof(buffer)), static_cast<const uint8_t *>(nullptr), 0);
EXPECT_STREQ(buffer, "");
}
TEST(FormatHexTo, ZeroBufferSize) {
char buffer[4] = "xxx";
const uint8_t data[] = {0xAB};
format_hex_to(buffer, static_cast<size_t>(0), data, 1);
// Should not crash, buffer unchanged
EXPECT_EQ(buffer[0], 'x');
}
TEST(FormatHexTo, BufferTooSmall) {
const uint8_t data[] = {0xAB, 0xCD, 0xEF};
char buffer[5]; // only room for 2 bytes
format_hex_to(buffer, data, 3);
EXPECT_STREQ(buffer, "abcd");
}
TEST(FormatHexTo, MacAddress) {
const uint8_t mac[] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF};
char buffer[13];
format_hex_to(buffer, mac, 6);
EXPECT_STREQ(buffer, "aabbccddeeff");
}
// --- format_hex_pretty_to() ---
TEST(FormatHexPrettyTo, BasicColon) {
const uint8_t data[] = {0xAB, 0xCD, 0xEF};
char buffer[9]; // 3 * 3
format_hex_pretty_to(buffer, data, 3);
EXPECT_STREQ(buffer, "AB:CD:EF");
}
TEST(FormatHexPrettyTo, SingleByte) {
const uint8_t data[] = {0x0F};
char buffer[3];
format_hex_pretty_to(buffer, data, 1);
EXPECT_STREQ(buffer, "0F");
}
TEST(FormatHexPrettyTo, ZeroLength) {
char buffer[4] = "xxx";
format_hex_pretty_to(buffer, static_cast<size_t>(sizeof(buffer)), static_cast<const uint8_t *>(nullptr), 0);
EXPECT_STREQ(buffer, "");
}
TEST(FormatHexPrettyTo, ZeroBufferSize) {
char buffer[4] = "xxx";
const uint8_t data[] = {0xAB};
format_hex_pretty_to(buffer, static_cast<size_t>(0), data, 1);
EXPECT_EQ(buffer[0], 'x');
}
TEST(FormatHexPrettyTo, CustomSeparator) {
const uint8_t data[] = {0xAA, 0xBB, 0xCC};
char buffer[9];
format_hex_pretty_to(buffer, data, 3, '-');
EXPECT_STREQ(buffer, "AA-BB-CC");
}
// --- format_mac_addr_upper() ---
TEST(FormatMacAddrUpper, Basic) {
const uint8_t mac[] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF};
char buffer[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
format_mac_addr_upper(mac, buffer);
EXPECT_STREQ(buffer, "AA:BB:CC:DD:EE:FF");
}
TEST(FormatMacAddrUpper, AllZeros) {
const uint8_t mac[] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
char buffer[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
format_mac_addr_upper(mac, buffer);
EXPECT_STREQ(buffer, "00:00:00:00:00:00");
}
// --- format_hex_char() ---
TEST(FormatHexChar, LowercaseDigits) {
EXPECT_EQ(format_hex_char(0), '0');
EXPECT_EQ(format_hex_char(9), '9');
EXPECT_EQ(format_hex_char(10), 'a');
EXPECT_EQ(format_hex_char(15), 'f');
}
TEST(FormatHexChar, UppercaseDigits) {
EXPECT_EQ(format_hex_pretty_char(0), '0');
EXPECT_EQ(format_hex_pretty_char(9), '9');
EXPECT_EQ(format_hex_pretty_char(10), 'A');
EXPECT_EQ(format_hex_pretty_char(15), 'F');
}
} // namespace esphome::core::testing
@@ -1,77 +0,0 @@
#include <gtest/gtest.h>
#include "esphome/core/helpers.h"
namespace esphome::core::testing {
// --- uint32_to_str_unchecked() (internal, raw pointer) ---
TEST(Uint32ToStr, InternalZero) {
char buf[UINT32_MAX_STR_SIZE];
char *end = uint32_to_str_unchecked(buf, 0);
*end = '\0';
EXPECT_STREQ(buf, "0");
EXPECT_EQ(end - buf, 1);
}
TEST(Uint32ToStr, InternalSingleDigit) {
char buf[UINT32_MAX_STR_SIZE];
char *end = uint32_to_str_unchecked(buf, 7);
*end = '\0';
EXPECT_STREQ(buf, "7");
}
TEST(Uint32ToStr, InternalMultiDigit) {
char buf[UINT32_MAX_STR_SIZE];
char *end = uint32_to_str_unchecked(buf, 12345);
*end = '\0';
EXPECT_STREQ(buf, "12345");
EXPECT_EQ(end - buf, 5);
}
TEST(Uint32ToStr, InternalMaxValue) {
char buf[UINT32_MAX_STR_SIZE];
char *end = uint32_to_str_unchecked(buf, 4294967295u);
*end = '\0';
EXPECT_STREQ(buf, "4294967295");
EXPECT_EQ(end - buf, 10);
}
TEST(Uint32ToStr, InternalPowersOfTen) {
char buf[UINT32_MAX_STR_SIZE];
char *end;
end = uint32_to_str_unchecked(buf, 10);
*end = '\0';
EXPECT_STREQ(buf, "10");
end = uint32_to_str_unchecked(buf, 100);
*end = '\0';
EXPECT_STREQ(buf, "100");
end = uint32_to_str_unchecked(buf, 1000000);
*end = '\0';
EXPECT_STREQ(buf, "1000000");
}
// --- uint32_to_str() (public, span API) ---
TEST(Uint32ToStr, SpanZero) {
char buf[UINT32_MAX_STR_SIZE];
EXPECT_EQ(uint32_to_str(buf, 0), 1u);
EXPECT_STREQ(buf, "0");
}
TEST(Uint32ToStr, SpanMultiDigit) {
char buf[UINT32_MAX_STR_SIZE];
EXPECT_EQ(uint32_to_str(buf, 12345), 5u);
EXPECT_STREQ(buf, "12345");
}
TEST(Uint32ToStr, SpanMaxValue) {
char buf[UINT32_MAX_STR_SIZE];
EXPECT_EQ(uint32_to_str(buf, 4294967295u), 10u);
EXPECT_STREQ(buf, "4294967295");
}
} // namespace esphome::core::testing
-8
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
-1
View File
@@ -20,7 +20,6 @@ lvgl:
- id: lvgl_0
default_font: space16
displays: sdl0
rotation: 180
top_layer:
- id: lvgl_1
@@ -1,141 +0,0 @@
esphome:
name: status-flags-test
host:
api:
actions:
# Warning flag services for sensor_a
- action: set_warning_a
then:
- lambda: "id(sensor_a)->status_set_warning();"
- component.update: app_warning_bit
- component.update: app_error_bit
- action: clear_warning_a
then:
- lambda: "id(sensor_a)->status_clear_warning();"
- component.update: app_warning_bit
- component.update: app_error_bit
# Warning flag services for sensor_b
- action: set_warning_b
then:
- lambda: "id(sensor_b)->status_set_warning();"
- component.update: app_warning_bit
- component.update: app_error_bit
- action: clear_warning_b
then:
- lambda: "id(sensor_b)->status_clear_warning();"
- component.update: app_warning_bit
- component.update: app_error_bit
# Error flag services for sensor_a
- action: set_error_a
then:
- lambda: "id(sensor_a)->status_set_error();"
- component.update: app_warning_bit
- component.update: app_error_bit
- action: clear_error_a
then:
- lambda: "id(sensor_a)->status_clear_error();"
- component.update: app_warning_bit
- component.update: app_error_bit
# Error flag services for sensor_b
- action: set_error_b
then:
- lambda: "id(sensor_b)->status_set_error();"
- component.update: app_warning_bit
- component.update: app_error_bit
- action: clear_error_b
then:
- lambda: "id(sensor_b)->status_clear_error();"
- component.update: app_warning_bit
- component.update: app_error_bit
# Snapshot of the status_led_light's output state for observation.
- action: snapshot_led
then:
- component.update: status_led_writes
- component.update: status_led_last_state
logger:
# Tracks each write to the fake status_led output.
globals:
- id: status_led_write_count
type: uint32_t
restore_value: no
initial_value: "0"
- id: status_led_last_write
type: bool
restore_value: no
initial_value: "false"
# Fake binary output — status_led_light writes to this instead of a pin.
# Every write bumps a counter and records the last value, both of which
# are exposed below so the test can verify status_led_light's loop is
# actually reading App.get_app_state() and responding.
output:
- platform: template
id: fake_status_led
type: binary
write_action:
- globals.set:
id: status_led_write_count
value: !lambda "return id(status_led_write_count) + 1;"
- globals.set:
id: status_led_last_write
value: !lambda "return state;"
# Actual status_led_light component under test.
light:
- platform: status_led
name: Status LED
id: status_led_light_id
output: fake_status_led
sensor:
# Two components that the test will toggle warning/error flags on.
- platform: template
name: Sensor A
id: sensor_a
update_interval: 24h
lambda: return 1.0;
- platform: template
name: Sensor B
id: sensor_b
update_interval: 24h
lambda: return 2.0;
# Expose App.app_state_'s STATUS_LED_WARNING / STATUS_LED_ERROR bits
# as 0.0 / 1.0. force_update ensures every manual component.update
# publishes even if the value is unchanged.
- platform: template
name: App Warning Bit
id: app_warning_bit
update_interval: 24h
force_update: true
lambda: |-
return (App.get_app_state() & STATUS_LED_WARNING) != 0 ? 1.0 : 0.0;
- platform: template
name: App Error Bit
id: app_error_bit
update_interval: 24h
force_update: true
lambda: |-
return (App.get_app_state() & STATUS_LED_ERROR) != 0 ? 1.0 : 0.0;
# Observables for the fake status_led output.
- platform: template
name: Status LED Writes
id: status_led_writes
update_interval: 24h
force_update: true
lambda: return id(status_led_write_count);
- platform: template
name: Status LED Last State
id: status_led_last_state
update_interval: 24h
force_update: true
lambda: |-
return id(status_led_last_write) ? 1.0 : 0.0;
-209
View File
@@ -1,209 +0,0 @@
"""Integration tests for Component::status_set/clear_warning/error propagation.
Verifies that toggling STATUS_LED_WARNING / STATUS_LED_ERROR on individual
components correctly updates the app-wide bits on Application::app_state_,
AND that the status_led_light component actually responds to those bits
by writing to its output (the full chain from component.status_set_warning
App.app_state_ status_led_light.loop() reading get_app_state()).
Exercises the multi-component OR semantics (the app bit stays set while
any component still has the flag, and only clears when the last component
clears its bit), the independence of warning and error, and the actual
status_led_light read of the bits via a fake template output that counts
writes.
"""
from __future__ import annotations
import asyncio
import pytest
from .state_utils import InitialStateHelper, SensorTracker, build_key_to_entity_mapping
from .types import APIClientConnectedFactory, RunCompiledFunction
# Time to let the host-mode main loop run so status_led_light.loop() can
# execute enough iterations to produce measurable write-count changes on
# the fake template output. 300 ms is well above the minimum needed.
STATUS_LED_SETTLE_S = 0.3
@pytest.mark.asyncio
async def test_status_flags(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
async with run_compiled(yaml_config), api_client_connected() as client:
entities, services = await client.list_entities_services()
# Map every custom API service by name for the test to execute.
svc = {s.name: s for s in services}
for name in (
"set_warning_a",
"clear_warning_a",
"set_warning_b",
"clear_warning_b",
"set_error_a",
"clear_error_a",
"set_error_b",
"clear_error_b",
"snapshot_led",
):
assert name in svc, f"service {name} not registered"
# Track every sensor we care about. SensorTracker gives us
# expect(value) / expect_any() futures that resolve when a
# matching state arrives; much simpler than manual bookkeeping.
tracker = SensorTracker(
[
"app_warning_bit",
"app_error_bit",
"status_led_writes",
"status_led_last_state",
]
)
tracker.key_to_sensor.update(
build_key_to_entity_mapping(entities, list(tracker.sensor_states.keys()))
)
# Swallow initial state broadcasts so the test only reacts to
# state changes triggered by our service calls.
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(tracker.on_state))
try:
await initial_state_helper.wait_for_initial_states()
except TimeoutError:
pytest.fail("Timeout waiting for initial states")
async def call(name: str) -> None:
await client.execute_service(svc[name], {})
async def call_and_expect_bits(
service_name: str, *, warning: float, error: float
) -> None:
"""Execute a service and wait for both app bit sensors to match.
Each bit-toggling service calls component.update on both
app_warning_bit and app_error_bit, so both sensors publish.
"""
futures = tracker.expect_all(
{"app_warning_bit": warning, "app_error_bit": error}
)
await call(service_name)
await tracker.await_all(futures)
async def snapshot_led_writes() -> int:
"""Trigger a publish of the fake status_led output counter and return it."""
future = tracker.expect_any("status_led_writes")
await call("snapshot_led")
await tracker.await_change(future, "status_led_writes")
return int(tracker.sensor_states["status_led_writes"][-1])
# ---- Baseline: everything clean ----
await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0)
# ================================================================
# Part 1 — STATUS_LED_WARNING propagation to App.app_state_
# ================================================================
# Single component set/clear
await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0)
await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0)
# Multi-component OR: both set, clear A, bit stays (B still has it), clear B, gone
await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0)
await call_and_expect_bits("set_warning_b", warning=1.0, error=0.0)
await call_and_expect_bits("clear_warning_a", warning=1.0, error=0.0)
await call_and_expect_bits("clear_warning_b", warning=0.0, error=0.0)
# Opposite clear order
await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0)
await call_and_expect_bits("set_warning_b", warning=1.0, error=0.0)
await call_and_expect_bits("clear_warning_b", warning=1.0, error=0.0)
await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0)
# ================================================================
# Part 2 — STATUS_LED_ERROR propagation (same scenarios)
# ================================================================
await call_and_expect_bits("set_error_a", warning=0.0, error=1.0)
await call_and_expect_bits("clear_error_a", warning=0.0, error=0.0)
await call_and_expect_bits("set_error_a", warning=0.0, error=1.0)
await call_and_expect_bits("set_error_b", warning=0.0, error=1.0)
await call_and_expect_bits("clear_error_a", warning=0.0, error=1.0)
await call_and_expect_bits("clear_error_b", warning=0.0, error=0.0)
# ================================================================
# Part 3 — warning and error are independent
# ================================================================
await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0)
await call_and_expect_bits("set_error_b", warning=1.0, error=1.0)
await call_and_expect_bits("clear_warning_a", warning=0.0, error=1.0)
await call_and_expect_bits("clear_error_b", warning=0.0, error=0.0)
# ================================================================
# Part 4 — status_led_light actually reads App.app_state_
# ================================================================
# The fake status_led_light output increments status_led_write_count
# on every write. status_led_light::loop() writes its output on every
# iteration while an error/warning bit is set, so after holding a
# warning for ~300 ms we should see the counter move significantly.
# This is the end-to-end proof that the bits we set above actually
# reach status_led_light and drive its behavior.
count_before_warning = await snapshot_led_writes()
await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0)
# Let status_led_light's loop run long enough to toggle the pin
# several times (it reads get_app_state() every main loop iteration).
await asyncio.sleep(STATUS_LED_SETTLE_S)
count_after_warning = await snapshot_led_writes()
assert count_after_warning > count_before_warning, (
"status_led_light did not respond to STATUS_LED_WARNING being set: "
f"write count stayed at {count_before_warning}{count_after_warning}. "
"The full chain Component::status_set_warning → App.app_state_ → "
"status_led_light::loop reading get_app_state() is broken."
)
await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0)
# Same check for ERROR
count_before_error = await snapshot_led_writes()
await call_and_expect_bits("set_error_a", warning=0.0, error=1.0)
await asyncio.sleep(STATUS_LED_SETTLE_S)
count_after_error = await snapshot_led_writes()
assert count_after_error > count_before_error, (
"status_led_light did not respond to STATUS_LED_ERROR being set: "
f"write count stayed at {count_before_error}{count_after_error}. "
)
await call_and_expect_bits("clear_error_a", warning=0.0, error=0.0)
# ---- Set → clear → re-set round-trip ----
# After clearing, status_led_light stops writing (steady state).
# Re-setting the flag must make it resume. This guards against a
# future idle optimization (e.g. #15642) where status_led disables
# its own loop when idle: if the re-enable path were broken, the
# second set would not produce writes.
#
# Snapshot AFTER the clear to avoid counting writes that were still
# in-flight from the error-set phase.
count_after_clear = await snapshot_led_writes()
await asyncio.sleep(STATUS_LED_SETTLE_S)
count_after_idle = await snapshot_led_writes()
assert count_after_idle - count_after_clear <= 5, (
"status_led_light kept writing after warning/error was cleared: "
f"count grew from {count_after_clear} to {count_after_idle}. "
"Expected it to stop writing once all status bits were clear."
)
# Re-set warning — writes must resume.
await call_and_expect_bits("set_warning_a", warning=1.0, error=0.0)
await asyncio.sleep(STATUS_LED_SETTLE_S)
count_after_reset = await snapshot_led_writes()
assert count_after_reset > count_after_idle + 5, (
"status_led_light did not resume writing after re-setting "
f"STATUS_LED_WARNING: count went from {count_after_idle} to "
f"{count_after_reset}. If an idle optimization disabled the "
"loop, the re-enable path may be broken."
)
await call_and_expect_bits("clear_warning_a", warning=0.0, error=0.0)
+3 -3
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
@@ -1,3 +0,0 @@
wifi:
password: pkg_password
ssid: main_ssid
@@ -1,4 +0,0 @@
packages: !include 13-packages_list.yaml
wifi:
ssid: main_ssid
@@ -1,2 +0,0 @@
- wifi:
password: pkg_password
@@ -1,3 +0,0 @@
wifi:
password: pkg_password
ssid: main_ssid
@@ -1,4 +0,0 @@
packages: !include 14-packages_dict.yaml
wifi:
ssid: main_ssid
@@ -1,3 +0,0 @@
network:
wifi:
password: pkg_password
-42
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,
+80 -39
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_runner.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