Compare commits

...

41 Commits

Author SHA1 Message Date
J. Nick Koston
1bc6a4bbda [test] Trigger device-builder downstream CI
DO NOT MERGE -- chained test PR to verify the new device-builder CI
job from #16214 actually runs and exercises esphome/device-builder
against the PR's Python code.

Touches a Python file under esphome/ to trip the should_run_device_builder
gate in determine-jobs.py.
2026-05-03 09:12:10 -05:00
J. Nick Koston
520371c4a2 [ci] Address Copilot review on device-builder gate
- Skip the device-builder downstream job on beta/release target
  branches. Those branches lag behind device-builder@main, so a
  newer device-builder API requirement would falsely fail the run
  without reflecting any problem in the PR itself. Mirrors the
  same skip detect_memory_impact_config already does.
- Broaden the trigger to any non-C++ file under esphome/. The
  package ships data files via include-package-data = true (e.g.
  esphome/idf_component.yml, dashboard templates, JSON), so a
  Python-only filter under-fires for changes that still affect
  what device-builder installs.

Tests cover both: per-file behavior (with the skip mocked off) and
the beta/release skip itself short-circuiting before changed_files
is even consulted.
2026-05-03 09:12:04 -05:00
J. Nick Koston
ed00f5f36b [ci] Use uv to install device-builder + esphome in downstream job
Mirrors esphome/device-builder#192: switch from pip to
astral-sh/setup-uv + uv pip install --system, and run pytest
with -n auto under pytest-xdist. uv is an order of magnitude
faster on cold boots, and -n auto matches the install shape
device-builder's own CI now uses.
2026-05-03 09:08:54 -05:00
J. Nick Koston
365d93f01b [ci] Run downstream device-builder tests against PR Python code
Add a CI job that checks out esphome/device-builder@main, installs it,
overlays the PR's esphome via `pip install -e ./esphome`, and runs its
pytest suite. Gated by determine-jobs.py: only runs when Python files
under esphome/ change or runtime deps (requirements.txt, pyproject.toml)
change -- the surface device-builder consumes when it imports esphome.

This catches downstream breakage at PR time instead of after release,
mirroring the yarl -> aiohttp pattern.
2026-05-03 08:49:59 -05:00
Edward Firmo
8046ff7e1e [nextion] TFT upload no longer fails when the display sends a split 0x08 ack (#16205)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-05-03 03:40:09 -05:00
dependabot[bot]
5e9db1c8c6 Bump github/codeql-action from 4.35.2 to 4.35.3 (#16201)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-01 21:46:29 -05:00
J. Nick Koston
81d147ff9e [esp32] Drop printf wrap on IDF 6.0+ (picolibc no longer needs it) (#16189) 2026-05-01 14:31:56 -05:00
Mat931
58cb7effd4 [ota] Add extended OTA protocol (#16164)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-05-01 10:40:14 -05:00
Kevin Ahrendt
3dd60c5713 [core] Support allocating ring buffer in internal memory (#16187) 2026-05-01 07:55:08 -05:00
Oliver Kleinecke
f073c1cabe [usb_host][usb_uart] Add configurable max packet size (#14584) 2026-05-01 20:43:13 +10:00
J. Nick Koston
5cc447e0da [core] Move per-platform hal_platform.h into components/platform/hal.h (#16183) 2026-04-30 21:27:31 -05:00
Clyde Stubbs
0980630f68 [lvgl] Clamp values for meter line indicators (#16180) 2026-04-30 22:23:14 -04:00
J. Nick Koston
b8dfffdf06 [core] Enable ruff FLY (flynt) lint family (#16182) 2026-04-30 21:20:07 -05:00
luar123
f6e39d305d [zigbee] Add newlib compatibility for zigbee sdk in idf 6 (#16174) 2026-04-30 22:08:55 -04:00
Jonathan Swoboda
08e5cb5576 [esp32_hosted] Bump esp_hosted to 2.12.6 and esp_wifi_remote to 1.5.1 (#16176) 2026-04-30 20:47:22 -05:00
Kevin Ahrendt
faa61696e0 [sendspin] Use sendspin-cpp to v0.4.0 to reduce stuttering (#16178) 2026-04-30 20:43:24 -05:00
J. Nick Koston
9999913d07 [zephyr] Move HAL bodies into components/zephyr/hal.cpp + inline trivial dispatches (#16116) 2026-04-30 20:10:51 -05:00
J. Nick Koston
92aa98f680 [host] Move HAL bodies into components/host/hal.cpp + inline trivial dispatches (#16115) 2026-05-01 00:42:38 +00:00
J. Nick Koston
3d69169141 [climate] Fold ControlAction fields into a single stateless lambda (#16044) 2026-04-30 19:16:16 -05:00
J. Nick Koston
24fdfcf1a1 [rp2040] Move HAL bodies into components/rp2040/hal.cpp + inline trivial dispatches (#16114) 2026-04-30 19:15:41 -05:00
J. Nick Koston
550444dc34 [binary_sensor] Drop Component from filter classes, use self-keyed scheduler (#16131) 2026-04-30 19:15:18 -05:00
J. Nick Koston
ba7c06785a [mdns] Broadcast config_hash TXT record on _esphomelib._tcp (#16145) 2026-04-30 19:14:55 -05:00
J. Nick Koston
b708d1a826 [core] Drop unused DELAY_ACTION from InternalSchedulerID enum (#16151) 2026-04-30 19:14:34 -05:00
J. Nick Koston
148d478dec [api] Add encode/decode benchmarks for Z-Wave, IR/RF, and serial proxy messages (#16157) 2026-04-30 19:14:20 -05:00
J. Nick Koston
45e78e4114 [core] Inline loop gate expression to avoid stale local reuse (#16167) 2026-04-30 19:13:54 -05:00
J. Nick Koston
3b3e003aa3 [sensor] Pack ThrottleAverageFilter have_nan_ into n_ bitfield (-4 B/instance) (#16169) 2026-04-30 19:13:10 -05:00
J. Nick Koston
2f3e16b482 [bk72xx] Apply CFG_SUPPORT_BLE=0 SDK option to BK7238 (#16181) 2026-04-30 19:12:06 -05:00
J. Nick Koston
e085cb50d9 [sensor] Drop Component from filter classes, use self-keyed scheduler (#16132) 2026-04-30 19:11:30 -05:00
J. Nick Koston
2fbfb4c385 [ci] Split integration tests into 3 buckets when count is more than 10 (#16152) 2026-04-30 19:10:53 -05:00
J. Nick Koston
61261b4a59 [libretiny] Move HAL bodies into components/libretiny/hal.cpp + inline trivial dispatches (#16113) 2026-04-30 12:33:22 -05:00
J. Nick Koston
d48aad8c4d [esp32] Replace 512B stack buffer in printf wraps with picolibc cookie FILE (#16170) 2026-04-30 13:27:54 -04:00
Kevin Ahrendt
f1d3be4bda [core] Simplify RAMAllocator and add internal fallback to external mode (#16171) 2026-04-30 12:03:40 -04:00
Kevin Ahrendt
2758aa5517 [audio] bump microOpus to v0.4.0 to use fixed-point by default on ESP32 (#16168) 2026-04-30 09:12:39 -04:00
Kevin Ahrendt
a8b0133ec1 [audio] Enable specific codecs and configure advanced features (#16166) 2026-04-30 08:49:28 -04:00
Clyde Stubbs
1398dcebb4 [st7789v] Add deprecation warnings (#16162) 2026-04-30 00:53:37 -05:00
dependabot[bot]
096d0c4279 Bump aioesphomeapi from 44.22.0 to 44.23.0 (#16161)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-30 04:45:19 +00:00
Jesse Hills
e127268dac [core] Strip \\?\ prefix from sys.executable for PlatformIO subprocess (#16158) 2026-04-30 16:04:52 +12:00
J. Nick Koston
f0bffed3c0 [esp8266] Move HAL bodies into components/esp8266/hal.cpp + inline arch_init (#16112) 2026-04-30 15:42:17 +12:00
Jesse Hills
1a871e231d [ci] Use client-id for GitHub App token generation (#16155) 2026-04-30 13:09:37 +12:00
Jesse Hills
47765bd2d0 [ci] Correct version comment on create-github-app-token pin (#16156) 2026-04-30 13:08:56 +12:00
dependabot[bot]
8066325e0b Bump esphome/workflows/.github/workflows/lock.yml from 2026.4.0 to 2026.4.1 (#16143)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-30 12:52:25 +12:00
87 changed files with 2700 additions and 772 deletions

View File

@@ -27,9 +27,9 @@ jobs:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v2
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
- name: Auto Label PR

View File

@@ -136,6 +136,53 @@ jobs:
if-no-files-found: ignore
retention-days: 14
device-builder:
name: Test downstream esphome/device-builder
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.device-builder == 'true'
steps:
- name: Check out esphome (this PR)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
path: esphome
- name: Check out esphome/device-builder
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: esphome/device-builder
ref: main
path: device-builder
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.13"
- name: Set up uv
# Mirrors the install shape device-builder's own CI uses
# (esphome/device-builder#192): uv replaces pip for the
# install step (order-of-magnitude faster on cold boots,
# with its own wheel cache). actions/setup-python still
# provides the interpreter.
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
- name: Install device-builder + esphome from PR
# Install device-builder with its esphome + test extras
# first so its pinned versions of pytest/etc. land, then
# overlay the PR's esphome so the downstream tests run
# against this PR's Python code. ``--system`` installs into
# the runner's Python instead of a venv.
run: |
uv pip install --system -e './device-builder[esphome,test]'
uv pip install --system -e ./esphome
- name: Run device-builder pytest
# ``-n auto`` runs under pytest-xdist (matches device-builder's
# own CI). No ``--cov`` here -- this is purely a downstream
# smoke check against this PR's esphome code.
working-directory: device-builder
run: pytest -q -n auto --maxfail=5 --durations=10 --no-cov --ignore=tests/benchmarks
pytest:
name: Run pytest
strategy:
@@ -199,12 +246,12 @@ jobs:
- common
outputs:
integration-tests: ${{ steps.determine.outputs.integration-tests }}
integration-tests-run-all: ${{ steps.determine.outputs.integration-tests-run-all }}
integration-test-files: ${{ steps.determine.outputs.integration-test-files }}
integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }}
clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }}
python-linters: ${{ steps.determine.outputs.python-linters }}
import-time: ${{ steps.determine.outputs.import-time }}
device-builder: ${{ steps.determine.outputs.device-builder }}
changed-components: ${{ steps.determine.outputs.changed-components }}
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }}
@@ -243,12 +290,12 @@ jobs:
# Extract individual fields
echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT
echo "integration-tests-run-all=$(echo "$output" | jq -r '.integration_tests_run_all')" >> $GITHUB_OUTPUT
echo "integration-test-files=$(echo "$output" | jq -c '.integration_test_files')" >> $GITHUB_OUTPUT
echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT
echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT
echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT
echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT
echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT
echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT
echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT
echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT
echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT
@@ -267,12 +314,16 @@ jobs:
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
integration-tests:
name: Run integration tests
name: Run integration tests (${{ matrix.bucket.name }})
runs-on: ubuntu-latest
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.integration-tests == 'true'
strategy:
fail-fast: false
matrix:
bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -299,19 +350,14 @@ jobs:
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
- name: Run integration tests
env:
INTEGRATION_TEST_FILES: ${{ needs.determine-jobs.outputs.integration-test-files }}
INTEGRATION_TESTS_RUN_ALL: ${{ needs.determine-jobs.outputs.integration-tests-run-all }}
# JSON array of test paths; parsed into a bash array below to avoid
# shell word-splitting / glob hazards.
BUCKET_TESTS: ${{ toJson(matrix.bucket.tests) }}
run: |
. venv/bin/activate
if [[ "$INTEGRATION_TESTS_RUN_ALL" == "true" ]]; then
echo "Running all integration tests"
pytest -vv --no-cov --tb=native -n auto tests/integration/
else
# Parse JSON array into bash array to avoid shell expansion issues
mapfile -t test_files < <(echo "$INTEGRATION_TEST_FILES" | jq -r '.[]')
echo "Running ${#test_files[@]} specific integration tests"
pytest -vv --no-cov --tb=native -n auto "${test_files[@]}"
fi
mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]')
echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests"
pytest -vv --no-cov --tb=native -n auto "${test_files[@]}"
cpp-unit-tests:
name: Run C++ unit tests
@@ -1066,6 +1112,7 @@ jobs:
- clang-tidy-nosplit
- clang-tidy-split
- determine-jobs
- device-builder
- test-build-components-split
- pre-commit-ci-lite
- memory-impact-target-branch

View File

@@ -58,7 +58,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -86,6 +86,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
with:
category: "/language:${{matrix.language}}"

View File

@@ -8,4 +8,4 @@ on:
jobs:
lock:
uses: esphome/workflows/.github/workflows/lock.yml@3c4e8446aa1029f1c346a482034b3ee1489077ca # 2026.4.0
uses: esphome/workflows/.github/workflows/lock.yml@025a1e6255610c498ed590403b7e510b69e474df # 2026.4.1

View File

@@ -223,7 +223,7 @@ jobs:
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
owner: esphome
repositories: home-assistant-addon
@@ -258,7 +258,7 @@ jobs:
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
owner: esphome
repositories: esphome-schema
@@ -289,7 +289,7 @@ jobs:
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
owner: esphome
repositories: version-notifier

View File

@@ -1125,15 +1125,16 @@ def upload_program(
remote_port = int(ota_conf[CONF_PORT])
password = ota_conf.get(CONF_PASSWORD)
if getattr(args, "file", None) is not None:
binary = Path(args.file)
else:
binary = CORE.firmware_bin
# Resolve MQTT magic strings to actual IP addresses
network_devices = _resolve_network_devices(devices, config, args)
return espota2.run_ota(network_devices, remote_port, password, binary)
binary = CORE.firmware_bin
ota_type = espota2.OTA_TYPE_UPDATE_APP
if getattr(args, "file", None) is not None:
binary = Path(args.file)
return espota2.run_ota(network_devices, remote_port, password, binary, ota_type)
def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None:

View File

@@ -1,4 +1,4 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
import esphome.codegen as cg
from esphome.components.esp32 import (
@@ -7,7 +7,12 @@ from esphome.components.esp32 import (
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.const import (
CONF_BITS_PER_SAMPLE,
CONF_NUM_CHANNELS,
CONF_SAMPLE_RATE,
CONF_SIZE,
)
from esphome.core import CORE
import esphome.final_validate as fv
@@ -25,13 +30,46 @@ AUDIO_FILE_TYPE_ENUM = {
"OPUS": AudioFileType.OPUS,
}
MEMORY_PSRAM = "psram"
MEMORY_INTERNAL = "internal"
MEMORY_LOCATIONS = [MEMORY_PSRAM, MEMORY_INTERNAL]
@dataclass
class FlacOptions:
buffer_memory: str | None = None
@dataclass
class Mp3Options:
buffer_memory: str | None = None
@dataclass
class OpusPseudostackOptions:
threadsafe: bool | None = None
buffer_memory: str | None = None
size: int | None = None
@dataclass
class OpusOptions:
floating_point: bool | None = None
state_memory: str | None = None
pseudostack: OpusPseudostackOptions = field(default_factory=OpusPseudostackOptions)
@dataclass
class AudioData:
flac_support: bool = False
mp3_support: bool = False
opus_support: bool = False
# WAV defaults to True for backward compatibility; will become opt-in in a future release
wav_support: bool = True
micro_decoder_support: bool = False
flac: FlacOptions = field(default_factory=FlacOptions)
mp3: Mp3Options = field(default_factory=Mp3Options)
opus: OpusOptions = field(default_factory=OpusOptions)
def _get_data() -> AudioData:
@@ -55,6 +93,11 @@ def request_opus_support() -> None:
_get_data().opus_support = True
def request_wav_support() -> None:
"""Request WAV codec support for audio decoding."""
_get_data().wav_support = True
def request_micro_decoder_support() -> None:
"""Request micro-decoder library support for audio decoding."""
_get_data().micro_decoder_support = True
@@ -67,9 +110,78 @@ CONF_MAX_CHANNELS = "max_channels"
CONF_MIN_SAMPLE_RATE = "min_sample_rate"
CONF_MAX_SAMPLE_RATE = "max_sample_rate"
CONF_CODECS = "codecs"
CONF_WAV = "wav"
CONF_FLAC = "flac"
CONF_MP3 = "mp3"
CONF_OPUS = "opus"
CONF_BUFFER_MEMORY = "buffer_memory"
CONF_FLOATING_POINT = "floating_point"
CONF_STATE_MEMORY = "state_memory"
CONF_PSEUDOSTACK = "pseudostack"
CONF_THREADSAFE = "threadsafe"
_MEMORY_LOCATION_VALIDATOR = cv.one_of(*MEMORY_LOCATIONS, lower=True)
def _maybe_empty_codec(schema):
"""Wrap a codec dict schema so that a bare key (None value) is treated as an empty dict."""
def validator(value):
if value is None:
value = {}
return schema(value)
return validator
CODEC_FLAC_SCHEMA = cv.Schema(
{
cv.Optional(CONF_BUFFER_MEMORY): _MEMORY_LOCATION_VALIDATOR,
}
)
CODEC_MP3_SCHEMA = cv.Schema(
{
cv.Optional(CONF_BUFFER_MEMORY): _MEMORY_LOCATION_VALIDATOR,
}
)
OPUS_PSEUDOSTACK_SCHEMA = cv.Schema(
{
cv.Optional(CONF_THREADSAFE): cv.boolean,
cv.Optional(CONF_BUFFER_MEMORY): _MEMORY_LOCATION_VALIDATOR,
cv.Optional(CONF_SIZE): cv.int_range(60000, 240000),
}
)
CODEC_OPUS_SCHEMA = cv.Schema(
{
cv.Optional(CONF_FLOATING_POINT): cv.boolean,
cv.Optional(CONF_STATE_MEMORY): _MEMORY_LOCATION_VALIDATOR,
cv.Optional(CONF_PSEUDOSTACK): _maybe_empty_codec(OPUS_PSEUDOSTACK_SCHEMA),
}
)
CODEC_WAV_SCHEMA = cv.Schema({})
CODECS_SCHEMA = cv.Schema(
{
cv.Optional(CONF_FLAC): _maybe_empty_codec(CODEC_FLAC_SCHEMA),
cv.Optional(CONF_MP3): _maybe_empty_codec(CODEC_MP3_SCHEMA),
cv.Optional(CONF_OPUS): _maybe_empty_codec(CODEC_OPUS_SCHEMA),
cv.Optional(CONF_WAV): _maybe_empty_codec(CODEC_WAV_SCHEMA),
}
)
CONFIG_SCHEMA = cv.All(
cv.Schema({}),
cv.Schema(
{
cv.Optional(CONF_CODECS): _maybe_empty_codec(CODECS_SCHEMA),
}
),
cv.only_on_esp32,
)
AUDIO_COMPONENT_SCHEMA = cv.Schema(
@@ -208,6 +320,15 @@ def final_validate_audio_schema(
)
def _emit_memory_pair(value: str | None, psram_key: str, internal_key: str) -> None:
if value == MEMORY_PSRAM:
add_idf_sdkconfig_option(psram_key, True)
add_idf_sdkconfig_option(internal_key, False)
elif value == MEMORY_INTERNAL:
add_idf_sdkconfig_option(psram_key, False)
add_idf_sdkconfig_option(internal_key, True)
async def to_code(config):
# Re-enable ESP-IDF's HTTP client (excluded by default to save compile time)
include_builtin_idf_component("esp_http_client")
@@ -219,6 +340,36 @@ async def to_code(config):
data = _get_data()
# Merge user-supplied codec configuration (additive: presence enables the codec)
if codecs_config := config.get(CONF_CODECS):
if (flac_config := codecs_config.get(CONF_FLAC)) is not None:
data.flac_support = True
if (buffer_memory := flac_config.get(CONF_BUFFER_MEMORY)) is not None:
data.flac.buffer_memory = buffer_memory
if (mp3_config := codecs_config.get(CONF_MP3)) is not None:
data.mp3_support = True
if (buffer_memory := mp3_config.get(CONF_BUFFER_MEMORY)) is not None:
data.mp3.buffer_memory = buffer_memory
if (opus_config := codecs_config.get(CONF_OPUS)) is not None:
data.opus_support = True
floating_point = opus_config.get(CONF_FLOATING_POINT)
if floating_point is not None:
data.opus.floating_point = floating_point
if (state_memory := opus_config.get(CONF_STATE_MEMORY)) is not None:
data.opus.state_memory = state_memory
if (pseudostack_config := opus_config.get(CONF_PSEUDOSTACK)) is not None:
threadsafe = pseudostack_config.get(CONF_THREADSAFE)
if threadsafe is not None:
data.opus.pseudostack.threadsafe = threadsafe
if (
buffer_memory := pseudostack_config.get(CONF_BUFFER_MEMORY)
) is not None:
data.opus.pseudostack.buffer_memory = buffer_memory
if (size := pseudostack_config.get(CONF_SIZE)) is not None:
data.opus.pseudostack.size = size
if CONF_WAV in codecs_config:
data.wav_support = True
if data.micro_decoder_support:
add_idf_component(name="esphome/micro-decoder", ref="0.2.0")
@@ -229,13 +380,50 @@ async def to_code(config):
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)
if not data.wav_support:
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_WAV", False)
# Legacy audio_decoder.cpp support defines and components
# Configure each codec library.
# Adds a define and IDF component for legacy `audio_decoder.cpp`.
if data.flac_support:
cg.add_define("USE_AUDIO_FLAC_SUPPORT")
add_idf_component(name="esphome/micro-flac", ref="0.1.1")
_emit_memory_pair(
data.flac.buffer_memory,
"CONFIG_MICRO_FLAC_PREFER_PSRAM",
"CONFIG_MICRO_FLAC_PREFER_INTERNAL",
)
if data.mp3_support:
cg.add_define("USE_AUDIO_MP3_SUPPORT")
_emit_memory_pair(
data.mp3.buffer_memory,
"CONFIG_MP3_DECODER_PREFER_PSRAM",
"CONFIG_MP3_DECODER_PREFER_INTERNAL",
)
if data.opus_support:
cg.add_define("USE_AUDIO_OPUS_SUPPORT")
add_idf_component(name="esphome/micro-opus", ref="0.3.6")
add_idf_component(name="esphome/micro-opus", ref="0.4.0")
if data.opus.floating_point is not None:
add_idf_sdkconfig_option(
"CONFIG_OPUS_FLOATING_POINT", data.opus.floating_point
)
_emit_memory_pair(
data.opus.state_memory,
"CONFIG_OPUS_STATE_PREFER_PSRAM",
"CONFIG_OPUS_STATE_PREFER_INTERNAL",
)
if data.opus.pseudostack.threadsafe is True:
add_idf_sdkconfig_option("CONFIG_OPUS_THREADSAFE_PSEUDOSTACK", True)
add_idf_sdkconfig_option("CONFIG_OPUS_NONTHREADSAFE_PSEUDOSTACK", False)
elif data.opus.pseudostack.threadsafe is False:
add_idf_sdkconfig_option("CONFIG_OPUS_THREADSAFE_PSEUDOSTACK", False)
add_idf_sdkconfig_option("CONFIG_OPUS_NONTHREADSAFE_PSEUDOSTACK", True)
_emit_memory_pair(
data.opus.pseudostack.buffer_memory,
"CONFIG_OPUS_PSEUDOSTACK_PREFER_PSRAM",
"CONFIG_OPUS_PSEUDOSTACK_PREFER_INTERNAL",
)
if data.opus.pseudostack.size is not None:
add_idf_sdkconfig_option(
"CONFIG_OPUS_PSEUDOSTACK_SIZE", data.opus.pseudostack.size
)

View File

@@ -62,6 +62,7 @@ CONF_IS_WRGB = "is_wrgb"
SUPPORTED_PINS = {
libretiny.const.FAMILY_BK7231N: [16],
libretiny.const.FAMILY_BK7231T: [16],
libretiny.const.FAMILY_BK7238: [16],
libretiny.const.FAMILY_BK7251: [16],
}

View File

@@ -143,15 +143,15 @@ BinarySensorCondition = binary_sensor_ns.class_("BinarySensorCondition", Conditi
# Filters
Filter = binary_sensor_ns.class_("Filter")
TimeoutFilter = binary_sensor_ns.class_("TimeoutFilter", Filter, cg.Component)
DelayedOnOffFilter = binary_sensor_ns.class_("DelayedOnOffFilter", Filter, cg.Component)
DelayedOnFilter = binary_sensor_ns.class_("DelayedOnFilter", Filter, cg.Component)
DelayedOffFilter = binary_sensor_ns.class_("DelayedOffFilter", Filter, cg.Component)
TimeoutFilter = binary_sensor_ns.class_("TimeoutFilter", Filter)
DelayedOnOffFilter = binary_sensor_ns.class_("DelayedOnOffFilter", Filter)
DelayedOnFilter = binary_sensor_ns.class_("DelayedOnFilter", Filter)
DelayedOffFilter = binary_sensor_ns.class_("DelayedOffFilter", Filter)
InvertFilter = binary_sensor_ns.class_("InvertFilter", Filter)
AutorepeatFilter = binary_sensor_ns.class_("AutorepeatFilter", Filter, cg.Component)
LambdaFilter = binary_sensor_ns.class_("LambdaFilter", Filter)
StatelessLambdaFilter = binary_sensor_ns.class_("StatelessLambdaFilter", Filter)
SettleFilter = binary_sensor_ns.class_("SettleFilter", Filter, cg.Component)
SettleFilter = binary_sensor_ns.class_("SettleFilter", Filter)
_LOGGER = getLogger(__name__)
@@ -175,7 +175,6 @@ async def invert_filter_to_code(config, filter_id):
)
async def timeout_filter_to_code(config, filter_id):
var = cg.new_Pvariable(filter_id)
await cg.register_component(var, {})
template_ = await cg.templatable(config, [], cg.uint32)
cg.add(var.set_timeout_value(template_))
return var
@@ -203,7 +202,6 @@ async def timeout_filter_to_code(config, filter_id):
)
async def delayed_on_off_filter_to_code(config, filter_id):
var = cg.new_Pvariable(filter_id)
await cg.register_component(var, {})
if isinstance(config, dict):
template_ = await cg.templatable(config[CONF_TIME_ON], [], cg.uint32)
cg.add(var.set_on_delay(template_))
@@ -221,7 +219,6 @@ async def delayed_on_off_filter_to_code(config, filter_id):
)
async def delayed_on_filter_to_code(config, filter_id):
var = cg.new_Pvariable(filter_id)
await cg.register_component(var, {})
template_ = await cg.templatable(config, [], cg.uint32)
cg.add(var.set_delay(template_))
return var
@@ -234,7 +231,6 @@ async def delayed_on_filter_to_code(config, filter_id):
)
async def delayed_off_filter_to_code(config, filter_id):
var = cg.new_Pvariable(filter_id)
await cg.register_component(var, {})
template_ = await cg.templatable(config, [], cg.uint32)
cg.add(var.set_delay(template_))
return var
@@ -306,7 +302,6 @@ async def lambda_filter_to_code(config, filter_id):
)
async def settle_filter_to_code(config, filter_id):
var = cg.new_Pvariable(filter_id)
await cg.register_component(var, {})
template_ = await cg.templatable(config, [], cg.uint32)
cg.add(var.set_delay(template_))
return var

View File

@@ -4,16 +4,14 @@
#include "filter.h"
#include "binary_sensor.h"
#include "esphome/core/application.h"
namespace esphome::binary_sensor {
static const char *const TAG = "sensor.filter";
// Timeout IDs for filter classes.
// Each filter is its own Component instance, so the scheduler scopes
// IDs by component pointer — no risk of collisions between instances.
constexpr uint32_t FILTER_TIMEOUT_ID = 0;
// AutorepeatFilter needs two distinct IDs (both timeouts on the same component)
// AutorepeatFilter still inherits Component (it schedules two distinct timer
// purposes), so it keeps the (Component *, id) scheduler API.
constexpr uint32_t AUTOREPEAT_TIMING_ID = 0;
constexpr uint32_t AUTOREPEAT_ON_OFF_ID = 1;
@@ -34,46 +32,40 @@ void Filter::input(bool value) {
}
void TimeoutFilter::input(bool value) {
this->set_timeout(FILTER_TIMEOUT_ID, this->timeout_delay_.value(), [this]() { this->parent_->invalidate_state(); });
App.scheduler.set_timeout(this, this->timeout_delay_.value(), [this]() { this->parent_->invalidate_state(); });
// we do not de-dup here otherwise changes from invalid to valid state will not be output
this->output(value);
}
optional<bool> DelayedOnOffFilter::new_value(bool value) {
if (value) {
this->set_timeout(FILTER_TIMEOUT_ID, this->on_delay_.value(), [this]() { this->output(true); });
App.scheduler.set_timeout(this, this->on_delay_.value(), [this]() { this->output(true); });
} else {
this->set_timeout(FILTER_TIMEOUT_ID, this->off_delay_.value(), [this]() { this->output(false); });
App.scheduler.set_timeout(this, this->off_delay_.value(), [this]() { this->output(false); });
}
return {};
}
float DelayedOnOffFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
optional<bool> DelayedOnFilter::new_value(bool value) {
if (value) {
this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->output(true); });
App.scheduler.set_timeout(this, this->delay_.value(), [this]() { this->output(true); });
return {};
} else {
this->cancel_timeout(FILTER_TIMEOUT_ID);
App.scheduler.cancel_timeout(this);
return false;
}
}
float DelayedOnFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
optional<bool> DelayedOffFilter::new_value(bool value) {
if (!value) {
this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->output(false); });
App.scheduler.set_timeout(this, this->delay_.value(), [this]() { this->output(false); });
return {};
} else {
this->cancel_timeout(FILTER_TIMEOUT_ID);
App.scheduler.cancel_timeout(this);
return true;
}
}
float DelayedOffFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
optional<bool> InvertFilter::new_value(bool value) { return !value; }
// AutorepeatFilterBase
@@ -118,20 +110,18 @@ optional<bool> LambdaFilter::new_value(bool value) { return this->f_(value); }
optional<bool> SettleFilter::new_value(bool value) {
if (!this->steady_) {
this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this, value]() {
App.scheduler.set_timeout(this, this->delay_.value(), [this, value]() {
this->steady_ = true;
this->output(value);
});
return {};
} else {
this->steady_ = false;
this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->steady_ = true; });
App.scheduler.set_timeout(this, this->delay_.value(), [this]() { this->steady_ = true; });
return value;
}
}
float SettleFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
} // namespace esphome::binary_sensor
#endif // USE_BINARY_SENSOR_FILTER

View File

@@ -29,7 +29,7 @@ class Filter {
Deduplicator<bool> dedup_;
};
class TimeoutFilter : public Filter, public Component {
class TimeoutFilter : public Filter {
public:
optional<bool> new_value(bool value) override { return value; }
void input(bool value) override;
@@ -39,12 +39,10 @@ class TimeoutFilter : public Filter, public Component {
TemplatableFn<uint32_t> timeout_delay_{};
};
class DelayedOnOffFilter final : public Filter, public Component {
class DelayedOnOffFilter final : public Filter {
public:
optional<bool> new_value(bool value) override;
float get_setup_priority() const override;
template<typename T> void set_on_delay(T delay) { this->on_delay_ = delay; }
template<typename T> void set_off_delay(T delay) { this->off_delay_ = delay; }
@@ -53,24 +51,20 @@ class DelayedOnOffFilter final : public Filter, public Component {
TemplatableFn<uint32_t> off_delay_{};
};
class DelayedOnFilter : public Filter, public Component {
class DelayedOnFilter : public Filter {
public:
optional<bool> new_value(bool value) override;
float get_setup_priority() const override;
template<typename T> void set_delay(T delay) { this->delay_ = delay; }
protected:
TemplatableFn<uint32_t> delay_{};
};
class DelayedOffFilter : public Filter, public Component {
class DelayedOffFilter : public Filter {
public:
optional<bool> new_value(bool value) override;
float get_setup_priority() const override;
template<typename T> void set_delay(T delay) { this->delay_ = delay; }
protected:
@@ -146,12 +140,10 @@ class StatelessLambdaFilter : public Filter {
optional<bool> (*f_)(bool);
};
class SettleFilter : public Filter, public Component {
class SettleFilter : public Filter {
public:
optional<bool> new_value(bool value) override;
float get_setup_priority() const override;
template<typename T> void set_delay(T delay) { this->delay_ = delay; }
protected:

View File

@@ -48,13 +48,13 @@ from esphome.const import (
CONF_VISUAL,
CONF_WEB_SERVER,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_entity,
)
from esphome.cpp_generator import MockObjClass
from esphome.cpp_generator import LambdaExpression, MockObjClass
IS_PLATFORM_COMPONENT = True
@@ -487,38 +487,57 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema(
)
async def climate_control_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, paren)
if (mode := config.get(CONF_MODE)) is not None:
template_ = await cg.templatable(mode, args, ClimateMode)
cg.add(var.set_mode(template_))
if (target_temp := config.get(CONF_TARGET_TEMPERATURE)) is not None:
template_ = await cg.templatable(target_temp, args, cg.float_)
cg.add(var.set_target_temperature(template_))
if (target_temp_low := config.get(CONF_TARGET_TEMPERATURE_LOW)) is not None:
template_ = await cg.templatable(target_temp_low, args, cg.float_)
cg.add(var.set_target_temperature_low(template_))
if (target_temp_high := config.get(CONF_TARGET_TEMPERATURE_HIGH)) is not None:
template_ = await cg.templatable(target_temp_high, args, cg.float_)
cg.add(var.set_target_temperature_high(template_))
if (target_humidity := config.get(CONF_TARGET_HUMIDITY)) is not None:
template_ = await cg.templatable(target_humidity, args, cg.float_)
cg.add(var.set_target_humidity(template_))
if (fan_mode := config.get(CONF_FAN_MODE)) is not None:
template_ = await cg.templatable(fan_mode, args, ClimateFanMode)
cg.add(var.set_fan_mode(template_))
if (custom_fan_mode := config.get(CONF_CUSTOM_FAN_MODE)) is not None:
template_ = await cg.templatable(custom_fan_mode, args, cg.std_string)
cg.add(var.set_custom_fan_mode(template_))
if (preset := config.get(CONF_PRESET)) is not None:
template_ = await cg.templatable(preset, args, ClimatePreset)
cg.add(var.set_preset(template_))
if (custom_preset := config.get(CONF_CUSTOM_PRESET)) is not None:
template_ = await cg.templatable(custom_preset, args, cg.std_string)
cg.add(var.set_custom_preset(template_))
if (swing_mode := config.get(CONF_SWING_MODE)) is not None:
template_ = await cg.templatable(swing_mode, args, ClimateSwingMode)
cg.add(var.set_swing_mode(template_))
return var
# All configured fields are folded into a single stateless lambda whose
# constants live in flash; the action stores only a function pointer.
# For custom_fan_mode/custom_preset the static-string path emits the
# (const char *, size_t) overload of set_fan_mode/set_preset to avoid
# constructing a std::string and calling runtime strlen.
FIELDS = (
(CONF_MODE, "set_mode", ClimateMode),
(CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_),
(CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_),
(CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_),
(CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_),
(CONF_FAN_MODE, "set_fan_mode", ClimateFanMode),
(CONF_CUSTOM_FAN_MODE, "set_fan_mode", cg.std_string),
(CONF_PRESET, "set_preset", ClimatePreset),
(CONF_CUSTOM_PRESET, "set_preset", cg.std_string),
(CONF_SWING_MODE, "set_swing_mode", ClimateSwingMode),
)
fwd_args = ", ".join(name for _, name in args)
body_lines: list[str] = []
for conf_key, setter, type_ in FIELDS:
if (value := config.get(conf_key)) is None:
continue
if isinstance(value, Lambda):
inner = await cg.process_lambda(value, args, return_type=type_)
body_lines.append(f"call.{setter}(({inner})({fwd_args}));")
elif type_ is cg.std_string:
# Static custom strings: emit a flash literal and pass the
# UTF-8 byte length to skip the runtime strlen inside
# set_fan_mode/set_preset.
literal = cg.safe_exp(value)
body_lines.append(
f"call.{setter}({literal}, {len(value.encode('utf-8'))});"
)
else:
body_lines.append(f"call.{setter}({cg.safe_exp(value)});")
# Match ControlAction::ApplyFn signature: const Ts &... for trigger args.
apply_args = [
(ClimateCall.operator("ref"), "call"),
*((t.operator("const").operator("ref"), n) for t, n in args),
]
apply_lambda = LambdaExpression(
["\n".join(body_lines)],
apply_args,
capture="",
return_type=cg.void,
)
return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda)
@coroutine_with_priority(CoroPriority.CORE)

View File

@@ -5,42 +5,25 @@
namespace esphome::climate {
// All configured fields are baked into a single stateless lambda whose
// constants live in flash. The action only stores one function pointer
// plus one parent pointer, regardless of how many fields the user set.
// Trigger args are forwarded to the apply function so user lambdas
// (e.g. `target_temperature: !lambda "return x;"`) keep working.
template<typename... Ts> class ControlAction : public Action<Ts...> {
public:
explicit ControlAction(Climate *climate) : climate_(climate) {}
TEMPLATABLE_VALUE(ClimateMode, mode)
TEMPLATABLE_VALUE(float, target_temperature)
TEMPLATABLE_VALUE(float, target_temperature_low)
TEMPLATABLE_VALUE(float, target_temperature_high)
TEMPLATABLE_VALUE(float, target_humidity)
TEMPLATABLE_VALUE(bool, away)
TEMPLATABLE_VALUE(ClimateFanMode, fan_mode)
TEMPLATABLE_VALUE(std::string, custom_fan_mode)
TEMPLATABLE_VALUE(ClimatePreset, preset)
TEMPLATABLE_VALUE(std::string, custom_preset)
TEMPLATABLE_VALUE(ClimateSwingMode, swing_mode)
using ApplyFn = void (*)(ClimateCall &, const Ts &...);
ControlAction(Climate *climate, ApplyFn apply) : climate_(climate), apply_(apply) {}
void play(const Ts &...x) override {
auto call = this->climate_->make_call();
call.set_mode(this->mode_.optional_value(x...));
call.set_target_temperature(this->target_temperature_.optional_value(x...));
call.set_target_temperature_low(this->target_temperature_low_.optional_value(x...));
call.set_target_temperature_high(this->target_temperature_high_.optional_value(x...));
call.set_target_humidity(this->target_humidity_.optional_value(x...));
if (away_.has_value()) {
call.set_preset(away_.value(x...) ? CLIMATE_PRESET_AWAY : CLIMATE_PRESET_HOME);
}
call.set_fan_mode(this->fan_mode_.optional_value(x...));
call.set_fan_mode(this->custom_fan_mode_.optional_value(x...));
call.set_preset(this->preset_.optional_value(x...));
call.set_preset(this->custom_preset_.optional_value(x...));
call.set_swing_mode(this->swing_mode_.optional_value(x...));
this->apply_(call, x...);
call.perform();
}
protected:
Climate *climate_;
ApplyFn apply_;
};
class ControlTrigger : public Trigger<ClimateCall &> {

View File

@@ -1753,7 +1753,17 @@ async def to_code(config):
# Wrap FILE*-based printf functions to eliminate newlib's _vfprintf_r
# (~11 KB). See printf_stubs.cpp for implementation.
if conf[CONF_ADVANCED][CONF_ENABLE_FULL_PRINTF]:
#
# The wrap is only beneficial against newlib. Picolibc's tinystdio
# implements vsnprintf by building a string-output FILE and calling
# vfprintf, so vfprintf is unconditionally linked in by any caller
# of snprintf/vsnprintf — effectively every build — and the wrap
# saves nothing while costing ~170 B of shim. IDF 5.x defaults to
# newlib on every variant; IDF 6.0+ switches to picolibc on every
# variant.
if conf[CONF_ADVANCED][CONF_ENABLE_FULL_PRINTF] or idf_version() >= cv.Version(
6, 0, 0
):
cg.add_define("USE_FULL_PRINTF")
else:
for symbol in ("vprintf", "printf", "fprintf", "vfprintf"):

View File

@@ -15,6 +15,8 @@
#define PROGMEM
#endif
namespace esphome::esp32 {}
namespace esphome {
// Forward decl from helpers.h (esphome/core/helpers.h) — kept here so this
@@ -42,6 +44,9 @@ __attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { dela
__attribute__((always_inline)) inline void arch_feed_wdt() { esp_task_wdt_reset(); }
__attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() { return esp_cpu_get_cycle_count(); }
void arch_init();
uint32_t arch_get_cpu_freq_hz();
} // namespace esphome
#endif // USE_ESP32

View File

@@ -1,32 +1,38 @@
/*
* Linker wrap stubs for FILE*-based printf functions.
* Linker wrap stubs for FILE*-based printf functions (newlib only).
*
* ESP-IDF SDK components (gpio driver, ringbuf, log_write) reference
* fprintf(), printf(), vprintf(), and vfprintf() which pull in the full
* printf implementation (~11 KB on newlib's _vfprintf_r, ~2.8 KB on
* picolibc's vfprintf). This is a separate implementation from the one
* used by snprintf/vsnprintf that handles FILE* stream I/O with buffering
* and locking.
* fprintf(), printf(), vprintf(), and vfprintf(), which on newlib pull
* in _vfprintf_r (~11 KB) — a separate implementation from the one used
* by snprintf/vsnprintf that handles FILE* stream I/O with buffering.
*
* ESPHome replaces the ESP-IDF log handler via esp_log_set_vprintf_(),
* so the SDK's vprintf() path is dead code at runtime. The fprintf()
* and printf() calls in SDK components are only in debug/assert paths
* (gpio_dump_io_configuration, ringbuf diagnostics) that are either
* GC'd or never called. Crash backtraces and panic output are
* unaffected they use esp_rom_printf() which is a ROM function
* and does not go through libc.
* unaffected; they use esp_rom_printf() which is a ROM function and
* does not go through libc.
*
* These stubs redirect through vsnprintf() (which uses _svfprintf_r
* already in the binary) and fwrite(), allowing the linker to
* dead-code eliminate _vfprintf_r.
* This wrap is newlib-only. On picolibc, vsnprintf is implemented as
* vfprintf into a string-output FILE, so vfprintf is unconditionally
* linked in by any caller of snprintf/vsnprintf and the wrap can never
* elide it — it just adds shim cost. Codegen forces USE_FULL_PRINTF
* on picolibc builds (IDF 6.0+ on all variants) so this file compiles
* to nothing there; the #error below catches a desynchronised gate.
*
* Saves ~11 KB of flash.
* Saves ~11 KB of flash on newlib.
*
* To disable these wraps, set enable_full_printf: true in the esp32
* advanced config section.
* To disable this wrap on newlib, set enable_full_printf: true in the
* esp32 advanced config section.
*/
#if defined(USE_ESP_IDF) && !defined(USE_FULL_PRINTF)
#ifdef __PICOLIBC__
#error "printf wrap is net-negative on picolibc; codegen should set USE_FULL_PRINTF"
#endif
#include <cstdarg>
#include <cstdio>
@@ -34,6 +40,9 @@
namespace esphome::esp32 {}
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
extern "C" {
static constexpr size_t PRINTF_BUFFER_SIZE = 512;
// These stubs are essentially dead code at runtime — ESPHome replaces the
@@ -55,14 +64,16 @@ static int write_printf_buffer(FILE *stream, char *buf, int len) {
return len;
}
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
extern "C" {
int __wrap_vprintf(const char *fmt, va_list ap) {
char buf[PRINTF_BUFFER_SIZE];
return write_printf_buffer(stdout, buf, vsnprintf(buf, sizeof(buf), fmt, ap));
}
int __wrap_vfprintf(FILE *stream, const char *fmt, va_list ap) {
char buf[PRINTF_BUFFER_SIZE];
return write_printf_buffer(stream, buf, vsnprintf(buf, sizeof(buf), fmt, ap));
}
int __wrap_printf(const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
@@ -71,11 +82,6 @@ int __wrap_printf(const char *fmt, ...) {
return len;
}
int __wrap_vfprintf(FILE *stream, const char *fmt, va_list ap) {
char buf[PRINTF_BUFFER_SIZE];
return write_printf_buffer(stream, buf, vsnprintf(buf, sizeof(buf), fmt, ap));
}
int __wrap_fprintf(FILE *stream, const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);

View File

@@ -246,9 +246,10 @@ async def to_code(config):
idf_ver = esp32.idf_version()
os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}"
if idf_ver >= cv.Version(5, 5, 0):
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.4.0")
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.4")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.1")
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1")
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2")
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.6")
else:
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0")
esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0")

View File

@@ -3,98 +3,12 @@
#include "core.h"
#include "esphome/core/defines.h"
#include "esphome/core/hal.h"
#include "esphome/core/time_64.h"
#include "esphome/core/helpers.h"
#include "preferences.h"
#include <Arduino.h>
#include <core_esp8266_features.h>
extern "C" {
#include <user_interface.h>
}
namespace esphome {
// yield(), micros(), millis_64() inlined in hal.h.
// Fast accumulator replacement for Arduino's millis() (~3.3 μs via 4× 64-bit
// multiplies on the LX106). Tracks a running ms counter from 32-bit
// system_get_time() deltas using pure 32-bit ops. Installed as __wrap_millis
// (via -Wl,--wrap=millis) so Arduino libs and IRAM_ATTR ISR handlers (e.g.
// Wiegand, ZyAura) also get the fast version. xt_rsil(15) guards the static
// state against ISR re-entry; the critical section is bounded (≤10 while-loop
// iterations, ~100 ns on the common path, or a constant-time /1000 ~2.5 μs on
// the rare path — well under WiFi's ~10 μs ISR latency budget). NMIs (level
// >15) are not masked, but the ESP8266 SDK's NMI handlers don't call millis().
//
// system_get_time() wraps every ~71.6 min; unsigned (now_us - last_us) handles
// one wrap. The main loop calls millis() at 60+ Hz, so delta stays tiny — a
// >71 min block would trip the watchdog long before it could matter here.
static constexpr uint32_t MILLIS_RARE_PATH_THRESHOLD_US = 10000;
static constexpr uint32_t US_PER_MS = 1000;
uint32_t IRAM_ATTR HOT millis() {
// Struct packs the three statics so the compiler loads one base address
// instead of three separate literal pool entries (saves ~8 bytes IRAM).
static struct {
uint32_t cache;
uint32_t remainder;
uint32_t last_us;
} state = {0, 0, 0};
uint32_t ps = xt_rsil(15);
uint32_t now_us = system_get_time();
uint32_t delta = now_us - state.last_us;
state.last_us = now_us;
state.remainder += delta;
if (state.remainder >= MILLIS_RARE_PATH_THRESHOLD_US) {
// Rare path: large gap (WiFi scan, boot, long block). Constant-time
// conversion keeps the critical section bounded.
uint32_t ms = state.remainder / US_PER_MS;
state.cache += ms;
// Reuse ms instead of `remainder %= US_PER_MS` — `%` would compile to a
// second __umodsi3 call on the LX106 (no hardware divide).
state.remainder -= ms * US_PER_MS;
} else {
// Common path: small gap. At most ~10 iterations since remainder was
// < threshold (10 ms) on entry and delta adds at most one more threshold
// before exiting this branch.
while (state.remainder >= US_PER_MS) {
state.cache++;
state.remainder -= US_PER_MS;
}
}
uint32_t result = state.cache;
xt_wsr_ps(ps);
return result;
}
// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object
// call to the original millis() that --wrap can't intercept, so calling ::delay()
// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still
// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and
// WiFi run correctly. Theoretically less power-efficient than Arduino's
// os_timer-based delay() for long waits, but nearly all ESPHome delays are short
// (sensor/I²C/SPI settling in the 1100 ms range) where the difference is
// negligible.
void HOT delay(uint32_t ms) {
if (ms == 0) {
optimistic_yield(1000);
return;
}
uint32_t start = millis();
while (millis() - start < ms) {
optimistic_yield(1000);
}
}
// delayMicroseconds(), arch_feed_wdt(), and progmem_read_*() are inlined in hal/hal_esp8266.h.
void arch_restart() {
system_restart();
// restart() doesn't always end execution
while (true) { // NOLINT(clang-diagnostic-unreachable-code)
yield();
}
}
void arch_init() {}
uint32_t IRAM_ATTR HOT arch_get_cpu_cycle_count() { return esp_get_cycle_count(); }
uint32_t arch_get_cpu_freq_hz() { return F_CPU; }
// HAL functions live in hal.cpp. This file keeps only the ESP8266-specific
// firmware bootstrap (Tasmota OTA magic bytes, optional GPIO pre-init).
void force_link_symbols() {
// Tasmota uses magic bytes in the binary to check if an OTA firmware is compatible
@@ -131,12 +45,4 @@ extern "C" void resetPins() { // NOLINT
} // namespace esphome
// Linker wrap: redirect all ::millis() calls (Arduino libs, ISRs) to our accumulator.
// Requires -Wl,--wrap=millis in build flags (added by __init__.py).
// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
extern "C" uint32_t IRAM_ATTR __wrap_millis() { return esphome::millis(); }
// Note: Arduino's init() registers a 60-second overflow timer for micros64().
// We leave it running — wrapping init() as a no-op would break micros64()'s
// overflow tracking, and the timer's cost is negligible (~3 μs per 60 s).
#endif // USE_ESP8266

View File

@@ -0,0 +1,111 @@
#ifdef USE_ESP8266
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include <Arduino.h>
#include <core_esp8266_features.h>
extern "C" {
#include <user_interface.h>
}
// Empty esp8266 namespace block to satisfy ci-custom's lint_namespace check.
// HAL functions live in namespace esphome (root) — they are not part of the
// esp8266 component's API.
namespace esphome::esp8266 {} // namespace esphome::esp8266
namespace esphome {
// yield(), micros(), millis_64(), delayMicroseconds(), arch_feed_wdt(),
// progmem_read_*() are inlined in components/esp8266/hal.h.
//
// Fast accumulator replacement for Arduino's millis() (~3.3 μs via 4× 64-bit
// multiplies on the LX106). Tracks a running ms counter from 32-bit
// system_get_time() deltas using pure 32-bit ops. Installed as __wrap_millis
// (via -Wl,--wrap=millis) so Arduino libs and IRAM_ATTR ISR handlers (e.g.
// Wiegand, ZyAura) also get the fast version. xt_rsil(15) guards the static
// state against ISR re-entry; the critical section is bounded (≤10 while-loop
// iterations, ~100 ns on the common path, or a constant-time /1000 ~2.5 μs on
// the rare path — well under WiFi's ~10 μs ISR latency budget). NMIs (level
// >15) are not masked, but the ESP8266 SDK's NMI handlers don't call millis().
//
// system_get_time() wraps every ~71.6 min; unsigned (now_us - last_us) handles
// one wrap. The main loop calls millis() at 60+ Hz, so delta stays tiny — a
// >71 min block would trip the watchdog long before it could matter here.
static constexpr uint32_t MILLIS_RARE_PATH_THRESHOLD_US = 10000;
static constexpr uint32_t US_PER_MS = 1000;
uint32_t IRAM_ATTR HOT millis() {
// Struct packs the three statics so the compiler loads one base address
// instead of three separate literal pool entries (saves ~8 bytes IRAM).
static struct {
uint32_t cache;
uint32_t remainder;
uint32_t last_us;
} state = {0, 0, 0};
uint32_t ps = xt_rsil(15);
uint32_t now_us = system_get_time();
uint32_t delta = now_us - state.last_us;
state.last_us = now_us;
state.remainder += delta;
if (state.remainder >= MILLIS_RARE_PATH_THRESHOLD_US) {
// Rare path: large gap (WiFi scan, boot, long block). Constant-time
// conversion keeps the critical section bounded.
uint32_t ms = state.remainder / US_PER_MS;
state.cache += ms;
// Reuse ms instead of `remainder %= US_PER_MS` — `%` would compile to a
// second __umodsi3 call on the LX106 (no hardware divide).
state.remainder -= ms * US_PER_MS;
} else {
// Common path: small gap. At most ~10 iterations since remainder was
// < threshold (10 ms) on entry and delta adds at most one more threshold
// before exiting this branch.
while (state.remainder >= US_PER_MS) {
state.cache++;
state.remainder -= US_PER_MS;
}
}
uint32_t result = state.cache;
xt_wsr_ps(ps);
return result;
}
// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object
// call to the original millis() that --wrap can't intercept, so calling ::delay()
// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still
// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and
// WiFi run correctly. Theoretically less power-efficient than Arduino's
// os_timer-based delay() for long waits, but nearly all ESPHome delays are short
// (sensor/I²C/SPI settling in the 1100 ms range) where the difference is
// negligible.
void HOT delay(uint32_t ms) {
if (ms == 0) {
optimistic_yield(1000);
return;
}
uint32_t start = millis();
while (millis() - start < ms) {
optimistic_yield(1000);
}
}
void arch_restart() {
system_restart();
// restart() doesn't always end execution
while (true) { // NOLINT(clang-diagnostic-unreachable-code)
yield();
}
}
} // namespace esphome
// Linker wrap: redirect all ::millis() calls (Arduino libs, ISRs) to our accumulator.
// Requires -Wl,--wrap=millis in build flags (added by __init__.py).
// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
extern "C" uint32_t IRAM_ATTR __wrap_millis() { return esphome::millis(); }
// Note: Arduino's init() registers a 60-second overflow timer for micros64().
// We leave it running — wrapping init() as a no-op would break micros64()'s
// overflow tracking, and the timer's cost is negligible (~3 μs per 60 s).
#endif // USE_ESP8266

View File

@@ -3,6 +3,7 @@
#ifdef USE_ESP8266
#include <c_types.h>
#include <core_esp8266_features.h>
#include <cstdint>
#include <pgmspace.h>
@@ -24,6 +25,8 @@ extern "C" unsigned long millis(void);
// NOLINTNEXTLINE(readability-redundant-declaration)
extern "C" void system_soft_wdt_feed(void);
namespace esphome::esp8266 {}
namespace esphome {
// Forward decl from helpers.h so this header stays cheap.
@@ -59,8 +62,11 @@ __attribute__((always_inline)) inline uint16_t progmem_read_uint16(const uint16_
// NOLINTNEXTLINE(readability-identifier-naming)
__attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); }
__attribute__((always_inline)) inline void arch_feed_wdt() { system_soft_wdt_feed(); }
uint32_t arch_get_cpu_cycle_count();
__attribute__((always_inline)) inline void arch_init() {}
// esp_get_cycle_count() declared in <core_esp8266_features.h>; F_CPU is a
// compiler-driven macro from the ESP8266 Arduino board defs (-DF_CPU=...).
__attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() { return esp_get_cycle_count(); }
__attribute__((always_inline)) inline uint32_t arch_get_cpu_freq_hz() { return F_CPU; }
} // namespace esphome

View File

@@ -114,8 +114,10 @@ void ESPHomeOTAComponent::loop() {
this->handle_handshake_();
}
static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01;
static const uint8_t FEATURE_SUPPORTS_SHA256_AUTH = 0x02;
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01;
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02;
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04;
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01;
void ESPHomeOTAComponent::handle_handshake_() {
/// Handle the OTA handshake and authentication.
@@ -201,16 +203,30 @@ void ESPHomeOTAComponent::handle_handshake_() {
this->ota_features_ = this->handshake_buf_[0];
ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_);
this->transition_ota_state_(OTAState::FEATURE_ACK);
this->handshake_buf_[0] =
((this->ota_features_ & FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression())
? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION
: ota::OTA_RESPONSE_HEADER_OK;
const bool supports_compression =
(this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression();
// Compose the feature-ack response. When the client negotiates the extended protocol we emit
// a 2-byte response (marker + server feature flags); otherwise we emit the single-byte
// legacy response.
this->extended_proto_ = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0;
if (this->extended_proto_) {
static_assert(HANDSHAKE_BUF_SIZE >= 2, "handshake_buf_ must hold the 2-byte extended-protocol feature ack");
this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS;
this->handshake_buf_[1] = (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0);
} else {
this->handshake_buf_[0] =
supports_compression ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION : ota::OTA_RESPONSE_HEADER_OK;
}
[[fallthrough]];
}
case OTAState::FEATURE_ACK: {
// Acknowledge header - 1 byte
if (!this->try_write_(1, LOG_STR("ack feature"))) {
static constexpr size_t STANDARD_PROTO_ACK_SIZE = 1;
static constexpr size_t EXTENDED_PROTO_ACK_SIZE = 2;
const size_t ack_size = this->extended_proto_ ? EXTENDED_PROTO_ACK_SIZE : STANDARD_PROTO_ACK_SIZE;
if (!this->try_write_(ack_size, LOG_STR("ack feature"))) {
return;
}
#ifdef USE_OTA_PASSWORD
@@ -296,6 +312,7 @@ void ESPHomeOTAComponent::handle_data_() {
uint8_t buf[OTA_BUFFER_SIZE];
char *sbuf = reinterpret_cast<char *>(buf);
size_t ota_size;
ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP;
#if USE_OTA_VERSION == 2
size_t size_acknowledged = 0;
#endif
@@ -311,6 +328,16 @@ void ESPHomeOTAComponent::handle_data_() {
// Acknowledge auth OK - 1 byte
this->write_byte_(ota::OTA_RESPONSE_AUTH_OK);
if (this->extended_proto_) {
// Read ota type, 1 byte
if (!this->readall_(buf, 1)) {
this->log_read_error_(LOG_STR("OTA type"));
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
ota_type = static_cast<ota::OTAType>(buf[0]);
}
ESP_LOGV(TAG, "OTA type is 0x%02x", ota_type);
// Read size, 4 bytes MSB first
if (!this->readall_(buf, 4)) {
this->log_read_error_(LOG_STR("size"));
@@ -320,6 +347,11 @@ void ESPHomeOTAComponent::handle_data_() {
(static_cast<size_t>(buf[2]) << 8) | buf[3];
ESP_LOGV(TAG, "Size is %u bytes", ota_size);
if (ota_type != ota::OTA_TYPE_UPDATE_APP) {
error_code = ota::OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE;
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
// Now that we've passed authentication and are actually
// starting the update, set the warning status and notify
// listeners. This ensures that port scanners do not
@@ -616,7 +648,7 @@ void ESPHomeOTAComponent::yield_and_feed_watchdog_() {
void ESPHomeOTAComponent::log_auth_warning_(const LogString *msg) { ESP_LOGW(TAG, "Auth: %s", LOG_STR_ARG(msg)); }
bool ESPHomeOTAComponent::select_auth_type_() {
bool client_supports_sha256 = (this->ota_features_ & FEATURE_SUPPORTS_SHA256_AUTH) != 0;
bool client_supports_sha256 = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_SHA256_AUTH) != 0;
// Require SHA256
if (!client_supports_sha256) {

View File

@@ -97,8 +97,9 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
ota::OTABackendPtr backend_;
uint32_t client_connect_time_{0};
static constexpr size_t HANDSHAKE_BUF_SIZE = 5;
uint16_t port_;
uint8_t handshake_buf_[5];
uint8_t handshake_buf_[HANDSHAKE_BUF_SIZE];
OTAState ota_state_{OTAState::IDLE};
uint8_t handshake_buf_pos_{0};
uint8_t ota_features_{0};
@@ -106,6 +107,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
uint8_t auth_buf_pos_{0};
uint8_t auth_type_{0}; // Store auth type to know which hasher to use
#endif // USE_OTA_PASSWORD
bool extended_proto_{false};
};
} // namespace esphome

View File

@@ -1,74 +1,16 @@
#ifdef USE_HOST
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "preferences.h"
#include <csignal>
#include <sched.h>
#include <time.h>
#include <cstdlib>
namespace {
volatile sig_atomic_t s_signal_received = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
void signal_handler(int signal) { s_signal_received = signal; }
} // namespace
namespace esphome {
void HOT yield() { ::sched_yield(); }
uint32_t IRAM_ATTR HOT millis() {
struct timespec spec;
clock_gettime(CLOCK_MONOTONIC, &spec);
return static_cast<uint32_t>(spec.tv_sec * 1000ULL + spec.tv_nsec / 1000000);
}
uint64_t millis_64() {
struct timespec spec;
clock_gettime(CLOCK_MONOTONIC, &spec);
return static_cast<uint64_t>(spec.tv_sec) * 1000ULL + static_cast<uint64_t>(spec.tv_nsec) / 1000000ULL;
}
void HOT delay(uint32_t ms) {
struct timespec ts;
ts.tv_sec = ms / 1000;
ts.tv_nsec = (ms % 1000) * 1000000;
int res;
do {
res = nanosleep(&ts, &ts);
} while (res != 0 && errno == EINTR);
}
uint32_t IRAM_ATTR HOT micros() {
struct timespec spec;
clock_gettime(CLOCK_MONOTONIC, &spec);
return static_cast<uint32_t>(spec.tv_sec * 1000000ULL + spec.tv_nsec / 1000);
}
void IRAM_ATTR HOT delayMicroseconds(uint32_t us) {
struct timespec ts;
ts.tv_sec = us / 1000000U;
ts.tv_nsec = (us % 1000000U) * 1000U;
int res;
do {
res = nanosleep(&ts, &ts);
} while (res != 0 && errno == EINTR);
}
void arch_restart() { exit(0); }
void arch_init() {
// pass
}
void HOT arch_feed_wdt() {
// pass
}
uint32_t arch_get_cpu_cycle_count() {
struct timespec spec;
clock_gettime(CLOCK_MONOTONIC, &spec);
time_t seconds = spec.tv_sec;
uint32_t us = spec.tv_nsec;
return ((uint32_t) seconds) * 1000000000U + us;
}
uint32_t arch_get_cpu_freq_hz() { return 1000000000U; }
} // namespace esphome
// HAL functions live in hal.cpp.
void setup();
void loop();

View File

@@ -0,0 +1,65 @@
#ifdef USE_HOST
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include <time.h>
#include <cerrno>
#include <cstdlib>
// Empty host namespace block to satisfy ci-custom's lint_namespace check.
// HAL functions live in namespace esphome (root) — they are not part of the
// host component's API.
namespace esphome::host {} // namespace esphome::host
namespace esphome {
// yield(), arch_init(), arch_feed_wdt(), arch_get_cpu_freq_hz() inlined in
// components/host/hal.h.
uint32_t IRAM_ATTR HOT millis() {
struct timespec spec;
clock_gettime(CLOCK_MONOTONIC, &spec);
return static_cast<uint32_t>(spec.tv_sec * 1000ULL + spec.tv_nsec / 1000000);
}
uint64_t millis_64() {
struct timespec spec;
clock_gettime(CLOCK_MONOTONIC, &spec);
return static_cast<uint64_t>(spec.tv_sec) * 1000ULL + static_cast<uint64_t>(spec.tv_nsec) / 1000000ULL;
}
void HOT delay(uint32_t ms) {
struct timespec ts;
ts.tv_sec = ms / 1000;
ts.tv_nsec = (ms % 1000) * 1000000;
int res;
do {
res = nanosleep(&ts, &ts);
} while (res != 0 && errno == EINTR);
}
uint32_t IRAM_ATTR HOT micros() {
struct timespec spec;
clock_gettime(CLOCK_MONOTONIC, &spec);
return static_cast<uint32_t>(spec.tv_sec * 1000000ULL + spec.tv_nsec / 1000);
}
void IRAM_ATTR HOT delayMicroseconds(uint32_t us) {
struct timespec ts;
ts.tv_sec = us / 1000000U;
ts.tv_nsec = (us % 1000000U) * 1000U;
int res;
do {
res = nanosleep(&ts, &ts);
} while (res != 0 && errno == EINTR);
}
void arch_restart() { exit(0); }
uint32_t arch_get_cpu_cycle_count() {
struct timespec spec;
clock_gettime(CLOCK_MONOTONIC, &spec);
time_t seconds = spec.tv_sec;
uint32_t ns = static_cast<uint32_t>(spec.tv_nsec);
return static_cast<uint32_t>(seconds) * 1000000000U + ns;
}
} // namespace esphome
#endif // USE_HOST

View File

@@ -3,26 +3,32 @@
#ifdef USE_HOST
#include <cstdint>
#include <sched.h>
#define IRAM_ATTR
#define PROGMEM
namespace esphome::host {}
namespace esphome {
/// Returns true when executing inside an interrupt handler.
/// Host has no ISR concept.
__attribute__((always_inline)) inline bool in_isr_context() { return false; }
void yield();
__attribute__((always_inline)) inline void yield() { ::sched_yield(); }
void delay(uint32_t ms);
uint32_t micros();
uint32_t millis();
uint64_t millis_64();
void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming)
void arch_feed_wdt();
uint32_t arch_get_cpu_cycle_count();
__attribute__((always_inline)) inline void arch_init() {}
__attribute__((always_inline)) inline void arch_feed_wdt() {}
__attribute__((always_inline)) inline uint32_t arch_get_cpu_freq_hz() { return 1000000000U; }
} // namespace esphome
#endif // USE_HOST

View File

@@ -37,6 +37,7 @@ from .const import (
CONF_UART_PORT,
FAMILIES,
FAMILY_BK7231N,
FAMILY_BK7238,
FAMILY_COMPONENT,
FAMILY_FRIENDLY,
FAMILY_RTL8710B,
@@ -56,19 +57,22 @@ CODEOWNERS = ["@kuba2k2"]
AUTO_LOAD = ["preferences"]
IS_TARGET_PLATFORM = True
# BK7231N SDK options to disable unused features.
# BLE 5.x BK SDK options to disable unused features.
# Disabling BLE saves ~21KB RAM and ~200KB Flash because BLE init code is
# called unconditionally by the SDK. ESPHome doesn't use BLE on LibreTiny.
#
# This only works on BK7231N (BLE 5.x). Other BK72XX chips using BLE 4.2
# (BK7231T, BK7231Q, BK7251; BK7252 boards use the BK7251 family) have a bug
# where the BLE library still links and references undefined symbols when
# CFG_SUPPORT_BLE=0.
# This only works on BLE 5.x BK chips (BK7231N, BK7238). Other BK72XX chips
# using BLE 4.2 (BK7231T, BK7231Q, BK7251; BK7252 boards use the BK7251 family)
# have a bug where the BLE library still links and references undefined symbols
# when CFG_SUPPORT_BLE=0.
#
# On BK7238 the SDK also hangs at WiFi STA enable when BLE init runs, so
# disabling it is required for reliable boot, not just an optimization.
#
# Other options like CFG_TX_EVM_TEST, CFG_RX_SENSITIVITY_TEST, CFG_SUPPORT_BKREG,
# CFG_SUPPORT_OTA_HTTP, and CFG_USE_SPI_SLAVE were evaluated but provide no # NOLINT
# measurable benefit - the linker already strips unreferenced code via -gc-sections.
_BK7231N_SYS_CONFIG_OPTIONS = [
_BLE5_BK_SYS_CONFIG_OPTIONS = [
"CFG_SUPPORT_BLE=0",
]
@@ -549,9 +553,9 @@ async def component_to_code(config):
cg.add_platformio_option("custom_fw_version", __version__)
# Apply chip-specific SDK options to save RAM/Flash
if config[CONF_FAMILY] == FAMILY_BK7231N:
if config[CONF_FAMILY] in (FAMILY_BK7231N, FAMILY_BK7238):
cg.add_platformio_option(
"custom_options.sys_config#h", _BK7231N_SYS_CONFIG_OPTIONS
"custom_options.sys_config#h", _BLE5_BK_SYS_CONFIG_OPTIONS
)
# Tune lwIP for ESPHome's actual needs.

View File

@@ -1,55 +1,6 @@
#ifdef USE_LIBRETINY
#include "core.h"
#include "esphome/core/defines.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "preferences.h"
#include <FreeRTOS.h>
#include <task.h>
void setup();
void loop();
namespace esphome {
// yield(), delay(), micros(), millis(), millis_64() inlined in hal.h.
void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { ::delayMicroseconds(us); }
void arch_init() {
libretiny::setup_preferences();
lt_wdt_enable(10000L);
#ifdef USE_BK72XX
// BK72xx SDK creates the main Arduino task at priority 3, which is lower than
// all WiFi (4-5), LwIP (4), and TCP/IP (7) tasks. This causes ~100ms loop
// stalls whenever WiFi background processing runs, because the main task
// cannot resume until every higher-priority task finishes.
//
// By contrast, RTL87xx creates the main task at osPriorityRealtime (highest).
//
// Raise to priority 6: above WiFi/LwIP tasks (4-5) so they don't preempt the
// main loop, but below the TCP/IP thread (7) so packet processing keeps priority.
// This is safe because ESPHome yields voluntarily via wakeable_delay() and
// the Arduino mainTask yield() after each loop() iteration.
static constexpr UBaseType_t MAIN_TASK_PRIORITY = 6;
static_assert(MAIN_TASK_PRIORITY < configMAX_PRIORITIES, "MAIN_TASK_PRIORITY must be less than configMAX_PRIORITIES");
vTaskPrioritySet(nullptr, MAIN_TASK_PRIORITY);
#endif
#if LT_GPIO_RECOVER
lt_gpio_recover();
#endif
}
void arch_restart() {
lt_reboot();
while (1) {
}
}
void HOT arch_feed_wdt() { lt_wdt_feed(); }
uint32_t arch_get_cpu_cycle_count() { return lt_cpu_get_cycle_count(); }
uint32_t arch_get_cpu_freq_hz() { return lt_cpu_get_freq(); }
} // namespace esphome
// HAL functions live in hal.cpp. core.cpp is intentionally empty for
// libretiny — there is no extra component bootstrap to keep here.
#endif // USE_LIBRETINY

View File

@@ -0,0 +1,53 @@
#ifdef USE_LIBRETINY
#include "core.h"
#include "esphome/core/hal.h"
#include "preferences.h"
#include <FreeRTOS.h>
#include <task.h>
// Empty libretiny namespace block to satisfy ci-custom's lint_namespace check.
// HAL functions live in namespace esphome (root) — they are not part of the
// libretiny component's API.
namespace esphome::libretiny {} // namespace esphome::libretiny
namespace esphome {
// yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(),
// arch_feed_wdt(), arch_get_cpu_cycle_count(), arch_get_cpu_freq_hz()
// inlined in components/libretiny/hal.h.
void arch_init() {
libretiny::setup_preferences();
lt_wdt_enable(10000L);
#ifdef USE_BK72XX
// BK72xx SDK creates the main Arduino task at priority 3, which is lower than
// all WiFi (4-5), LwIP (4), and TCP/IP (7) tasks. This causes ~100ms loop
// stalls whenever WiFi background processing runs, because the main task
// cannot resume until every higher-priority task finishes.
//
// By contrast, RTL87xx creates the main task at osPriorityRealtime (highest).
//
// Raise to priority 6: above WiFi/LwIP tasks (4-5) so they don't preempt the
// main loop, but below the TCP/IP thread (7) so packet processing keeps priority.
// This is safe because ESPHome yields voluntarily via wakeable_delay() and
// the Arduino mainTask yield() after each loop() iteration.
static constexpr UBaseType_t MAIN_TASK_PRIORITY = 6;
static_assert(MAIN_TASK_PRIORITY < configMAX_PRIORITIES, "MAIN_TASK_PRIORITY must be less than configMAX_PRIORITIES");
vTaskPrioritySet(nullptr, MAIN_TASK_PRIORITY);
#endif
#if LT_GPIO_RECOVER
lt_gpio_recover();
#endif
}
void arch_restart() {
lt_reboot();
while (1) {
}
}
} // namespace esphome
#endif // USE_LIBRETINY

View File

@@ -51,8 +51,18 @@ extern "C" void yield(void);
extern "C" void delay(unsigned long ms);
extern "C" unsigned long micros(void);
extern "C" unsigned long millis(void);
extern "C" void delayMicroseconds(unsigned int us);
// NOLINTEND(google-runtime-int,readability-identifier-naming,readability-redundant-declaration)
// Forward decls from libretiny's <lt_api.h> family for the inline arch_*
// wrappers below. Pulling the full header would drag in the rest of the
// LibreTiny C API.
extern "C" void lt_wdt_feed(void);
extern "C" uint32_t lt_cpu_get_cycle_count(void);
extern "C" uint32_t lt_cpu_get_freq(void);
namespace esphome::libretiny {}
namespace esphome {
/// Returns true when executing inside an interrupt handler.
@@ -88,9 +98,13 @@ __attribute__((always_inline)) inline uint32_t millis() { return static_cast<uin
#endif
__attribute__((always_inline)) inline uint64_t millis_64() { return Millis64Impl::compute(millis()); }
void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming)
void arch_feed_wdt();
uint32_t arch_get_cpu_cycle_count();
// NOLINTNEXTLINE(readability-identifier-naming)
__attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { ::delayMicroseconds(us); }
__attribute__((hot, always_inline)) inline void arch_feed_wdt() { lt_wdt_feed(); }
__attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() { return lt_cpu_get_cycle_count(); }
__attribute__((always_inline)) inline uint32_t arch_get_cpu_freq_hz() { return lt_cpu_get_freq(); }
void arch_init();
} // namespace esphome

View File

@@ -454,10 +454,12 @@ void LVTouchListener::update(const touchscreen::TouchPoints_t &tpoints) {
#ifdef USE_LVGL_METER
int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int value) {
int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value) {
auto *scale = lv_obj_get_parent(obj);
auto min_value = lv_scale_get_range_min_value(scale);
return ((value - min_value) * lv_scale_get_angle_range(scale) / (lv_scale_get_range_max_value(scale) - min_value) +
auto max_value = lv_scale_get_range_max_value(scale);
value = clamp(value, min_value, max_value);
return ((value - min_value) * lv_scale_get_angle_range(scale) / (max_value - min_value) +
lv_scale_get_rotation((scale))) %
360;
}

View File

@@ -112,7 +112,7 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector<image::Image *> images
#endif // USE_LVGL_ANIMIMG
#ifdef USE_LVGL_METER
int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int value);
int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value);
#endif
#ifdef USE_LVGL_GRADIENT

View File

@@ -39,7 +39,39 @@ MDNS_STATIC_CONST_CHAR(SERVICE_TCP, "_tcp");
// Wrap build-time defines into flash storage
MDNS_STATIC_CONST_CHAR(VALUE_VERSION, ESPHOME_VERSION);
void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services, char *mac_address_buf) {
void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_register) {
#ifdef USE_MDNS_STORE_SERVICES
auto &services = this->services_;
#else
StaticVector<MDNSService, MDNS_SERVICE_COUNT> services_storage;
auto &services = services_storage;
#endif
#ifdef USE_API
#ifdef USE_MDNS_STORE_SERVICES
get_mac_address_into_buffer(this->mac_address_);
char *mac_ptr = this->mac_address_;
format_hex_to(this->config_hash_str_, App.get_config_hash());
char *cfg_ptr = this->config_hash_str_;
#else
char mac_address[MAC_ADDRESS_BUFFER_SIZE];
char config_hash_str[CONFIG_HASH_STR_SIZE];
get_mac_address_into_buffer(mac_address);
format_hex_to(config_hash_str, App.get_config_hash());
char *mac_ptr = mac_address;
char *cfg_ptr = config_hash_str;
#endif
#else
char *mac_ptr = nullptr;
char *cfg_ptr = nullptr;
#endif
this->compile_records_(services, mac_ptr, cfg_ptr);
platform_register(this, services);
}
void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services, char *mac_address_buf,
char *config_hash_buf) {
// IMPORTANT: The #ifdef blocks below must match COMPONENTS_WITH_MDNS_SERVICES
// in mdns/__init__.py. If you add a new service here, update both locations.
@@ -47,6 +79,7 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
MDNS_STATIC_CONST_CHAR(SERVICE_ESPHOMELIB, "_esphomelib");
MDNS_STATIC_CONST_CHAR(TXT_FRIENDLY_NAME, "friendly_name");
MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version");
MDNS_STATIC_CONST_CHAR(TXT_CONFIG_HASH, "config_hash");
MDNS_STATIC_CONST_CHAR(TXT_MAC, "mac");
MDNS_STATIC_CONST_CHAR(TXT_PLATFORM, "platform");
MDNS_STATIC_CONST_CHAR(TXT_BOARD, "board");
@@ -63,7 +96,7 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
bool friendly_name_empty = friendly_name.empty();
// Calculate exact capacity for txt_records
size_t txt_count = 3; // version, mac, board (always present)
size_t txt_count = 4; // version, config_hash, mac, board (always present)
if (!friendly_name_empty) {
txt_count++; // friendly_name
}
@@ -91,6 +124,9 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
}
txt_records.push_back({MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)});
// Config hash: passed from caller (either member buffer or stack buffer depending on USE_MDNS_STORE_SERVICES)
txt_records.push_back({MDNS_STR(TXT_CONFIG_HASH), MDNS_STR(config_hash_buf)});
// MAC address: passed from caller (either member buffer or stack buffer depending on USE_MDNS_STORE_SERVICES)
txt_records.push_back({MDNS_STR(TXT_MAC), MDNS_STR(mac_address_buf)});

View File

@@ -70,6 +70,9 @@ class MDNSComponent final : public Component
void setup() override;
void dump_config() override;
/// Size of buffer required for config hash hex string (8 hex chars + null terminator)
static constexpr size_t CONFIG_HASH_STR_SIZE = format_hex_size(sizeof(uint32_t));
#ifdef USE_MDNS_EVENT_DRIVEN_POLLING
// LEAmDNS has meaningful work only during the probe+announce phase (3×250ms probes +
// 8×1000ms announces, ~9s). Afterwards every internal timer is resetToNeverExpires()
@@ -124,30 +127,7 @@ class MDNSComponent final : public Component
/// Helper to set up services and MAC buffers, then call platform-specific registration
using PlatformRegisterFn = void (*)(MDNSComponent *, StaticVector<MDNSService, MDNS_SERVICE_COUNT> &);
void setup_buffers_and_register_(PlatformRegisterFn platform_register) {
#ifdef USE_MDNS_STORE_SERVICES
auto &services = this->services_;
#else
StaticVector<MDNSService, MDNS_SERVICE_COUNT> services_storage;
auto &services = services_storage;
#endif
#ifdef USE_API
#ifdef USE_MDNS_STORE_SERVICES
get_mac_address_into_buffer(this->mac_address_);
char *mac_ptr = this->mac_address_;
#else
char mac_address[MAC_ADDRESS_BUFFER_SIZE];
get_mac_address_into_buffer(mac_address);
char *mac_ptr = mac_address;
#endif
#else
char *mac_ptr = nullptr;
#endif
this->compile_records_(services, mac_ptr);
platform_register(this, services);
}
void setup_buffers_and_register_(PlatformRegisterFn platform_register);
#ifdef USE_MDNS_DYNAMIC_TXT
/// Storage for runtime-generated TXT values from user lambdas
@@ -159,6 +139,8 @@ class MDNSComponent final : public Component
#if defined(USE_API) && defined(USE_MDNS_STORE_SERVICES)
/// Fixed buffer for MAC address (only needed when services are stored)
char mac_address_[MAC_ADDRESS_BUFFER_SIZE];
/// Fixed buffer for config hash hex string (only needed when services are stored)
char config_hash_str_[CONFIG_HASH_STR_SIZE];
#endif
#ifdef USE_MDNS_STORE_SERVICES
StaticVector<MDNSService, MDNS_SERVICE_COUNT> services_{};
@@ -167,7 +149,8 @@ class MDNSComponent final : public Component
// RP2040 defers MDNS.begin() until the first IP-up event; this tracks that.
bool initialized_{false};
#endif
void compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services, char *mac_address_buf);
void compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services, char *mac_address_buf,
char *config_hash_buf);
};
} // namespace esphome::mdns

View File

@@ -3,6 +3,8 @@
#include "esphome/components/network/ip_address.h"
#include "esphome/components/network/util.h"
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "mdns_component.h"
@@ -13,10 +15,13 @@ void MDNSComponent::setup() {
#ifdef USE_API
get_mac_address_into_buffer(this->mac_address_);
char *mac_ptr = this->mac_address_;
format_hex_to(this->config_hash_str_, App.get_config_hash());
char *cfg_ptr = this->config_hash_str_;
#else
char *mac_ptr = nullptr;
char *cfg_ptr = nullptr;
#endif
this->compile_records_(this->services_, mac_ptr);
this->compile_records_(this->services_, mac_ptr, cfg_ptr);
#endif
// Host platform doesn't have actual mDNS implementation
}

View File

@@ -88,6 +88,33 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) {
this->write_array(buffer, buffer_size);
App.feed_wdt();
this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true);
// Some Nextion firmware variants (notably bootloader/recovery mode on panels
// with no installed TFT) emit the 5-byte 0x08+position fast-mode ack with a
// multi-second gap between the leading 0x08 byte and the 4 trailing position
// bytes. recv_ret_string_ returns after the first byte; manually drain the
// trailing bytes from the UART before continuing.
if (!recv_string.empty() && recv_string[0] == 0x08 && recv_string.size() < 5) {
const uint32_t deadline = millis() + NEXTION_UPLOAD_ACK_TIMEOUT_MS;
while (recv_string.size() < 5 && millis() < deadline) {
if (this->available()) {
uint8_t b = 0;
if (this->read_byte(&b)) {
recv_string.push_back(static_cast<char>(b));
}
} else {
delay(5); // NOLINT
App.feed_wdt();
}
}
if (recv_string.size() < 5) {
ESP_LOGE(TAG, "Truncated 0x08 response: got %zu bytes within %" PRIu32 "ms", recv_string.size(),
NEXTION_UPLOAD_ACK_TIMEOUT_MS);
allocator.deallocate(buffer, 4096);
buffer = nullptr;
return -1;
}
}
this->content_length_ -= read_len;
const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_;
ESP_LOGD(TAG, "Upload: %0.2f%% (%" PRIu32 " left, heap: %" PRIu32 ")", upload_percentage, this->content_length_,

View File

@@ -104,6 +104,33 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r
this->write_array(buffer, buffer_size);
App.feed_wdt();
this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true);
// Some Nextion firmware variants (notably bootloader/recovery mode on panels
// with no installed TFT) emit the 5-byte 0x08+position fast-mode ack with a
// multi-second gap between the leading 0x08 byte and the 4 trailing position
// bytes. recv_ret_string_ returns after the first byte; manually drain the
// trailing bytes from the UART before continuing.
if (!recv_string.empty() && recv_string[0] == 0x08 && recv_string.size() < 5) {
const uint32_t deadline = millis() + NEXTION_UPLOAD_ACK_TIMEOUT_MS;
while (recv_string.size() < 5 && millis() < deadline) {
if (this->available()) {
uint8_t b = 0;
if (this->read_byte(&b)) {
recv_string.push_back(static_cast<char>(b));
}
} else {
vTaskDelay(pdMS_TO_TICKS(5)); // NOLINT
App.feed_wdt();
}
}
if (recv_string.size() < 5) {
ESP_LOGE(TAG, "Truncated 0x08 response: got %zu bytes within %" PRIu32 "ms", recv_string.size(),
NEXTION_UPLOAD_ACK_TIMEOUT_MS);
allocator.deallocate(buffer, 4096);
buffer = nullptr;
return -1;
}
}
this->content_length_ -= read_len;
const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_;
#ifdef USE_PSRAM

View File

@@ -4,6 +4,8 @@
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include <cstdint>
#ifdef USE_OTA_STATE_LISTENER
#include <vector>
#endif
@@ -23,6 +25,7 @@ enum OTAResponseTypes {
OTA_RESPONSE_UPDATE_END_OK = 0x45,
OTA_RESPONSE_SUPPORTS_COMPRESSION = 0x46,
OTA_RESPONSE_CHUNK_OK = 0x47,
OTA_RESPONSE_FEATURE_FLAGS = 0x48,
OTA_RESPONSE_ERROR_MAGIC = 0x80,
OTA_RESPONSE_ERROR_UPDATE_PREPARE = 0x81,
@@ -38,6 +41,7 @@ enum OTAResponseTypes {
OTA_RESPONSE_ERROR_MD5_MISMATCH = 0x8B,
OTA_RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE = 0x8C,
OTA_RESPONSE_ERROR_SIGNATURE_INVALID = 0x8D,
OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE = 0x8E,
OTA_RESPONSE_ERROR_UNKNOWN = 0xFF,
};
@@ -49,6 +53,10 @@ enum OTAState {
OTA_ERROR,
};
enum OTAType : uint8_t {
OTA_TYPE_UPDATE_APP = 0x00,
};
/** Listener interface for OTA state changes.
*
* Components can implement this interface to receive OTA state updates

View File

@@ -1,41 +1,6 @@
#ifdef USE_RP2040
#include "core.h"
#include "esphome/core/defines.h"
#ifdef USE_RP2040_CRASH_HANDLER
#include "crash_handler.h"
#endif
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "hardware/timer.h"
#include "hardware/watchdog.h"
namespace esphome {
// yield(), delay(), micros(), millis(), millis_64() inlined in hal.h.
void HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); }
void arch_restart() {
watchdog_reboot(0, 0, 10);
while (1) {
continue;
}
}
void arch_init() {
#ifdef USE_RP2040_CRASH_HANDLER
rp2040::crash_handler_read_and_clear();
#endif
#if USE_RP2040_WATCHDOG_TIMEOUT > 0
watchdog_enable(USE_RP2040_WATCHDOG_TIMEOUT, false);
#endif
}
void HOT arch_feed_wdt() { watchdog_update(); }
uint32_t HOT arch_get_cpu_cycle_count() { return ulMainGetRunTimeCounterValue(); }
uint32_t arch_get_cpu_freq_hz() { return RP2040::f_cpu(); }
} // namespace esphome
// HAL functions live in hal.cpp. core.cpp is intentionally empty for
// rp2040 — there is no extra component bootstrap to keep here.
#endif // USE_RP2040

View File

@@ -0,0 +1,41 @@
#ifdef USE_RP2040
#include "core.h"
#include "esphome/core/defines.h"
#include "esphome/core/hal.h"
#ifdef USE_RP2040_CRASH_HANDLER
#include "crash_handler.h"
#endif
#include "hardware/watchdog.h"
// Empty rp2040 namespace block to satisfy ci-custom's lint_namespace check.
// HAL functions live in namespace esphome (root) — they are not part of the
// rp2040 component's API.
namespace esphome::rp2040 {} // namespace esphome::rp2040
namespace esphome {
// yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(),
// arch_feed_wdt(), arch_get_cpu_cycle_count() inlined in components/rp2040/hal.h.
void arch_restart() {
watchdog_reboot(0, 0, 10);
while (1) {
continue;
}
}
void arch_init() {
#ifdef USE_RP2040_CRASH_HANDLER
rp2040::crash_handler_read_and_clear();
#endif
#if USE_RP2040_WATCHDOG_TIMEOUT > 0
watchdog_enable(USE_RP2040_WATCHDOG_TIMEOUT, false);
#endif
}
uint32_t arch_get_cpu_freq_hz() { return RP2040::f_cpu(); }
} // namespace esphome
#endif // USE_RP2040

View File

@@ -20,8 +20,19 @@ extern "C" unsigned long millis(void);
// Forward decl from <pico/time.h>.
extern "C" uint64_t time_us_64(void);
// Forward decls from pico-sdk / FreeRTOS port for the inline arch_*
// wrappers below.
extern "C" void watchdog_update(void);
extern "C" unsigned long ulMainGetRunTimeCounterValue(void);
namespace esphome::rp2040 {}
namespace esphome {
// Forward decl from helpers.h.
// NOLINTNEXTLINE(readability-redundant-declaration)
void delay_microseconds_safe(uint32_t us);
/// Returns true when executing inside an interrupt handler.
__attribute__((always_inline)) inline bool in_isr_context() {
uint32_t ipsr;
@@ -35,9 +46,15 @@ __attribute__((always_inline)) inline uint32_t micros() { return static_cast<uin
__attribute__((always_inline)) inline uint32_t millis() { return micros_to_millis(::time_us_64()); }
__attribute__((always_inline)) inline uint64_t millis_64() { return micros_to_millis<uint64_t>(::time_us_64()); }
void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming)
void arch_feed_wdt();
uint32_t arch_get_cpu_cycle_count();
// NOLINTNEXTLINE(readability-identifier-naming)
__attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); }
__attribute__((always_inline)) inline void arch_feed_wdt() { watchdog_update(); }
__attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() {
return static_cast<uint32_t>(ulMainGetRunTimeCounterValue());
}
void arch_init();
uint32_t arch_get_cpu_freq_hz();
} // namespace esphome

View File

@@ -24,6 +24,7 @@ CONF_SENDSPIN_ID = "sendspin_id"
CONF_INITIAL_STATIC_DELAY = "initial_static_delay"
CONF_FIXED_DELAY = "fixed_delay"
CONF_DECODE_MEMORY = "decode_memory"
# sendspin-cpp library lives in the global `sendspin` namespace.
sendspin_library_ns = cg.global_ns.namespace("sendspin")
@@ -39,6 +40,18 @@ CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED")
AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObject")
PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig")
# MemoryLocation enum (from sendspin/types.h) controls SPIRAM-vs-internal-RAM placement
# preference for the player role's transfer buffers.
SendspinMemoryLocation = sendspin_library_ns.enum("MemoryLocation", is_class=True)
MEMORY_PSRAM = "psram"
MEMORY_INTERNAL = "internal"
MEMORY_LOCATIONS = [MEMORY_PSRAM, MEMORY_INTERNAL]
MEMORY_LOCATION_ENUM = {
MEMORY_PSRAM: SendspinMemoryLocation.PREFER_EXTERNAL,
MEMORY_INTERNAL: SendspinMemoryLocation.PREFER_INTERNAL,
}
# Trailing underscore avoids clashing with sendspin-cpp's global `sendspin` namespace.
# Analysis tools strip the trailing underscore (same pattern as `template_`).
sendspin_ns = cg.esphome_ns.namespace("sendspin_")
@@ -193,7 +206,7 @@ async def to_code(config: ConfigType) -> None:
)
# sendspin-cpp library
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.3.1")
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.4.0")
cg.add_define("USE_SENDSPIN", True) # for MDNS
@@ -249,14 +262,23 @@ async def to_code(config: ConfigType) -> None:
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
)
player_config_struct = cg.StructInitializer(
PlayerRoleConfig,
# Library defaults: priority 18 (one above httpd_priority 17 so the decoder is not
# starved by the HTTP server during the initial encoded-audio burst at stream start),
# interpolation/decode buffer locations PREFER_EXTERNAL.
player_struct_fields = [
("audio_formats", audio_format_structs),
("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]),
("fixed_delay_us", player_cfg[CONF_FIXED_DELAY]),
("initial_static_delay_ms", player_cfg[CONF_INITIAL_STATIC_DELAY]),
("psram_stack", psram_stack),
("priority", 2),
]
if (decode_memory := player_cfg.get(CONF_DECODE_MEMORY)) is not None:
player_struct_fields.append(
("decode_buffer_location", MEMORY_LOCATION_ENUM[decode_memory])
)
player_config_struct = cg.StructInitializer(
PlayerRoleConfig,
*player_struct_fields,
)
cg.add(var.set_player_config(player_config_struct))
else:

View File

@@ -13,9 +13,11 @@ from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType
from .. import (
CONF_DECODE_MEMORY,
CONF_FIXED_DELAY,
CONF_INITIAL_STATIC_DELAY,
CONF_SENDSPIN_ID,
MEMORY_LOCATIONS,
SendspinHub,
_validate_task_stack_in_psram,
register_player_config,
@@ -57,6 +59,7 @@ def _register(config: ConfigType) -> ConfigType:
CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY],
CONF_FIXED_DELAY: config[CONF_FIXED_DELAY],
CONF_TASK_STACK_IN_PSRAM: config.get(CONF_TASK_STACK_IN_PSRAM, False),
CONF_DECODE_MEMORY: config.get(CONF_DECODE_MEMORY),
}
)
return config
@@ -82,6 +85,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_SAMPLE_RATE, default=48000): cv.int_range(
min=16000, max=96000
),
cv.Optional(CONF_DECODE_MEMORY): cv.one_of(*MEMORY_LOCATIONS, lower=True),
}
),
cv.only_on_esp32,

View File

@@ -266,7 +266,7 @@ StreamingMovingAverageFilter = sensor_ns.class_("StreamingMovingAverageFilter",
ExponentialMovingAverageFilter = sensor_ns.class_(
"ExponentialMovingAverageFilter", Filter
)
ThrottleAverageFilter = sensor_ns.class_("ThrottleAverageFilter", Filter, cg.Component)
ThrottleAverageFilter = sensor_ns.class_("ThrottleAverageFilter", Filter)
LambdaFilter = sensor_ns.class_("LambdaFilter", Filter)
StatelessLambdaFilter = sensor_ns.class_("StatelessLambdaFilter", Filter)
OffsetFilter = sensor_ns.class_("OffsetFilter", Filter)
@@ -283,8 +283,8 @@ ThrottleWithPriorityNanFilter = sensor_ns.class_(
TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component)
TimeoutFilterLast = sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase)
TimeoutFilterConfigured = sensor_ns.class_("TimeoutFilterConfigured", TimeoutFilterBase)
DebounceFilter = sensor_ns.class_("DebounceFilter", Filter, cg.Component)
HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter, cg.Component)
DebounceFilter = sensor_ns.class_("DebounceFilter", Filter)
HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter)
DeltaFilter = sensor_ns.class_("DeltaFilter", Filter)
OrFilter = sensor_ns.class_("OrFilter", Filter)
CalibrateLinearFilter = sensor_ns.class_("CalibrateLinearFilter", Filter)
@@ -564,12 +564,15 @@ async def exponential_moving_average_filter_to_code(config, filter_id):
@FILTER_REGISTRY.register(
"throttle_average", ThrottleAverageFilter, cv.positive_time_period_milliseconds
"throttle_average",
ThrottleAverageFilter,
cv.All(
cv.positive_time_period_milliseconds,
cv.Range(max=cv.TimePeriod(hours=24)),
),
)
async def throttle_average_filter_to_code(config, filter_id):
var = cg.new_Pvariable(filter_id, config)
await cg.register_component(var, {})
return var
return cg.new_Pvariable(filter_id, config)
@FILTER_REGISTRY.register("lambda", LambdaFilter, cv.returning_lambda)
@@ -698,13 +701,10 @@ HEARTBEAT_SCHEMA = cv.Schema(
async def heartbeat_filter_to_code(config, filter_id):
if isinstance(config, dict):
var = cg.new_Pvariable(filter_id, config[CONF_PERIOD])
await cg.register_component(var, {})
cg.add(var.set_optimistic(config[CONF_OPTIMISTIC]))
return var
var = cg.new_Pvariable(filter_id, config)
await cg.register_component(var, {})
return var
return cg.new_Pvariable(filter_id, config)
TIMEOUT_SCHEMA = cv.maybe_simple_value(
@@ -738,9 +738,7 @@ async def timeout_filter_to_code(config, filter_id):
"debounce", DebounceFilter, cv.positive_time_period_milliseconds
)
async def debounce_filter_to_code(config, filter_id):
var = cg.new_Pvariable(filter_id, config)
await cg.register_component(var, {})
return var
return cg.new_Pvariable(filter_id, config)
CONF_DATAPOINTS = "datapoints"

View File

@@ -13,11 +13,6 @@ namespace esphome::sensor {
static const char *const TAG = "sensor.filter";
// Filter scheduler IDs.
// Each filter is its own Component instance, so the scheduler scopes
// IDs by component pointer — no risk of collisions between instances.
constexpr uint32_t FILTER_ID = 0;
// Filter
void Filter::input(float value) {
ESP_LOGVV(TAG, "Filter(%p)::input(%f)", this, value);
@@ -185,8 +180,9 @@ optional<float> ThrottleAverageFilter::new_value(float value) {
}
return {};
}
void ThrottleAverageFilter::setup() {
this->set_interval(FILTER_ID, this->time_period_, [this]() {
void ThrottleAverageFilter::initialize(Sensor *parent, Filter *next) {
Filter::initialize(parent, next);
App.scheduler.set_interval(this, this->time_period_, [this]() {
ESP_LOGVV(TAG, "ThrottleAverageFilter(%p)::interval(sum=%f, n=%i)", this, this->sum_, this->n_);
if (this->n_ == 0) {
if (this->have_nan_)
@@ -199,7 +195,6 @@ void ThrottleAverageFilter::setup() {
this->have_nan_ = false;
});
}
float ThrottleAverageFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
// LambdaFilter
LambdaFilter::LambdaFilter(lambda_filter_t lambda_filter) : lambda_filter_(std::move(lambda_filter)) {}
@@ -362,13 +357,12 @@ optional<float> TimeoutFilterConfigured::new_value(float value) {
// DebounceFilter
optional<float> DebounceFilter::new_value(float value) {
this->set_timeout(FILTER_ID, this->time_period_, [this, value]() { this->output(value); });
App.scheduler.set_timeout(this, this->time_period_, [this, value]() { this->output(value); });
return {};
}
DebounceFilter::DebounceFilter(uint32_t time_period) : time_period_(time_period) {}
float DebounceFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
// HeartbeatFilter
HeartbeatFilter::HeartbeatFilter(uint32_t time_period) : time_period_(time_period), last_input_(NAN) {}
@@ -384,8 +378,9 @@ optional<float> HeartbeatFilter::new_value(float value) {
return {};
}
void HeartbeatFilter::setup() {
this->set_interval(FILTER_ID, this->time_period_, [this]() {
void HeartbeatFilter::initialize(Sensor *parent, Filter *next) {
Filter::initialize(parent, next);
App.scheduler.set_interval(this, this->time_period_, [this]() {
ESP_LOGVV(TAG, "HeartbeatFilter(%p)::interval(has_value=%s, last_input=%f)", this, YESNO(this->has_value_),
this->last_input_);
if (!this->has_value_)
@@ -395,8 +390,6 @@ void HeartbeatFilter::setup() {
});
}
float HeartbeatFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
optional<float> calibrate_linear_compute(const std::array<float, 3> *functions, size_t count, float value) {
for (size_t i = 0; i < count; i++) {
if (!std::isfinite(functions[i][2]) || value < functions[i][2])

View File

@@ -254,21 +254,22 @@ class ExponentialMovingAverageFilter : public Filter {
*
* It takes the average of all the values received in a period of time.
*/
class ThrottleAverageFilter : public Filter, public Component {
class ThrottleAverageFilter : public Filter {
public:
explicit ThrottleAverageFilter(uint32_t time_period);
void setup() override;
void initialize(Sensor *parent, Filter *next) override;
optional<float> new_value(float value) override;
float get_setup_priority() const override;
protected:
float sum_{0.0f};
unsigned int n_{0};
uint32_t time_period_;
bool have_nan_{false};
// Sample count packed with NaN-seen flag in a single 32-bit word.
// n_ is bounded by YAML cap on time_period_ (24 h) × max plausible source
// rate (1 kHz) = 86.4M ≪ 2^31, so 31 bits has 25x headroom.
uint32_t n_ : 31 {0};
uint32_t have_nan_ : 1 {0};
};
using lambda_filter_t = std::function<optional<float>(float)>;
@@ -454,25 +455,22 @@ class TimeoutFilterConfigured : public TimeoutFilterBase {
// Total: 8 (base) + 4 = 12 bytes + vtable ptr + Component overhead
};
class DebounceFilter : public Filter, public Component {
class DebounceFilter : public Filter {
public:
explicit DebounceFilter(uint32_t time_period);
optional<float> new_value(float value) override;
float get_setup_priority() const override;
protected:
uint32_t time_period_;
};
class HeartbeatFilter : public Filter, public Component {
class HeartbeatFilter : public Filter {
public:
explicit HeartbeatFilter(uint32_t time_period);
void setup() override;
void initialize(Sensor *parent, Filter *next) override;
optional<float> new_value(float value) override;
float get_setup_priority() const override;
void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; }

View File

@@ -1,3 +1,8 @@
import esphome.codegen as cg
st7789v_ns = cg.esphome_ns.namespace("st7789v")
DEPRECATED_COMPONENT = """
The 'st7789v' component is deprecated and no new functionality will be added to it.
PRs should target the newer and more performant 'mipi_spi' component.
"""

View File

@@ -1,3 +1,5 @@
import logging
from esphome import pins
import esphome.codegen as cg
from esphome.components import display, power_supply, spi
@@ -26,6 +28,8 @@ CODEOWNERS = ["@kbx81"]
DEPENDENCIES = ["spi"]
LOGGER = logging.getLogger(__name__)
ST7789V = st7789v_ns.class_(
"ST7789V", cg.PollingComponent, spi.SPIDevice, display.DisplayBuffer
)
@@ -175,6 +179,9 @@ FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema(
async def to_code(config):
LOGGER.warning(
"The 'st7789v' component is deprecated, it is recommended to use 'mipi_spi' instead."
)
var = cg.new_Pvariable(config[CONF_ID])
await display.register_display(var, config)
await spi.register_spi_device(var, config, write_only=True)

View File

@@ -10,6 +10,7 @@ from esphome.components.esp32 import (
)
import esphome.config_validation as cv
from esphome.const import CONF_DEVICES, CONF_ID
from esphome.core import CORE
from esphome.cpp_types import Component
from esphome.types import ConfigType
@@ -19,14 +20,15 @@ DEPENDENCIES = ["esp32"]
usb_host_ns = cg.esphome_ns.namespace("usb_host")
USBHost = usb_host_ns.class_("USBHost", Component)
USBClient = usb_host_ns.class_("USBClient", Component)
DOMAIN = "usb_host"
CONF_VID = "vid"
CONF_PID = "pid"
CONF_ENABLE_HUBS = "enable_hubs"
CONF_MAX_TRANSFER_REQUESTS = "max_transfer_requests"
CONF_MAX_PACKET_SIZE = "max_packet_size"
def usb_device_schema(cls=USBClient, vid: int = None, pid: [int] = None) -> cv.Schema:
def usb_device_schema(cls=USBClient, vid: int = None, pid: int = None) -> cv.Schema:
schema = cv.COMPONENT_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(cls),
@@ -43,6 +45,17 @@ def usb_device_schema(cls=USBClient, vid: int = None, pid: [int] = None) -> cv.S
return schema
def _set_max_packet_size(config: dict) -> dict:
CORE.data.setdefault(DOMAIN, {})[CONF_MAX_PACKET_SIZE] = config[
CONF_MAX_PACKET_SIZE
]
return config
def get_max_packet_size() -> int:
return CORE.data.get(DOMAIN, {}).get(CONF_MAX_PACKET_SIZE, 64)
CONFIG_SCHEMA = cv.All(
cv.COMPONENT_SCHEMA.extend(
{
@@ -51,10 +64,14 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_MAX_TRANSFER_REQUESTS, default=16): cv.int_range(
min=1, max=32
),
cv.Optional(CONF_MAX_PACKET_SIZE, default=64): cv.one_of(
64, 128, 256, 512, 1024, int=True
),
cv.Optional(CONF_DEVICES): cv.ensure_list(usb_device_schema()),
}
),
only_on_variant(supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3]),
_set_max_packet_size,
)
@@ -72,8 +89,8 @@ async def to_code(config: ConfigType) -> None:
if config.get(CONF_ENABLE_HUBS):
add_idf_sdkconfig_option("CONFIG_USB_HOST_HUBS_SUPPORTED", True)
max_requests = config[CONF_MAX_TRANSFER_REQUESTS]
cg.add_define("USB_HOST_MAX_REQUESTS", max_requests)
cg.add_define("USB_HOST_MAX_REQUESTS", config[CONF_MAX_TRANSFER_REQUESTS])
cg.add_define("USB_HOST_MAX_PACKET_SIZE", config[CONF_MAX_PACKET_SIZE])
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)

View File

@@ -66,6 +66,8 @@ static_assert(MAX_REQUESTS >= 1 && MAX_REQUESTS <= 32, "MAX_REQUESTS must be bet
using trq_bitmask_t = std::conditional<(MAX_REQUESTS <= 16), uint16_t, uint32_t>::type;
static constexpr trq_bitmask_t ALL_REQUESTS_IN_USE = MAX_REQUESTS == 32 ? ~0 : (1 << MAX_REQUESTS) - 1;
static constexpr size_t USB_MAX_PACKET_SIZE =
USB_HOST_MAX_PACKET_SIZE; // Max USB packet size (64 for FS, 512 for P4 HS)
static constexpr size_t USB_EVENT_QUEUE_SIZE = 32; // Size of event queue between USB task and main loop
static constexpr size_t USB_TASK_STACK_SIZE = 4096; // Stack size for USB task (same as ESP-IDF USB examples)
static constexpr UBaseType_t USB_TASK_PRIORITY = 5; // Higher priority than main loop (tskIDLE_PRIORITY + 5)

View File

@@ -217,7 +217,7 @@ void USBClient::setup() {
// Pre-allocate USB transfer buffers for all slots at startup
// This avoids any dynamic allocation during runtime
for (auto &request : this->requests_) {
usb_host_transfer_alloc(64, 0, &request.transfer);
usb_host_transfer_alloc(USB_MAX_PACKET_SIZE, 0, &request.transfer);
request.client = this; // Set once, never changes
}

View File

@@ -1,7 +1,11 @@
import esphome.codegen as cg
from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS
from esphome.components.uart import CONF_DEBUG_PREFIX, CONF_FLUSH_TIMEOUT, UARTComponent
from esphome.components.usb_host import register_usb_client, usb_device_schema
from esphome.components.usb_host import (
get_max_packet_size,
register_usb_client,
usb_device_schema,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_BAUD_RATE,
@@ -118,14 +122,14 @@ CONFIG_SCHEMA = cv.ensure_list(
async def to_code(config):
# The output chunk pool/queue are compile-time-sized templates shared by all
# USBUartChannel instances, so use the largest buffer_size across every channel
# of every device. Each chunk is 64 bytes (USB FS MPS); add one extra slot
# because LockFreeQueue<T,N> is a ring buffer that wastes one entry.
# of every device. Add one extra slot because LockFreeQueue<T,N> is a ring
# buffer that wastes one entry.
max_buffer_size = max(
channel[CONF_BUFFER_SIZE]
for device in config
for channel in device[CONF_CHANNELS]
)
output_chunk_count = max_buffer_size // 64 + 1
output_chunk_count = max(max_buffer_size // get_max_packet_size(), 2) + 1
cg.add_define("USB_UART_OUTPUT_CHUNK_COUNT", output_chunk_count)
for device in config:

View File

@@ -157,7 +157,7 @@ void USBUartChannel::write_array(const uint8_t *data, size_t len) {
ESP_LOGE(TAG, "Output pool full - lost %zu bytes", len);
break;
}
size_t chunk_len = std::min(len, UsbOutputChunk::MAX_CHUNK_SIZE);
uint16_t chunk_len = std::min(len, UsbOutputChunk::MAX_CHUNK_SIZE);
memcpy(chunk->data, data, chunk_len);
chunk->length = static_cast<uint8_t>(chunk_len);
// Push always succeeds: pool is sized to queue capacity (SIZE-1), so if
@@ -222,7 +222,7 @@ void USBUartComponent::loop() {
#ifdef USE_UART_DEBUGGER
if (channel->debug_) {
char buf[4 + format_hex_pretty_size(UsbDataChunk::MAX_CHUNK_SIZE)]; // "<<< " + hex
char buf[4 + format_hex_pretty_size(usb_host::USB_MAX_PACKET_SIZE)]; // "<<< " + hex
memcpy(buf, "<<< ", 4);
format_hex_pretty_to(buf + 4, sizeof(buf) - 4, chunk->data, chunk->length, ',');
ESP_LOGD(TAG, "%s%s", channel->debug_prefix_.c_str(), buf);
@@ -377,7 +377,7 @@ void USBUartComponent::start_output(USBUartChannel *channel) {
this->start_output(channel);
};
const uint8_t len = chunk->length;
const auto len = chunk->length;
if (!this->transfer_out(ep->bEndpointAddress, callback, chunk->data, len)) {
// Transfer submission failed — return chunk and release flag so callers can retry.
channel->output_pool_.release(chunk);
@@ -394,10 +394,10 @@ void USBUartComponent::start_output(USBUartChannel *channel) {
static void fix_mps(const usb_ep_desc_t *ep) {
if (ep != nullptr) {
auto *ep_mutable = const_cast<usb_ep_desc_t *>(ep);
if (ep->wMaxPacketSize > 64) {
ESP_LOGW(TAG, "Corrected MPS of EP 0x%02X from %u to 64", static_cast<uint8_t>(ep->bEndpointAddress & 0xFF),
ep->wMaxPacketSize);
ep_mutable->wMaxPacketSize = 64;
if (ep->wMaxPacketSize > usb_host::USB_MAX_PACKET_SIZE) {
ESP_LOGW(TAG, "Corrected MPS of EP 0x%02X from %u to %u", static_cast<uint8_t>(ep->bEndpointAddress & 0xFF),
ep->wMaxPacketSize, usb_host::USB_MAX_PACKET_SIZE);
ep_mutable->wMaxPacketSize = usb_host::USB_MAX_PACKET_SIZE;
}
}
}

View File

@@ -106,20 +106,19 @@ class RingBuffer {
// Structure for queuing received USB data chunks
struct UsbDataChunk {
static constexpr size_t MAX_CHUNK_SIZE = 64; // USB packet size
uint8_t data[MAX_CHUNK_SIZE];
uint8_t length; // Max 64 bytes, so uint8_t is sufficient
uint8_t data[usb_host::USB_MAX_PACKET_SIZE];
uint16_t length;
USBUartChannel *channel;
// Required for EventPool - no cleanup needed for POD types
void release() {}
};
// Structure for queuing outgoing USB data chunks (one per USB FS packet)
// Structure for queuing outgoing USB data chunks (one per USB packet)
struct UsbOutputChunk {
static constexpr size_t MAX_CHUNK_SIZE = 64; // USB FS MPS
static constexpr size_t MAX_CHUNK_SIZE = usb_host::USB_MAX_PACKET_SIZE;
uint8_t data[MAX_CHUNK_SIZE];
uint8_t length;
uint16_t length;
// Required for EventPool - no cleanup needed for POD types
void release() {}

View File

@@ -1,8 +1,6 @@
#ifdef USE_ZEPHYR
#include <zephyr/kernel.h>
#include <zephyr/drivers/watchdog.h>
#include <zephyr/sys/reboot.h>
#include <zephyr/random/random.h>
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
@@ -10,55 +8,7 @@
namespace esphome {
#ifdef CONFIG_WATCHDOG
static int wdt_channel_id = -1; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static const device *const WDT = DEVICE_DT_GET(DT_ALIAS(watchdog0));
#endif
void yield() { ::k_yield(); }
uint32_t millis() { return static_cast<uint32_t>(millis_64()); }
uint64_t millis_64() { return static_cast<uint64_t>(k_uptime_get()); }
uint32_t micros() { return k_ticks_to_us_floor32(k_uptime_ticks()); }
void delayMicroseconds(uint32_t us) { ::k_usleep(us); }
void delay(uint32_t ms) { ::k_msleep(ms); }
void arch_init() {
#ifdef CONFIG_WATCHDOG
if (device_is_ready(WDT)) {
static wdt_timeout_cfg wdt_config{};
wdt_config.flags = WDT_FLAG_RESET_SOC;
#ifdef USE_ZIGBEE
// zboss thread use a lot of cpu cycles during start
wdt_config.window.max = 10000;
#else
wdt_config.window.max = 2000;
#endif
wdt_channel_id = wdt_install_timeout(WDT, &wdt_config);
if (wdt_channel_id >= 0) {
uint8_t options = 0;
#ifdef USE_DEBUG
options |= WDT_OPT_PAUSE_HALTED_BY_DBG;
#endif
#ifdef USE_DEEP_SLEEP
options |= WDT_OPT_PAUSE_IN_SLEEP;
#endif
wdt_setup(WDT, options);
}
}
#endif
}
void arch_feed_wdt() {
#ifdef CONFIG_WATCHDOG
if (wdt_channel_id >= 0) {
wdt_feed(WDT, wdt_channel_id);
}
#endif
}
void arch_restart() { sys_reboot(SYS_REBOOT_COLD); }
uint32_t arch_get_cpu_cycle_count() { return k_cycle_get_32(); }
uint32_t arch_get_cpu_freq_hz() { return sys_clock_hw_cycles_per_sec(); }
// HAL functions live in hal.cpp.
Mutex::Mutex() {
auto *mutex = new k_mutex();

View File

@@ -0,0 +1,63 @@
#ifdef USE_ZEPHYR
#include "esphome/core/defines.h"
#include "esphome/core/hal.h"
#include <zephyr/drivers/watchdog.h>
#include <zephyr/sys/reboot.h>
// Empty zephyr namespace block to satisfy ci-custom's lint_namespace check.
// HAL functions live in namespace esphome (root) — they are not part of the
// zephyr component's API.
namespace esphome::zephyr {} // namespace esphome::zephyr
namespace esphome {
#ifdef CONFIG_WATCHDOG
static int wdt_channel_id = -1; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static const device *const WDT = DEVICE_DT_GET(DT_ALIAS(watchdog0));
#endif
// yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(),
// arch_get_cpu_cycle_count(), arch_get_cpu_freq_hz() inlined in
// components/zephyr/hal.h.
void arch_init() {
#ifdef CONFIG_WATCHDOG
if (device_is_ready(WDT)) {
static wdt_timeout_cfg wdt_config{};
wdt_config.flags = WDT_FLAG_RESET_SOC;
#ifdef USE_ZIGBEE
// zboss thread uses a lot of CPU cycles during startup
wdt_config.window.max = 10000;
#else
wdt_config.window.max = 2000;
#endif
wdt_channel_id = wdt_install_timeout(WDT, &wdt_config);
if (wdt_channel_id >= 0) {
uint8_t options = 0;
#ifdef USE_DEBUG
options |= WDT_OPT_PAUSE_HALTED_BY_DBG;
#endif
#ifdef USE_DEEP_SLEEP
options |= WDT_OPT_PAUSE_IN_SLEEP;
#endif
wdt_setup(WDT, options);
}
}
#endif
}
void arch_feed_wdt() {
#ifdef CONFIG_WATCHDOG
if (wdt_channel_id >= 0) {
wdt_feed(WDT, wdt_channel_id);
}
#endif
}
void arch_restart() { sys_reboot(SYS_REBOOT_COLD); }
} // namespace esphome
#endif // USE_ZEPHYR

View File

@@ -0,0 +1,36 @@
#pragma once
#ifdef USE_ZEPHYR
#include <cstdint>
#include <zephyr/kernel.h>
#define IRAM_ATTR
#define PROGMEM
namespace esphome::zephyr {}
namespace esphome {
/// Returns true when executing inside an interrupt handler.
/// Zephyr/nRF52: not currently consulted — wake path is platform-specific.
__attribute__((always_inline)) inline bool in_isr_context() { return false; }
__attribute__((always_inline)) inline void yield() { ::k_yield(); }
__attribute__((always_inline)) inline void delay(uint32_t ms) { ::k_msleep(ms); }
__attribute__((always_inline)) inline uint32_t micros() { return k_ticks_to_us_floor32(k_uptime_ticks()); }
__attribute__((always_inline)) inline uint64_t millis_64() { return static_cast<uint64_t>(k_uptime_get()); }
__attribute__((always_inline)) inline uint32_t millis() { return static_cast<uint32_t>(millis_64()); }
// NOLINTNEXTLINE(readability-identifier-naming)
__attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { ::k_usleep(us); }
__attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() { return k_cycle_get_32(); }
__attribute__((always_inline)) inline uint32_t arch_get_cpu_freq_hz() { return sys_clock_hw_cycles_per_sec(); }
void arch_feed_wdt();
void arch_init();
} // namespace esphome
#endif // USE_ZEPHYR

View File

@@ -9,6 +9,7 @@ from esphome.components.esp32 import (
add_idf_component,
add_idf_sdkconfig_option,
add_partition,
idf_version,
require_vfs_select,
)
import esphome.config_validation as cv
@@ -186,6 +187,10 @@ async def _zigbee_add_sdkconfigs(config: ConfigType) -> None:
# The pre-built Zigbee library uses esp_log_default_level which requires
# dynamic log level control to be enabled
add_idf_sdkconfig_option("CONFIG_LOG_DYNAMIC_LEVEL_CONTROL", True)
# The pre-built Zigbee library is compiled against newlib which requires newlib
# reentrancy to be enabled with picolibc compatibility.
if idf_version() >= cv.Version(6, 0, 0):
add_idf_sdkconfig_option("CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY", True)
async def attributes_to_code(

View File

@@ -1,4 +1,4 @@
"""Constants used by esphome."""
"""Constants used by ESPHome."""
from enum import Enum

View File

@@ -637,10 +637,12 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
// flag preserves it. wake_request_take() exchange-clears the flag; wakes
// that arrive during Phase B re-set it and run Phase B again on the next
// iteration.
const bool high_frequency = HighFrequencyLoopRequester::is_high_frequency();
const uint32_t elapsed = now - this->last_loop_;
const bool woke = esphome::wake_request_take();
const bool do_component_phase = high_frequency || woke || (elapsed >= this->loop_interval_);
//
// wake_request_take() must always be called first since it does an
// atomic exchange to clear the flag, and we want to run the component phase
// if either the flag was set or the scheduler requested a high-frequency loop.
const bool do_component_phase = esphome::wake_request_take() || HighFrequencyLoopRequester::is_high_frequency() ||
(now - this->last_loop_ >= this->loop_interval_);
if (do_component_phase) {
ComponentPhaseGuard phase_guard{*this};

View File

@@ -65,7 +65,6 @@ inline constexpr uint32_t SCHEDULER_DONT_RUN = 4294967295UL;
/// with component-level NUMERIC_ID values, even if the uint32_t values overlap.
enum class InternalSchedulerID : uint32_t {
POLLING_UPDATE = 0, // PollingComponent interval
DELAY_ACTION = 1, // DelayAction timeout
};
// Forward declaration

View File

@@ -8,22 +8,22 @@
// Per-platform HAL bits (IRAM_ATTR / PROGMEM macros, in_isr_context(),
// inline yield/delay/micros/millis/millis_64 wrappers, ESP8266 progmem
// helpers) live under esphome/core/hal/ and are dispatched here based on
// the active USE_* platform define. Each header guards its body with the
// matching #ifdef USE_<platform> and re-enters namespace esphome {} so it
// is safe to be re-included.
// helpers) live next to each platform component as components/<platform>/hal.h
// and are dispatched here based on the active USE_* platform define. Each
// header guards its body with the matching #ifdef USE_<platform> and re-enters
// namespace esphome {} so it is safe to be re-included.
#if defined(USE_ESP32)
#include "esphome/core/hal/hal_esp32.h"
#include "esphome/components/esp32/hal.h"
#elif defined(USE_ESP8266)
#include "esphome/core/hal/hal_esp8266.h"
#include "esphome/components/esp8266/hal.h"
#elif defined(USE_LIBRETINY)
#include "esphome/core/hal/hal_libretiny.h"
#include "esphome/components/libretiny/hal.h"
#elif defined(USE_RP2040)
#include "esphome/core/hal/hal_rp2040.h"
#include "esphome/components/rp2040/hal.h"
#elif defined(USE_HOST)
#include "esphome/core/hal/hal_host.h"
#include "esphome/components/host/hal.h"
#elif defined(USE_ZEPHYR)
#include "esphome/core/hal/hal_zephyr.h"
#include "esphome/components/zephyr/hal.h"
#else
#error "hal.h: not implemented for this platform"
#endif
@@ -31,15 +31,14 @@
namespace esphome {
// Cross-platform declarations. delayMicroseconds(), arch_feed_wdt(),
// arch_get_cpu_cycle_count() vary per platform (some inline, some
// out-of-line) so they live in hal/hal_<platform>.h.
// arch_get_cpu_cycle_count(), arch_init(), arch_get_cpu_freq_hz() vary
// per platform (some inline, some out-of-line) so they live in
// components/<platform>/hal.h.
void __attribute__((noreturn)) arch_restart();
void arch_init();
uint32_t arch_get_cpu_freq_hz();
#ifndef USE_ESP8266
// All non-ESP8266 platforms: PROGMEM is a no-op, so these are direct dereferences.
// ESP8266's out-of-line declarations live in hal/hal_esp8266.h.
// ESP8266's out-of-line declarations live in components/esp8266/hal.h.
inline uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; }
inline const char *progmem_read_ptr(const char *const *addr) { return *addr; }
inline uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; }

View File

@@ -1,28 +0,0 @@
#pragma once
#ifdef USE_ZEPHYR
#include <cstdint>
#define IRAM_ATTR
#define PROGMEM
namespace esphome {
/// Returns true when executing inside an interrupt handler.
/// Zephyr/nRF52: not currently consulted — wake path is platform-specific.
__attribute__((always_inline)) inline bool in_isr_context() { return false; }
void yield();
void delay(uint32_t ms);
uint32_t micros();
uint32_t millis();
uint64_t millis_64();
void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming)
void arch_feed_wdt();
uint32_t arch_get_cpu_cycle_count();
} // namespace esphome
#endif // USE_ZEPHYR

View File

@@ -2045,7 +2045,8 @@ void delay_microseconds_safe(uint32_t us);
* Returns `nullptr` in case no memory is available.
*
* By setting flags, it can be configured to:
* - perform external allocation falling back to main memory if SPI RAM is full or unavailable
* - perform external allocation falling back to internal memory if SPI RAM is full or unavailable (default)
* - perform internal allocation falling back to external memory (with PREFER_INTERNAL)
* - perform external allocation only
* - perform internal allocation only
*/
@@ -2054,16 +2055,26 @@ template<class T> class RAMAllocator {
using value_type = T;
enum Flags {
NONE = 0, // Perform external allocation and fall back to internal memory
ALLOC_EXTERNAL = 1 << 0, // Perform external allocation only.
ALLOC_INTERNAL = 1 << 1, // Perform internal allocation only.
ALLOW_FAILURE = 1 << 2, // Does nothing. Kept for compatibility.
NONE = 0, // Perform external allocation and fall back to internal memory
ALLOC_EXTERNAL = 1 << 0, // Perform external allocation only.
ALLOC_INTERNAL = 1 << 1, // Perform internal allocation only.
ALLOW_FAILURE = 1 << 2, // Does nothing. Kept for compatibility.
PREFER_INTERNAL = 1 << 3, // Perform internal allocation and fall back to external memory
};
constexpr RAMAllocator() = default;
constexpr RAMAllocator(uint8_t flags)
: flags_((flags & (ALLOC_INTERNAL | ALLOC_EXTERNAL)) != 0 ? (flags & (ALLOC_INTERNAL | ALLOC_EXTERNAL))
: (ALLOC_INTERNAL | ALLOC_EXTERNAL)) {}
constexpr RAMAllocator(uint8_t flags) {
if (flags & PREFER_INTERNAL) {
this->flags_ = ALLOC_INTERNAL | ALLOC_EXTERNAL | PREFER_INTERNAL;
return;
}
const uint8_t alloc_bits = flags & (ALLOC_INTERNAL | ALLOC_EXTERNAL);
if (alloc_bits != 0) {
this->flags_ = alloc_bits;
return;
}
this->flags_ = ALLOC_INTERNAL | ALLOC_EXTERNAL;
}
template<class U> constexpr RAMAllocator(const RAMAllocator<U> &other) : flags_{other.flags_} {}
T *allocate(size_t n) { return this->allocate(n, sizeof(T)); }
@@ -2072,12 +2083,8 @@ template<class T> class RAMAllocator {
size_t size = n * manual_size;
T *ptr = nullptr;
#ifdef USE_ESP32
if (this->flags_ & Flags::ALLOC_EXTERNAL) {
ptr = static_cast<T *>(heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
}
if (ptr == nullptr && this->flags_ & Flags::ALLOC_INTERNAL) {
ptr = static_cast<T *>(heap_caps_malloc(size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT));
}
const auto caps = this->get_caps_();
ptr = static_cast<T *>(heap_caps_malloc_prefer(size, 2, caps[0], caps[1]));
#else
// Ignore ALLOC_EXTERNAL/ALLOC_INTERNAL flags if external allocation is not supported
ptr = static_cast<T *>(malloc(size)); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc)
@@ -2091,12 +2098,8 @@ template<class T> class RAMAllocator {
size_t size = n * manual_size;
T *ptr = nullptr;
#ifdef USE_ESP32
if (this->flags_ & Flags::ALLOC_EXTERNAL) {
ptr = static_cast<T *>(heap_caps_realloc(p, size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
}
if (ptr == nullptr && this->flags_ & Flags::ALLOC_INTERNAL) {
ptr = static_cast<T *>(heap_caps_realloc(p, size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT));
}
const auto caps = this->get_caps_();
ptr = static_cast<T *>(heap_caps_realloc_prefer(p, size, 2, caps[0], caps[1]));
#else
// Ignore ALLOC_EXTERNAL/ALLOC_INTERNAL flags if external allocation is not supported
ptr = static_cast<T *>(realloc(p, size)); // NOLINT(cppcoreguidelines-owning-memory,cppcoreguidelines-no-malloc)
@@ -2147,6 +2150,24 @@ template<class T> class RAMAllocator {
}
private:
#ifdef USE_ESP32
/// Returns {primary_caps, fallback_caps} for heap_caps_*_prefer based on the configured flags.
/// PREFER_INTERNAL implies both regions are enabled (enforced by the constructor), so when it is set
/// the primary is internal and the fallback is external. Otherwise the primary is whichever region
/// is enabled (external preferred when both are enabled), and the fallback is the other region (or
/// the same region when only one is enabled, making the second attempt a no-op).
std::array<uint32_t, 2> get_caps_() const {
constexpr uint32_t external_caps = MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT;
constexpr uint32_t internal_caps = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT;
if (this->flags_ & PREFER_INTERNAL) {
return {internal_caps, external_caps};
}
const uint32_t primary = (this->flags_ & ALLOC_EXTERNAL) ? external_caps : internal_caps;
const uint32_t fallback = (this->flags_ & ALLOC_INTERNAL) ? internal_caps : external_caps;
return {primary, fallback};
}
#endif
uint8_t flags_{ALLOC_INTERNAL | ALLOC_EXTERNAL};
};

View File

@@ -1,11 +1,9 @@
#include "ring_buffer.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifdef USE_ESP32
#include "helpers.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome {
@@ -19,12 +17,15 @@ RingBuffer::~RingBuffer() {
}
}
std::unique_ptr<RingBuffer> RingBuffer::create(size_t len) {
std::unique_ptr<RingBuffer> RingBuffer::create(size_t len, MemoryPreference preference) {
std::unique_ptr<RingBuffer> rb = make_unique<RingBuffer>();
rb->size_ = len;
RAMAllocator<uint8_t> allocator;
const uint8_t type = (preference == MemoryPreference::INTERNAL_FIRST) ? RAMAllocator<uint8_t>::PREFER_INTERNAL
: RAMAllocator<uint8_t>::NONE;
RAMAllocator<uint8_t> allocator(type);
rb->storage_ = allocator.allocate(rb->size_);
if (rb->storage_ == nullptr) {
return nullptr;

View File

@@ -80,7 +80,12 @@ class RingBuffer {
*/
BaseType_t reset();
static std::unique_ptr<RingBuffer> create(size_t len);
enum class MemoryPreference {
EXTERNAL_FIRST, // External RAM preferred, fall back to internal (default)
INTERNAL_FIRST, // Internal RAM preferred, fall back to external
};
static std::unique_ptr<RingBuffer> create(size_t len, MemoryPreference preference = MemoryPreference::EXTERNAL_FIRST);
protected:
/// @brief Discards data from the ring buffer.

View File

@@ -15,6 +15,8 @@ from typing import Any
from esphome.core import EsphomeError
from esphome.helpers import ProgressBar, resolve_ip_address
OTA_TYPE_UPDATE_APP = 0x00
RESPONSE_OK = 0x00
RESPONSE_REQUEST_AUTH = 0x01
RESPONSE_REQUEST_SHA256_AUTH = 0x02
@@ -27,6 +29,7 @@ RESPONSE_RECEIVE_OK = 0x44
RESPONSE_UPDATE_END_OK = 0x45
RESPONSE_SUPPORTS_COMPRESSION = 0x46
RESPONSE_CHUNK_OK = 0x47
RESPONSE_FEATURE_FLAGS = 0x48
RESPONSE_ERROR_MAGIC = 0x80
RESPONSE_ERROR_UPDATE_PREPARE = 0x81
@@ -42,6 +45,7 @@ RESPONSE_ERROR_NO_UPDATE_PARTITION = 0x8A
RESPONSE_ERROR_MD5_MISMATCH = 0x8B
RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE = 0x8C
RESPONSE_ERROR_SIGNATURE_INVALID = 0x8D
RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE = 0x8E
RESPONSE_ERROR_UNKNOWN = 0xFF
OTA_VERSION_1_0 = 1
@@ -49,9 +53,16 @@ OTA_VERSION_2_0 = 2
MAGIC_BYTES = [0x6C, 0x26, 0xF7, 0x5C, 0x45]
FEATURE_SUPPORTS_COMPRESSION = 0x01
FEATURE_SUPPORTS_SHA256_AUTH = 0x02
CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01
CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02
CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04
SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01
SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02
# OTA types this client knows how to send. Future PRs that add bootloader/partition
# updates extend this set. Anything outside the set is rejected up front so callers
# of perform_ota/run_ota get a clear error instead of a post-auth 0x8E from the device.
_SUPPORTED_OTA_TYPES: frozenset[int] = frozenset({OTA_TYPE_UPDATE_APP})
UPLOAD_BLOCK_SIZE = 8192
UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8
@@ -64,6 +75,62 @@ _AUTH_METHODS: dict[int, tuple[Callable[..., Any], int, str]] = {
RESPONSE_REQUEST_AUTH: (hashlib.md5, 32, "MD5"),
}
# Error response code -> human-readable message (without the "Error: " prefix; check_error()
# prepends it uniformly). Looked up by check_error() to translate a single byte from the device
# into an OTAError. Add new error codes here rather than extending the if-chain in check_error().
_ERROR_MESSAGES: dict[int, str] = {
RESPONSE_ERROR_MAGIC: "Invalid magic byte",
RESPONSE_ERROR_UPDATE_PREPARE: (
"Couldn't prepare flash memory for update. Is the binary too big? "
"Please try restarting the ESP."
),
RESPONSE_ERROR_AUTH_INVALID: "Authentication invalid. Is the password correct?",
RESPONSE_ERROR_WRITING_FLASH: (
"Writing OTA data to flash memory failed. See USB logs for more information."
),
RESPONSE_ERROR_UPDATE_END: (
"Finishing update failed. See the MQTT/USB logs for more information."
),
RESPONSE_ERROR_INVALID_BOOTSTRAPPING: (
"Please press the reset button on the ESP. A manual reset is "
"required on the first OTA-Update after flashing via USB."
),
RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG: (
"ESP has been flashed with wrong flash size. Please choose the "
"correct 'board' option (esp01_1m always works) and then flash over USB."
),
RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG: (
"ESP does not have the requested flash size (wrong board). Please "
"choose the correct 'board' option (esp01_1m always works) and try "
"uploading again."
),
RESPONSE_ERROR_ESP8266_NOT_ENOUGH_SPACE: (
"ESP does not have enough space to store OTA file. Please try "
"flashing a minimal firmware (remove everything except ota)"
),
RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE: (
"The OTA partition on the ESP is too small. ESPHome needs to resize "
"this partition, please flash over USB."
),
RESPONSE_ERROR_NO_UPDATE_PARTITION: (
"The OTA partition on the ESP couldn't be found. ESPHome needs to "
"create this partition, please flash over USB."
),
RESPONSE_ERROR_MD5_MISMATCH: (
"Application MD5 code mismatch. Please try again "
"or flash over USB with a good quality cable."
),
RESPONSE_ERROR_SIGNATURE_INVALID: (
"Firmware signature verification failed. The firmware was not signed "
"with the correct key. Ensure the signing key matches the one used to build "
"the firmware currently running on the device."
),
RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE: (
"The requested OTA type is not supported by the device."
),
RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP",
}
class OTAError(EsphomeError):
pass
@@ -130,8 +197,10 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None
:param expect: Expected response code(s), None to skip validation.
:raises OTAError: If an error code is detected or response doesn't match expected.
"""
if expect is None:
return
# Detect device errors and connection-closed cases regardless of `expect`. If we
# only ran these checks when expect was set, error bytes returned during
# accept-any-response reads (e.g. feature negotiation, auth nonces) would be
# silently passed through and surface later as cryptic decode/timeout failures.
if not data:
raise OTAError(
"Error: Device closed connection without responding. "
@@ -139,69 +208,11 @@ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None
"a network issue, or the connection was interrupted."
)
dat = data[0]
if dat == RESPONSE_ERROR_MAGIC:
raise OTAError("Error: Invalid magic byte")
if dat == RESPONSE_ERROR_UPDATE_PREPARE:
raise OTAError(
"Error: Couldn't prepare flash memory for update. Is the binary too big? "
"Please try restarting the ESP."
)
if dat == RESPONSE_ERROR_AUTH_INVALID:
raise OTAError("Error: Authentication invalid. Is the password correct?")
if dat == RESPONSE_ERROR_WRITING_FLASH:
raise OTAError(
"Error: Writing OTA data to flash memory failed. See USB logs for more "
"information."
)
if dat == RESPONSE_ERROR_UPDATE_END:
raise OTAError(
"Error: Finishing update failed. See the MQTT/USB logs for more "
"information."
)
if dat == RESPONSE_ERROR_INVALID_BOOTSTRAPPING:
raise OTAError(
"Error: Please press the reset button on the ESP. A manual reset is "
"required on the first OTA-Update after flashing via USB."
)
if dat == RESPONSE_ERROR_WRONG_CURRENT_FLASH_CONFIG:
raise OTAError(
"Error: ESP has been flashed with wrong flash size. Please choose the "
"correct 'board' option (esp01_1m always works) and then flash over USB."
)
if dat == RESPONSE_ERROR_WRONG_NEW_FLASH_CONFIG:
raise OTAError(
"Error: ESP does not have the requested flash size (wrong board). Please "
"choose the correct 'board' option (esp01_1m always works) and try "
"uploading again."
)
if dat == RESPONSE_ERROR_ESP8266_NOT_ENOUGH_SPACE:
raise OTAError(
"Error: ESP does not have enough space to store OTA file. Please try "
"flashing a minimal firmware (remove everything except ota)"
)
if dat == RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE:
raise OTAError(
"Error: The OTA partition on the ESP is too small. ESPHome needs to resize "
"this partition, please flash over USB."
)
if dat == RESPONSE_ERROR_NO_UPDATE_PARTITION:
raise OTAError(
"Error: The OTA partition on the ESP couldn't be found. ESPHome needs to create "
"this partition, please flash over USB."
)
if dat == RESPONSE_ERROR_MD5_MISMATCH:
raise OTAError(
"Error: Application MD5 code mismatch. Please try again "
"or flash over USB with a good quality cable."
)
if dat == RESPONSE_ERROR_SIGNATURE_INVALID:
raise OTAError(
"Error: Firmware signature verification failed. The firmware was not signed "
"with the correct key. Ensure the signing key matches the one used to build "
"the firmware currently running on the device."
)
if dat == RESPONSE_ERROR_UNKNOWN:
raise OTAError("Unknown error from ESP")
error_msg = _ERROR_MESSAGES.get(dat)
if error_msg is not None:
raise OTAError(f"Error: {error_msg}")
if expect is None:
return
if not isinstance(expect, (list, tuple)):
expect = [expect]
if dat not in expect:
@@ -232,8 +243,25 @@ def send_check(
def perform_ota(
sock: socket.socket, password: str | None, file_handle: io.IOBase, filename: Path
sock: socket.socket,
password: str | None,
file_handle: io.IOBase,
filename: Path,
ota_type: int = OTA_TYPE_UPDATE_APP,
) -> None:
# Validate ota_type up front. It travels as a single byte on the wire, and
# passing an out-of-range value would only surface as a ValueError from
# bytes([ota_type]) deep inside send_check, bypassing OTAError handling.
if not isinstance(ota_type, int) or not 0 <= ota_type <= 0xFF:
raise OTAError(
f"Invalid ota_type {ota_type!r}; expected an integer in range 0-255"
)
if ota_type not in _SUPPORTED_OTA_TYPES:
supported = ", ".join(f"0x{t:02X}" for t in sorted(_SUPPORTED_OTA_TYPES))
raise OTAError(
f"Unsupported OTA type 0x{ota_type:02X}; this ESPHome supports: {supported}"
)
file_contents = file_handle.read()
file_size = len(file_contents)
_LOGGER.info("Uploading %s (%s bytes)", filename, file_size)
@@ -251,7 +279,11 @@ def perform_ota(
)
# Features - send both compression and SHA256 auth support
features_to_send = FEATURE_SUPPORTS_COMPRESSION | FEATURE_SUPPORTS_SHA256_AUTH
features_to_send = (
CLIENT_FEATURE_SUPPORTS_COMPRESSION
| CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
)
send_check(sock, features_to_send, "features")
features = receive_exactly(
sock,
@@ -260,7 +292,36 @@ def perform_ota(
None, # Accept any response
)[0]
if features == RESPONSE_SUPPORTS_COMPRESSION:
extended_proto = False
if features == RESPONSE_FEATURE_FLAGS:
extended_proto = True
features = receive_exactly(
sock,
1,
"feature flags",
None, # Accept any response
)[0]
elif features == RESPONSE_SUPPORTS_COMPRESSION:
features = SERVER_FEATURE_SUPPORTS_COMPRESSION
else:
features = 0
if ota_type != OTA_TYPE_UPDATE_APP:
# Any non-app OTA type requires the extended protocol and the
# partition-access server feature. Reject up front so the user gets
# a clear capability error instead of a post-auth 0x8E from the device.
if not extended_proto:
raise OTAError(
f"Device does not support extended OTA protocol; "
f"OTA type 0x{ota_type:02X} requires it"
)
if not (features & SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS):
raise OTAError(
f"Device does not support partition access; "
f"OTA type 0x{ota_type:02X} cannot be used"
)
if features & SERVER_FEATURE_SUPPORTS_COMPRESSION:
upload_contents = gzip.compress(file_contents, compresslevel=9)
_LOGGER.info("Compressed to %s bytes", len(upload_contents))
else:
@@ -315,6 +376,9 @@ def perform_ota(
# Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures
sock.settimeout(90.0)
if extended_proto:
send_check(sock, ota_type, "ota type")
upload_size = len(upload_contents)
upload_size_encoded = [
(upload_size >> 24) & 0xFF,
@@ -375,7 +439,11 @@ def perform_ota(
def run_ota_impl_(
remote_host: str | list[str], remote_port: int, password: str | None, filename: Path
remote_host: str | list[str],
remote_port: int,
password: str | None,
filename: Path,
ota_type: int = OTA_TYPE_UPDATE_APP,
) -> tuple[int, str | None]:
from esphome.core import CORE
@@ -413,7 +481,7 @@ def run_ota_impl_(
_LOGGER.info("Connected to %s", sa[0])
with open(filename, "rb") as file_handle:
try:
perform_ota(sock, password, file_handle, filename)
perform_ota(sock, password, file_handle, filename, ota_type)
except OTAError as err:
_LOGGER.error(str(err))
return 1, None
@@ -428,10 +496,14 @@ def run_ota_impl_(
def run_ota(
remote_host: str | list[str], remote_port: int, password: str | None, filename: Path
remote_host: str | list[str],
remote_port: int,
password: str | None,
filename: Path,
ota_type: int = OTA_TYPE_UPDATE_APP,
) -> tuple[int, str | None]:
try:
return run_ota_impl_(remote_host, remote_port, password, filename)
return run_ota_impl_(remote_host, remote_port, password, filename, ota_type)
except OTAError as err:
_LOGGER.error(err)
return 1, None

View File

@@ -10,7 +10,7 @@ dependencies:
esphome/micro-flac:
version: 0.1.1
esphome/micro-opus:
version: 0.3.6
version: 0.4.0
espressif/esp-dsp:
version: "1.7.1"
espressif/esp-tflite-micro:
@@ -20,15 +20,19 @@ dependencies:
espressif/mdns:
version: 1.11.0
espressif/esp_wifi_remote:
version: 1.4.0
version: 1.5.1
rules:
- if: "target in [esp32h2, esp32p4]"
espressif/wifi_remote_over_eppp:
version: 0.3.2
rules:
- if: "target in [esp32h2, esp32p4]"
espressif/eppp_link:
version: 1.1.4
version: 1.1.5
rules:
- if: "target in [esp32h2, esp32p4]"
espressif/esp_hosted:
version: 2.12.1
version: 2.12.6
rules:
- if: "target in [esp32h2, esp32p4]"
zorxx/multipart-parser:
@@ -92,6 +96,6 @@ dependencies:
esp32async/asynctcp:
version: 3.4.91
sendspin/sendspin-cpp:
version: 0.3.1
version: 0.4.0
lvgl/lvgl:
version: 9.5.0

View File

@@ -14,6 +14,37 @@ from esphome.util import run_external_process
_LOGGER = logging.getLogger(__name__)
def _strip_win_long_path_prefix(path: str) -> str:
r"""Strip the Windows extended-length path prefix from ``path``.
Handles both forms documented at
https://learn.microsoft.com/windows/win32/fileio/naming-a-file:
* ``\\?\C:\path\to\file`` -> ``C:\path\to\file``
* ``\\?\UNC\server\share\path`` -> ``\\server\share\path``
The NSIS-installed ``esphome.exe`` launcher on Windows starts Python with
``sys.executable`` already prefixed with ``\\?\``. That prefix propagates
into PlatformIO's ``$PYTHONEXE`` (PlatformIO reads ``PYTHONEXEPATH`` from
the environment, falling back to ``os.path.normpath(sys.executable)``)
and ends up baked into SCons-emitted command lines for build steps such
as the esp8266 ``elf2bin`` invocation. ``cmd.exe`` does not understand
the ``\\?\`` prefix, so the build fails with
"The system cannot find the path specified." Stripping the prefix early
keeps the path shell-quotable.
No-op on non-Windows platforms.
"""
if sys.platform != "win32":
return path
if path.startswith("\\\\?\\UNC\\"):
# \\?\UNC\server\share\... -> \\server\share\...
return "\\\\" + path[len("\\\\?\\UNC\\") :]
if path.startswith("\\\\?\\"):
return path[len("\\\\?\\") :]
return path
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,7 +55,18 @@ 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)
# Strip the Windows extended-length path prefix from sys.executable so it
# doesn't propagate into PlatformIO's $PYTHONEXE and break SCons-emitted
# command lines run through cmd.exe.
python_exe = _strip_win_long_path_prefix(sys.executable)
if python_exe != sys.executable:
# Only override PYTHONEXEPATH when we actually stripped a prefix.
# PlatformIO's get_pythonexe_path() reads this and falls back to
# sys.executable otherwise; setting it unconditionally would clobber
# a user-provided value (or the unmodified path on platforms that
# don't need the strip).
os.environ["PYTHONEXEPATH"] = python_exe
cmd = [python_exe, "-m", "esphome.platformio_runner"] + list(args)
return run_external_process(*cmd, **kwargs)

View File

@@ -101,7 +101,7 @@ def patch_file_downloader() -> None:
FileDownloader.__init__ = patched_init
_IGNORE_LIB_WARNINGS = f"(?:{'|'.join(['Hash', 'Update'])})"
_IGNORE_LIB_WARNINGS = "(?: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

View File

@@ -113,6 +113,7 @@ exclude = ['generated']
select = [
"E", # pycodestyle
"F", # pyflakes/autoflake
"FLY", # flynt: convert string formatting to f-strings
"FURB", # refurb
"I", # isort
"PERF", # performance

View File

@@ -12,7 +12,7 @@ platformio==6.1.19
esptool==5.2.0
click==8.3.3
esphome-dashboard==20260425.0
aioesphomeapi==44.22.0
aioesphomeapi==44.23.0
zeroconf==0.148.0
puremagic==1.30
ruamel.yaml==0.19.1 # dashboard_import

View File

@@ -6,11 +6,11 @@ what files have changed. It outputs JSON with the following structure:
{
"integration_tests": true/false,
"integration_tests_run_all": true/false,
"integration_test_files": ["tests/integration/test_foo.py", ...],
"integration_test_buckets": [{"name": "1/3", "tests": ["tests/integration/test_foo.py", ...]}, ...],
"clang_tidy": true/false,
"clang_format": true/false,
"python_linters": true/false,
"device_builder": true/false,
"changed_components": ["component1", "component2", ...],
"component_test_count": 5,
"memory_impact": {
@@ -26,6 +26,7 @@ The CI workflow uses this information to:
- Skip or run clang-tidy (and whether to do a full scan)
- Skip or run clang-format
- Skip or run Python linters (ruff, flake8, pylint, pyupgrade)
- Skip or run downstream esphome/device-builder tests against the PR's Python code
- Determine which components to test individually
- Decide how to split component tests (if there are many)
- Run memory impact analysis whenever there are changed components (merged config), and also for core-only changes
@@ -81,6 +82,62 @@ CLANG_TIDY_SPLIT_THRESHOLD = 65
# Isolated components count as 10x, groupable components count as 1x
COMPONENT_TEST_BATCH_SIZE = 40
# Integration test bucketing: when more than the threshold tests are scheduled,
# fan out across this many parallel jobs. Below the threshold, a single job runs.
INTEGRATION_TESTS_SPLIT_THRESHOLD = 10
INTEGRATION_TESTS_SPLIT_BUCKETS = 3
def _split_list(items: list[str], n: int) -> list[list[str]]:
"""Split a list into n roughly-equal contiguous parts (matches script/clang-tidy)."""
k, m = divmod(len(items), n)
return [items[i * k + min(i, m) : (i + 1) * k + min(i + 1, m)] for i in range(n)]
def _all_integration_test_files() -> list[str]:
"""Return all integration test file paths, sorted, relative to repo root."""
return sorted(
str(p.relative_to(root_path))
for p in (Path(root_path) / "tests" / "integration").glob("test_*.py")
)
def _compute_integration_test_buckets(
integration_run_all: bool,
integration_test_files: list[str],
) -> tuple[bool, list[dict[str, Any]]]:
"""Compute (run_integration, buckets) from the determine_integration_tests result.
Pure function for unit testing — no I/O beyond `_all_integration_test_files`
when `integration_run_all` is set.
`buckets` is a list of `{name, tests}` dicts where `tests` is a JSON-friendly
list of file paths so the workflow can build a bash array via jq, avoiding
shell word-splitting / glob hazards.
"""
if integration_run_all:
files = _all_integration_test_files()
else:
files = sorted(integration_test_files)
# Empty list (e.g. run_all expansion with no files on disk) would otherwise
# cause the workflow to invoke pytest with no path argument and collect
# tests outside tests/integration/. Suppress the run instead.
if not files:
return False, []
if len(files) > INTEGRATION_TESTS_SPLIT_THRESHOLD:
parts = [
part for part in _split_list(files, INTEGRATION_TESTS_SPLIT_BUCKETS) if part
]
buckets = [
{"name": f"{i + 1}/{len(parts)}", "tests": part}
for i, part in enumerate(parts)
]
else:
buckets = [{"name": "1/1", "tests": files}]
return True, buckets
class Platform(StrEnum):
"""Platform identifiers for memory impact analysis."""
@@ -385,6 +442,56 @@ def should_run_import_time(branch: str | None = None) -> bool:
return False
# Files outside esphome/**/*.py whose changes can affect the downstream
# device-builder build. requirements.txt / pyproject.toml change the runtime
# dependency graph that device-builder picks up when it installs esphome.
DEVICE_BUILDER_TRIGGER_FILES = frozenset(
{
"requirements.txt",
"pyproject.toml",
}
)
def should_run_device_builder(branch: str | None = None) -> bool:
"""Determine if downstream esphome/device-builder tests should run.
device-builder imports esphome as a library, so whenever the importable
Python surface, the runtime dependencies, or any non-C++ file packaged
with esphome (pyproject.toml has ``include-package-data = true``, so
things like esphome/idf_component.yml ship and can affect installs)
changes we re-run its test suite against the PR's code to catch
breakage we'd otherwise only see after a release.
Skipped on beta/release branches: those branches typically lag behind
device-builder@main, so a new device-builder API dependency would
falsely fail the run without reflecting any problem in the PR itself.
Args:
branch: Branch to compare against. If None, uses default.
Returns:
True if the device-builder downstream tests should run, False otherwise.
"""
target_branch = get_target_branch()
if target_branch and (
target_branch.startswith("release") or target_branch.startswith("beta")
):
return False
for file in changed_files(branch):
if file in DEVICE_BUILDER_TRIGGER_FILES:
return True
# Anything under esphome/ that isn't C++ source can change the
# importable / packaged surface device-builder consumes
# (Python sources, packaged YAML/JSON like idf_component.yml,
# etc.). C++ files only affect compiled firmware, not the
# Python install device-builder pulls in.
if file.startswith("esphome/") and not file.endswith(CPP_FILE_EXTENSIONS):
return True
return False
def determine_cpp_unit_tests(
branch: str | None = None,
) -> tuple[bool, list[str]]:
@@ -812,11 +919,14 @@ def main() -> None:
integration_run_all, integration_test_files = determine_integration_tests(
args.branch
)
run_integration = integration_run_all or bool(integration_test_files)
run_integration, integration_test_buckets = _compute_integration_test_buckets(
integration_run_all, integration_test_files
)
run_clang_tidy = should_run_clang_tidy(args.branch)
run_clang_format = should_run_clang_format(args.branch)
run_python_linters = should_run_python_linters(args.branch)
run_import_time = should_run_import_time(args.branch)
run_device_builder = should_run_device_builder(args.branch)
changed_cpp_file_count = count_changed_cpp_files(args.branch)
# Get changed components
@@ -944,13 +1054,13 @@ def main() -> None:
output: dict[str, Any] = {
"integration_tests": run_integration,
"integration_tests_run_all": integration_run_all,
"integration_test_files": integration_test_files,
"integration_test_buckets": integration_test_buckets,
"clang_tidy": run_clang_tidy,
"clang_tidy_mode": clang_tidy_mode,
"clang_format": run_clang_format,
"python_linters": run_python_linters,
"import_time": run_import_time,
"device_builder": run_device_builder,
"changed_components": changed_components,
"changed_components_with_tests": changed_components_with_tests,
"directly_changed_components_with_tests": list(directly_changed_with_tests),

View File

@@ -11,11 +11,19 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code(config):
await original_to_code(config)
# Enable BLE proto message types for benchmarks. The real
# bluetooth_proxy component is ESP32-only; a lightweight stub
# header in tests/benchmarks/stubs/ satisfies the include.
# Enable proxy proto message types for benchmarks. The real
# components have hardware dependencies (BLE/UART/RMT); lightweight
# stub headers in tests/benchmarks/stubs/ satisfy the includes.
cg.add_define("USE_BLUETOOTH_PROXY")
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 3)
cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16)
cg.add_define("USE_ZWAVE_PROXY")
cg.add_define("USE_INFRARED")
cg.add_define("USE_IR_RF")
cg.add_define("USE_RADIO_FREQUENCY")
cg.add_define("USE_SERIAL_PROXY")
cg.add_define("SERIAL_PROXY_COUNT", 0)
cg.add_define("ESPHOME_ENTITY_INFRARED_COUNT", 0)
cg.add_define("ESPHOME_ENTITY_RADIO_FREQUENCY_COUNT", 0)
manifest.to_code = to_code

View File

@@ -0,0 +1,280 @@
// Encode/decode microbenchmarks for proxy message families that carry
// high-volume traffic (Z-Wave, IR/RF, serial). Mirrors the existing
// BluetoothLERawAdvertisementsResponse benchmarks in bench_proto_encode.cpp.
#include <benchmark/benchmark.h>
#include <cstring>
#include "esphome/components/api/api_pb2.h"
#include "esphome/components/api/api_buffer.h"
namespace esphome::api::benchmarks {
static constexpr int kInnerIterations = 2000;
// Encodes `src` into `out`. Caller owns `out` and must keep it alive across
// the decode loop (decoded messages may store pointers back into its bytes).
template<typename T> static void encode_into(APIBuffer &out, const T &src) {
out.resize(src.calculate_size());
ProtoWriteBuffer writer(&out, 0);
src.encode(writer);
}
// --- ZWaveProxyFrame (Z-Wave frame, ~16 bytes payload) ---
#ifdef USE_ZWAVE_PROXY
static const uint8_t kZWaveFrameData[] = {0x01, 0x09, 0x00, 0x13, 0x01, 0x02, 0x00, 0x00,
0x25, 0x00, 0x05, 0xC4, 0x00, 0x00, 0x00, 0x00};
static void Encode_ZWaveProxyFrame(benchmark::State &state) {
ZWaveProxyFrame msg;
msg.data = kZWaveFrameData;
msg.data_len = sizeof(kZWaveFrameData);
APIBuffer buffer;
buffer.resize(msg.calculate_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_ZWaveProxyFrame);
static void Decode_ZWaveProxyFrame(benchmark::State &state) {
ZWaveProxyFrame source;
source.data = kZWaveFrameData;
source.data_len = sizeof(kZWaveFrameData);
APIBuffer encoded;
encode_into(encoded, source);
const uint8_t *data = encoded.data();
size_t size = encoded.size();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
ZWaveProxyFrame msg;
msg.decode(data, size);
benchmark::DoNotOptimize(msg);
}
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Decode_ZWaveProxyFrame);
static const uint8_t kZWaveRequestData[] = {0xDE, 0xAD, 0xBE, 0xEF};
static void Decode_ZWaveProxyRequest(benchmark::State &state) {
ZWaveProxyRequest source;
source.type = enums::ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE;
source.data = kZWaveRequestData;
source.data_len = sizeof(kZWaveRequestData);
APIBuffer encoded;
encode_into(encoded, source);
const uint8_t *data = encoded.data();
size_t size = encoded.size();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
ZWaveProxyRequest msg;
msg.decode(data, size);
benchmark::DoNotOptimize(msg);
}
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Decode_ZWaveProxyRequest);
#endif // USE_ZWAVE_PROXY
// --- SerialProxyDataReceived encode + SerialProxyWriteRequest decode ---
//
// SerialProxyWriteRequest is decode-only (SOURCE_CLIENT) but has the same
// wire layout as SerialProxyDataReceived, so we encode via the latter and
// decode as the former.
#ifdef USE_SERIAL_PROXY
static constexpr size_t kSerialPayloadSize = 64;
static const uint8_t kSerialPayload[kSerialPayloadSize] = {
0x55, 0xAA, 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB,
0xCD, 0xEF, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
0xFF, 0x00, 0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80, 0x90, 0xA0, 0xB0, 0xC0, 0xD0, 0xE0,
0xF0, 0x0F, 0x1F, 0x2F, 0x3F, 0x4F, 0x5F, 0x6F, 0x7F, 0x8F, 0x9F, 0xAF, 0xBF, 0xCF, 0xDF, 0xEF};
static void Encode_SerialProxyDataReceived(benchmark::State &state) {
SerialProxyDataReceived msg;
msg.instance = 0;
msg.set_data(kSerialPayload, kSerialPayloadSize);
APIBuffer buffer;
buffer.resize(msg.calculate_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_SerialProxyDataReceived);
static void Decode_SerialProxyWriteRequest(benchmark::State &state) {
SerialProxyDataReceived source;
source.instance = 0;
source.set_data(kSerialPayload, kSerialPayloadSize);
APIBuffer encoded;
encode_into(encoded, source);
const uint8_t *data = encoded.data();
size_t size = encoded.size();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
SerialProxyWriteRequest msg;
msg.decode(data, size);
benchmark::DoNotOptimize(msg);
}
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Decode_SerialProxyWriteRequest);
#endif // USE_SERIAL_PROXY
// --- InfraredRFReceiveEvent encode (100 sint32 timings) +
// InfraredRFTransmitRawTimingsRequest decode (hand-built wire bytes) ---
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
// Mark/space pairs simulating a typical RC-5 / NEC capture (100 timings).
static std::vector<int32_t> make_ir_timings_100() {
std::vector<int32_t> v;
v.reserve(100);
for (int i = 0; i < 100; i++) {
v.push_back((i % 2 == 0) ? 560 : -560);
}
return v;
}
static const std::vector<int32_t> &get_ir_timings_100() {
static const std::vector<int32_t> timings = make_ir_timings_100();
return timings;
}
static void Encode_InfraredRFReceiveEvent(benchmark::State &state) {
InfraredRFReceiveEvent msg;
msg.key = 0xDEADBEEF;
msg.timings = &get_ir_timings_100();
APIBuffer buffer;
buffer.resize(msg.calculate_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_InfraredRFReceiveEvent);
static void CalculateSize_InfraredRFReceiveEvent(benchmark::State &state) {
InfraredRFReceiveEvent msg;
msg.key = 0xDEADBEEF;
msg.timings = &get_ir_timings_100();
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_InfraredRFReceiveEvent);
// Hand-built wire bytes for InfraredRFTransmitRawTimingsRequest (decode-only,
// no sister message with identical wire layout).
// field 2 (key, fixed32): tag=0x15, 4 LE bytes
// field 3 (carrier_frequency): tag=0x18, varint
// field 4 (repeat_count): tag=0x20, varint
// field 5 (timings, packed sint32): tag=0x2A, length varint, packed payload
// field 6 (modulation): tag=0x30, varint
static APIBuffer build_infrared_rf_transmit_wire() {
uint8_t bytes[256];
size_t len = 0;
auto put_byte = [&](uint8_t b) { bytes[len++] = b; };
auto put_varint = [&](uint32_t v) {
while (v >= 0x80) {
bytes[len++] = static_cast<uint8_t>((v & 0x7F) | 0x80);
v >>= 7;
}
bytes[len++] = static_cast<uint8_t>(v);
};
auto encode_zigzag = [](int32_t v) -> uint32_t {
return (static_cast<uint32_t>(v) << 1) ^ static_cast<uint32_t>(v >> 31);
};
put_byte(0x15);
put_byte(0xEF);
put_byte(0xBE);
put_byte(0xAD);
put_byte(0xDE);
put_byte(0x18);
put_varint(38000);
put_byte(0x20);
put_varint(2);
uint8_t packed[200];
size_t packed_len = 0;
for (int i = 0; i < 100; i++) {
int32_t value = (i % 2 == 0) ? 560 : -560;
uint32_t zz = encode_zigzag(value);
while (zz >= 0x80) {
packed[packed_len++] = static_cast<uint8_t>((zz & 0x7F) | 0x80);
zz >>= 7;
}
packed[packed_len++] = static_cast<uint8_t>(zz);
}
put_byte(0x2A);
put_varint(static_cast<uint32_t>(packed_len));
std::memcpy(bytes + len, packed, packed_len);
len += packed_len;
// field 6: modulation = 1 (non-zero so it's actually emitted and exercises
// decode_varint for this field, matching the documented layout above).
put_byte(0x30);
put_varint(1);
APIBuffer buf;
buf.resize(len);
std::memcpy(buf.data(), bytes, len);
return buf;
}
static void Decode_InfraredRFTransmitRawTimingsRequest(benchmark::State &state) {
auto encoded = build_infrared_rf_transmit_wire();
const uint8_t *data = encoded.data();
size_t size = encoded.size();
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
InfraredRFTransmitRawTimingsRequest msg;
msg.decode(data, size);
benchmark::DoNotOptimize(msg);
}
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(Decode_InfraredRFTransmitRawTimingsRequest);
#endif // USE_IR_RF || USE_RADIO_FREQUENCY
} // namespace esphome::api::benchmarks

View File

@@ -0,0 +1,45 @@
// Stub for benchmark builds — provides the minimal interface that
// api_connection.cpp and Application need when USE_INFRARED is defined,
// without pulling in the real remote_base/RMT dependencies.
#pragma once
#include <cstdint>
#include "esphome/core/component.h"
#include "esphome/core/entity_base.h"
namespace esphome::infrared {
class Infrared;
class InfraredCall {
public:
explicit InfraredCall(Infrared *parent) : parent_(parent) {}
InfraredCall &set_carrier_frequency(uint32_t /*frequency*/) { return *this; }
InfraredCall &set_raw_timings_packed(const uint8_t * /*data*/, uint16_t /*length*/, uint16_t /*count*/) {
return *this;
}
InfraredCall &set_repeat_count(uint32_t /*count*/) { return *this; }
void perform() {}
protected:
Infrared *parent_;
};
class InfraredTraits {
public:
uint32_t get_receiver_frequency_hz() const { return 0; }
};
class Infrared : public Component, public EntityBase {
public:
Infrared() = default;
InfraredTraits &get_traits() { return this->traits_; }
const InfraredTraits &get_traits() const { return this->traits_; }
InfraredCall make_call() { return InfraredCall(this); }
uint32_t get_capability_flags() const { return 0; }
protected:
InfraredTraits traits_;
};
} // namespace esphome::infrared

View File

@@ -0,0 +1,51 @@
// Stub for benchmark builds — provides the minimal interface that
// api_connection.cpp and Application need when USE_RADIO_FREQUENCY is defined.
#pragma once
#include <cstdint>
#include "esphome/core/component.h"
#include "esphome/core/entity_base.h"
namespace esphome::radio_frequency {
enum RadioFrequencyModulation : uint32_t {
RADIO_FREQUENCY_MODULATION_OOK = 0,
};
class RadioFrequency;
class RadioFrequencyCall {
public:
explicit RadioFrequencyCall(RadioFrequency *parent) : parent_(parent) {}
RadioFrequencyCall &set_frequency(uint32_t /*frequency*/) { return *this; }
RadioFrequencyCall &set_modulation(RadioFrequencyModulation /*mod*/) { return *this; }
RadioFrequencyCall &set_repeat_count(uint32_t /*count*/) { return *this; }
RadioFrequencyCall &set_raw_timings_packed(const uint8_t * /*data*/, uint16_t /*length*/, uint16_t /*count*/) {
return *this;
}
void perform() {}
protected:
RadioFrequency *parent_;
};
class RadioFrequencyTraits {
public:
uint32_t get_frequency_min_hz() const { return 0; }
uint32_t get_frequency_max_hz() const { return 0; }
uint32_t get_supported_modulations() const { return 0; }
};
class RadioFrequency : public Component, public EntityBase {
public:
RadioFrequency() = default;
RadioFrequencyTraits &get_traits() { return this->traits_; }
const RadioFrequencyTraits &get_traits() const { return this->traits_; }
RadioFrequencyCall make_call() { return RadioFrequencyCall(this); }
uint32_t get_capability_flags() const { return 0; }
protected:
RadioFrequencyTraits traits_;
};
} // namespace esphome::radio_frequency

View File

@@ -0,0 +1,46 @@
// Stub for benchmark builds — provides the minimal interface that
// api_connection.cpp and Application need when USE_SERIAL_PROXY is defined,
// without pulling in the real UART implementation.
#pragma once
#include <cstdint>
#include <cstddef>
#include "esphome/components/api/api_pb2.h"
namespace esphome {
namespace api {
class APIConnection;
} // namespace api
namespace uart {
enum class UARTFlushResult : uint8_t {
UART_FLUSH_RESULT_SUCCESS,
UART_FLUSH_RESULT_ASSUMED_SUCCESS,
UART_FLUSH_RESULT_TIMEOUT,
UART_FLUSH_RESULT_FAILED,
};
} // namespace uart
namespace serial_proxy {
class SerialProxy {
public:
void set_instance_index(uint32_t index) { this->instance_index_ = index; }
uint32_t get_instance_index() const { return this->instance_index_; }
const char *get_name() const { return ""; }
api::enums::SerialProxyPortType get_port_type() const { return {}; }
api::APIConnection *get_api_connection() { return nullptr; }
void serial_proxy_request(api::APIConnection *conn, api::enums::SerialProxyRequestType type) {}
void configure(uint32_t baudrate, bool flow_control, uint8_t parity, uint32_t stop_bits, uint32_t data_size) {}
void write_from_client(const uint8_t *data, size_t len) {}
void set_modem_pins(uint32_t line_states) {}
uint32_t get_modem_pins() const { return 0; }
uart::UARTFlushResult flush_port() { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; }
protected:
uint32_t instance_index_{0};
};
} // namespace serial_proxy
} // namespace esphome

View File

@@ -0,0 +1,29 @@
// Stub for benchmark builds — provides the minimal interface that
// api_connection.cpp needs when USE_ZWAVE_PROXY is defined,
// without pulling in the real UART-based ZWaveProxy implementation.
#pragma once
#include "esphome/components/api/api_pb2.h"
namespace esphome {
namespace api {
class APIConnection;
} // namespace api
namespace zwave_proxy {
class ZWaveProxy {
public:
api::APIConnection *get_api_connection() { return nullptr; }
void zwave_proxy_request(api::APIConnection *conn, api::enums::ZWaveProxyRequestType type) {}
void send_frame(const uint8_t *data, size_t length) {}
void api_connection_authenticated(api::APIConnection *conn) {}
uint32_t get_feature_flags() const { return 0; }
uint32_t get_home_id() { return 0; }
};
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
extern ZWaveProxy *global_zwave_proxy;
} // namespace zwave_proxy
} // namespace esphome

View File

@@ -0,0 +1,14 @@
audio:
codecs:
flac:
buffer_memory: internal
mp3:
buffer_memory: psram
opus:
floating_point: false
state_memory: psram
pseudostack:
threadsafe: false
buffer_memory: internal
size: 80000
wav:

View File

@@ -0,0 +1 @@
<<: !include common.yaml

View File

@@ -7,3 +7,4 @@ media_source:
initial_static_delay: 5ms
static_delay_adjustable: true
fixed_delay: 480us
decode_memory: internal

View File

@@ -0,0 +1,92 @@
esphome:
name: climate-control-action-test
host:
api:
logger:
level: DEBUG
globals:
- id: test_target_temp
type: float
initial_value: "21.5"
sensor:
- platform: template
id: temp_sensor
name: "Temp"
lambda: 'return 20.0;'
update_interval: 60s
climate:
- platform: thermostat
id: test_climate
name: "Test Climate"
sensor: temp_sensor
min_idle_time: 30s
min_heating_off_time: 300s
min_heating_run_time: 300s
min_cooling_off_time: 300s
min_cooling_run_time: 300s
heat_action:
- logger.log: heating
idle_action:
- logger.log: idle
cool_action:
- logger.log: cooling
heat_cool_mode:
- logger.log: heat_cool
preset:
- name: Default
default_target_temperature_low: 18 °C
default_target_temperature_high: 22 °C
visual:
min_temperature: 10 °C
max_temperature: 30 °C
button:
# mode only
- platform: template
id: btn_mode
name: "Set Mode Heat"
on_press:
- climate.control:
id: test_climate
mode: HEAT
# mode + target_temperature_low + target_temperature_high
- platform: template
id: btn_mode_temps
name: "Set Mode Temps"
on_press:
- climate.control:
id: test_climate
mode: HEAT_COOL
target_temperature_low: 19.0 °C
target_temperature_high: 23.0 °C
# target_temperature_low only
- platform: template
id: btn_low_only
name: "Set Low Only"
on_press:
- climate.control:
id: test_climate
target_temperature_low: 17.5 °C
# Lambda path: target_temperature_high computed at runtime
- platform: template
id: btn_lambda_high
name: "Lambda High"
on_press:
- climate.control:
id: test_climate
target_temperature_high: !lambda "return id(test_target_temp);"
# mode only — turn off via mode
- platform: template
id: btn_off
name: "Set Off"
on_press:
- climate.control:
id: test_climate
mode: "OFF"

View File

@@ -0,0 +1,84 @@
"""Integration test for climate ControlAction.
Tests that climate.control automation actions work correctly with the
single stateless apply lambda/function pointer implementation. Exercises
multiple field combinations and the lambda path.
"""
from __future__ import annotations
import asyncio
from aioesphomeapi import (
ButtonInfo,
ClimateInfo,
ClimateMode,
ClimateState,
EntityState,
)
import pytest
from .state_utils import InitialStateHelper, require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_climate_control_action(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test climate ControlAction with constants and lambdas."""
loop = asyncio.get_running_loop()
async with run_compiled(yaml_config), api_client_connected() as client:
climate_state_future: asyncio.Future[ClimateState] | None = None
def on_state(state: EntityState) -> None:
if (
isinstance(state, ClimateState)
and climate_state_future is not None
and not climate_state_future.done()
):
climate_state_future.set_result(state)
async def wait_for_climate_state(timeout: float = 5.0) -> ClimateState:
nonlocal climate_state_future
climate_state_future = loop.create_future()
try:
return await asyncio.wait_for(climate_state_future, timeout)
finally:
climate_state_future = None
entities, _ = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
client.subscribe_states(initial_state_helper.on_state_wrapper(on_state))
await initial_state_helper.wait_for_initial_states()
require_entity(entities, "test_climate", ClimateInfo)
async def press_and_wait(name: str) -> ClimateState:
btn = require_entity(entities, name.lower().replace(" ", "_"), ButtonInfo)
client.button_command(btn.key)
return await wait_for_climate_state()
# mode only — set HEAT
state = await press_and_wait("Set Mode Heat")
assert state.mode == ClimateMode.HEAT
# mode + target_temperature_low + target_temperature_high
state = await press_and_wait("Set Mode Temps")
assert state.mode == ClimateMode.HEAT_COOL
assert state.target_temperature_low == pytest.approx(19.0, abs=0.5)
assert state.target_temperature_high == pytest.approx(23.0, abs=0.5)
# target_temperature_low only
state = await press_and_wait("Set Low Only")
assert state.target_temperature_low == pytest.approx(17.5, abs=0.5)
# lambda path: target_temperature_high computed at runtime
state = await press_and_wait("Lambda High")
assert state.target_temperature_high == pytest.approx(21.5, abs=0.5)
# mode only — turn off via mode
state = await press_and_wait("Set Off")
assert state.mode == ClimateMode.OFF

View File

@@ -63,6 +63,13 @@ def mock_should_run_import_time() -> Generator[Mock, None, None]:
yield mock
@pytest.fixture
def mock_should_run_device_builder() -> Generator[Mock, None, None]:
"""Mock should_run_device_builder from determine_jobs."""
with patch.object(determine_jobs, "should_run_device_builder") as mock:
yield mock
@pytest.fixture
def mock_determine_cpp_unit_tests() -> Generator[Mock, None, None]:
"""Mock determine_cpp_unit_tests from helpers."""
@@ -99,6 +106,7 @@ def test_main_all_tests_should_run(
mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock,
mock_should_run_import_time: Mock,
mock_should_run_device_builder: Mock,
mock_changed_files: Mock,
mock_determine_cpp_unit_tests: Mock,
capsys: pytest.CaptureFixture[str],
@@ -113,6 +121,7 @@ def test_main_all_tests_should_run(
mock_should_run_clang_format.return_value = True
mock_should_run_python_linters.return_value = True
mock_should_run_import_time.return_value = True
mock_should_run_device_builder.return_value = True
mock_determine_cpp_unit_tests.return_value = (False, ["wifi", "api", "sensor"])
# Mock changed_files to return non-component files (to avoid memory impact)
@@ -122,10 +131,19 @@ def test_main_all_tests_should_run(
"esphome/helpers.py",
]
# Stable, deterministic stand-in for the tests/integration/ glob so the
# bucket assertions don't drift with the real test count.
fake_test_files = [f"tests/integration/test_{i:03d}.py" for i in range(15)]
# Run main function with mocked argv
with (
patch("sys.argv", ["determine-jobs.py"]),
patch.object(determine_jobs, "_is_clang_tidy_full_scan", return_value=False),
patch.object(
determine_jobs,
"_all_integration_test_files",
return_value=fake_test_files,
),
patch.object(
determine_jobs,
"get_changed_components",
@@ -161,13 +179,30 @@ def test_main_all_tests_should_run(
output = json.loads(captured.out)
assert output["integration_tests"] is True
assert output["integration_tests_run_all"] is True
assert output["integration_test_files"] == []
# run_all=True expands to the full glob and pre-buckets into 3 parts.
# Each bucket's `tests` is a JSON list of file paths.
assert isinstance(output["integration_test_buckets"], list)
assert len(output["integration_test_buckets"]) == 3
assert [b["name"] for b in output["integration_test_buckets"]] == [
"1/3",
"2/3",
"3/3",
]
for bucket in output["integration_test_buckets"]:
assert isinstance(bucket["tests"], list)
for path in bucket["tests"]:
assert isinstance(path, str)
bucket_files = [f for b in output["integration_test_buckets"] for f in b["tests"]]
assert bucket_files == fake_test_files
# Bucket sizes are balanced (max-min difference at most 1).
sizes = [len(b["tests"]) for b in output["integration_test_buckets"]]
assert max(sizes) - min(sizes) <= 1
assert output["clang_tidy"] is True
assert output["clang_tidy_mode"] in ["nosplit", "split"]
assert output["clang_format"] is True
assert output["python_linters"] is True
assert output["import_time"] is True
assert output["device_builder"] is True
assert output["changed_components"] == ["wifi", "api", "sensor"]
# changed_components_with_tests will only include components that actually have test files
assert "changed_components_with_tests" in output
@@ -200,6 +235,7 @@ def test_main_no_tests_should_run(
mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock,
mock_should_run_import_time: Mock,
mock_should_run_device_builder: Mock,
mock_changed_files: Mock,
mock_determine_cpp_unit_tests: Mock,
capsys: pytest.CaptureFixture[str],
@@ -214,6 +250,7 @@ def test_main_no_tests_should_run(
mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = False
mock_should_run_import_time.return_value = False
mock_should_run_device_builder.return_value = False
mock_determine_cpp_unit_tests.return_value = (False, [])
# Mock changed_files to return no component files
@@ -247,13 +284,13 @@ def test_main_no_tests_should_run(
output = json.loads(captured.out)
assert output["integration_tests"] is False
assert output["integration_tests_run_all"] is False
assert output["integration_test_files"] == []
assert output["integration_test_buckets"] == []
assert output["clang_tidy"] is False
assert output["clang_tidy_mode"] == "disabled"
assert output["clang_format"] is False
assert output["python_linters"] is False
assert output["import_time"] is False
assert output["device_builder"] is False
assert output["changed_components"] == []
assert output["changed_components_with_tests"] == []
assert output["component_test_count"] == 0
@@ -275,6 +312,7 @@ def test_main_with_branch_argument(
mock_should_run_clang_format: Mock,
mock_should_run_python_linters: Mock,
mock_should_run_import_time: Mock,
mock_should_run_device_builder: Mock,
mock_changed_files: Mock,
mock_determine_cpp_unit_tests: Mock,
capsys: pytest.CaptureFixture[str],
@@ -289,6 +327,7 @@ def test_main_with_branch_argument(
mock_should_run_clang_format.return_value = False
mock_should_run_python_linters.return_value = True
mock_should_run_import_time.return_value = True
mock_should_run_device_builder.return_value = True
mock_determine_cpp_unit_tests.return_value = (False, ["mqtt"])
# Mock changed_files to return non-component files (to avoid memory impact)
@@ -326,19 +365,20 @@ def test_main_with_branch_argument(
mock_should_run_clang_format.assert_called_once_with("main")
mock_should_run_python_linters.assert_called_once_with("main")
mock_should_run_import_time.assert_called_once_with("main")
mock_should_run_device_builder.assert_called_once_with("main")
# Check output
captured = capsys.readouterr()
output = json.loads(captured.out)
assert output["integration_tests"] is False
assert output["integration_tests_run_all"] is False
assert output["integration_test_files"] == []
assert output["integration_test_buckets"] == []
assert output["clang_tidy"] is True
assert output["clang_tidy_mode"] in ["nosplit", "split"]
assert output["clang_format"] is False
assert output["python_linters"] is True
assert output["import_time"] is True
assert output["device_builder"] is True
assert output["changed_components"] == ["mqtt"]
# changed_components_with_tests will only include components that actually have test files
assert "changed_components_with_tests" in output
@@ -357,6 +397,59 @@ def test_main_with_branch_argument(
assert output["cpp_unit_tests_components"] == ["mqtt"]
def test_compute_integration_test_buckets_empty() -> None:
"""No integration tests scheduled => (False, [])."""
run, buckets = determine_jobs._compute_integration_test_buckets(False, [])
assert run is False
assert buckets == []
def test_compute_integration_test_buckets_below_threshold() -> None:
"""A small explicit list (<= threshold) => single 1/1 bucket with that list."""
files = [f"tests/integration/test_{name}.py" for name in ("c", "a", "b")]
run, buckets = determine_jobs._compute_integration_test_buckets(False, files)
assert run is True
assert buckets == [{"name": "1/1", "tests": sorted(files)}]
def test_compute_integration_test_buckets_at_threshold_stays_single() -> None:
"""Exactly INTEGRATION_TESTS_SPLIT_THRESHOLD files => still one bucket
(the split kicks in only when count is strictly greater than threshold)."""
files = [
f"tests/integration/test_{i:02d}.py"
for i in range(determine_jobs.INTEGRATION_TESTS_SPLIT_THRESHOLD)
]
run, buckets = determine_jobs._compute_integration_test_buckets(False, files)
assert run is True
assert len(buckets) == 1
assert buckets[0]["name"] == "1/1"
assert buckets[0]["tests"] == sorted(files)
def test_compute_integration_test_buckets_just_over_threshold_splits() -> None:
"""One file over the threshold triggers the 3-bucket fan-out, balanced."""
n = determine_jobs.INTEGRATION_TESTS_SPLIT_THRESHOLD + 1
files = [f"tests/integration/test_{i:02d}.py" for i in range(n)]
run, buckets = determine_jobs._compute_integration_test_buckets(False, files)
assert run is True
assert [b["name"] for b in buckets] == ["1/3", "2/3", "3/3"]
union = [path for b in buckets for path in b["tests"]]
assert union == sorted(files)
sizes = [len(b["tests"]) for b in buckets]
assert max(sizes) - min(sizes) <= 1
def test_compute_integration_test_buckets_run_all_with_empty_glob_disables_run() -> (
None
):
"""run_all=True but glob returns no files => run suppressed (otherwise
pytest would collect tests outside tests/integration/)."""
with patch.object(determine_jobs, "_all_integration_test_files", return_value=[]):
run, buckets = determine_jobs._compute_integration_test_buckets(True, [])
assert run is False
assert buckets == []
def test_determine_integration_tests(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -658,6 +751,82 @@ def test_should_run_import_time_with_branch() -> None:
mock_changed.assert_called_once_with("release")
@pytest.mark.parametrize(
("changed_files", "expected_result"),
[
# esphome Python files trigger downstream device-builder tests
(["esphome/__main__.py"], True),
(["esphome/components/wifi/__init__.py"], True),
(["esphome/core/config.py"], True),
(["esphome/types.pyi"], True),
# Runtime dependency changes trigger
(["requirements.txt"], True),
(["pyproject.toml"], True),
# Non-C++ files packaged with esphome trigger -- device-builder
# picks them up because esphome's pyproject sets
# include-package-data = true.
(["esphome/idf_component.yml"], True),
(["esphome/dashboard/templates/index.html"], True),
(["esphome/components/api/api_pb2_service.json"], True),
# Mixed: any triggering file is enough
(["docs/README.md", "esphome/config.py"], True),
# Dev/test-only dependency changes don't trigger device-builder
# (they don't affect the importable surface device-builder uses)
(["requirements_dev.txt"], False),
(["requirements_test.txt"], False),
# Files outside esphome/ don't trigger
(["script/some_other_script.py"], False),
(["tests/script/test_determine_jobs.py"], False),
# C++ files under esphome/ don't trigger -- they only affect
# compiled firmware, not the Python install device-builder pulls in.
(["esphome/core/component.cpp"], False),
(["esphome/core/component.h"], False),
(["esphome/components/wifi/wifi_component.cpp"], False),
# Files outside esphome/ entirely
(["tests/components/wifi/test.esp32-idf.yaml"], False),
(["README.md"], False),
([], False),
],
)
def test_should_run_device_builder(
changed_files: list[str], expected_result: bool
) -> None:
"""Test should_run_device_builder function (non-beta/release target)."""
with (
patch.object(determine_jobs, "changed_files", return_value=changed_files),
# Mock target branch to "dev" so the beta/release skip is bypassed
# for these per-file behavior checks.
patch.object(determine_jobs, "get_target_branch", return_value="dev"),
):
result = determine_jobs.should_run_device_builder()
assert result == expected_result
def test_should_run_device_builder_with_branch() -> None:
"""Test should_run_device_builder with branch argument."""
with (
patch.object(determine_jobs, "changed_files") as mock_changed,
patch.object(determine_jobs, "get_target_branch", return_value="dev"),
):
mock_changed.return_value = []
determine_jobs.should_run_device_builder("release")
mock_changed.assert_called_once_with("release")
@pytest.mark.parametrize("target_branch", ["beta", "release", "release-2026.5"])
def test_should_run_device_builder_skips_beta_release(target_branch: str) -> None:
"""Beta/release target branches skip device-builder (lag behind device-builder@main)."""
with (
patch.object(determine_jobs, "get_target_branch", return_value=target_branch),
patch.object(determine_jobs, "changed_files") as mock_changed,
):
# Even with a triggering file present, the target-branch guard wins.
mock_changed.return_value = ["esphome/__main__.py"]
assert determine_jobs.should_run_device_builder() is False
# changed_files shouldn't even be consulted -- the guard short-circuits.
mock_changed.assert_not_called()
@pytest.mark.parametrize(
("changed_files", "expected_result"),
[

View File

@@ -185,6 +185,14 @@ def test_receive_exactly_socket_error(mock_socket: Mock) -> None:
"Error: The OTA partition on the ESP couldn't be found",
),
(espota2.RESPONSE_ERROR_MD5_MISMATCH, "Error: Application MD5 code mismatch"),
(
espota2.RESPONSE_ERROR_SIGNATURE_INVALID,
"Error: Firmware signature verification failed",
),
(
espota2.RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE,
"Error: The requested OTA type is not supported by the device",
),
(espota2.RESPONSE_ERROR_UNKNOWN, "Unknown error from ESP"),
],
)
@@ -270,12 +278,13 @@ def test_perform_ota_successful_md5_auth(
# Verify magic bytes were sent
assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES))
# Verify features were sent (compression + SHA256 support)
# Verify features were sent (compression + SHA256 support + extended protocol)
assert mock_socket.sendall.call_args_list[1] == call(
bytes(
[
espota2.FEATURE_SUPPORTS_COMPRESSION
| espota2.FEATURE_SUPPORTS_SHA256_AUTH
espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
]
)
)
@@ -640,12 +649,13 @@ def test_perform_ota_successful_sha256_auth(
# Verify magic bytes were sent
assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES))
# Verify features were sent (compression + SHA256 support)
# Verify features were sent (compression + SHA256 support + extended protocol)
assert mock_socket.sendall.call_args_list[1] == call(
bytes(
[
espota2.FEATURE_SUPPORTS_COMPRESSION
| espota2.FEATURE_SUPPORTS_SHA256_AUTH
espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
]
)
)
@@ -699,8 +709,9 @@ def test_perform_ota_sha256_fallback_to_md5(
assert mock_socket.sendall.call_args_list[1] == call(
bytes(
[
espota2.FEATURE_SUPPORTS_COMPRESSION
| espota2.FEATURE_SUPPORTS_SHA256_AUTH
espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
]
)
)
@@ -765,3 +776,220 @@ def test_perform_ota_version_differences(
# For v2.0, verify more recv calls due to chunk acknowledgments
assert mock_socket.recv.call_count == 9 # v2.0 has 9 recv calls (includes chunk OK)
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_extended_protocol_app(
mock_socket: Mock, mock_file: io.BytesIO
) -> None:
"""Test OTA extended protocol app update."""
recv_responses = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_2_0]), # Version number
bytes([espota2.RESPONSE_FEATURE_FLAGS]), # Device supports extended protocol
bytes(
[
espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION
| espota2.SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS
]
), # Device feature flags
bytes([espota2.RESPONSE_AUTH_OK]), # No auth required
bytes([espota2.RESPONSE_UPDATE_PREPARE_OK]), # Binary size OK
bytes([espota2.RESPONSE_BIN_MD5_OK]), # MD5 checksum OK
bytes([espota2.RESPONSE_CHUNK_OK]), # Chunk OK
bytes([espota2.RESPONSE_RECEIVE_OK]), # Receive OK
bytes([espota2.RESPONSE_UPDATE_END_OK]), # Update end OK
]
mock_socket.recv.side_effect = recv_responses
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"test.bin",
espota2.OTA_TYPE_UPDATE_APP,
)
# Verify magic bytes were sent
assert mock_socket.sendall.call_args_list[0] == call(bytes(espota2.MAGIC_BYTES))
# Verify features were sent (compression + SHA256 support + extended protocol)
assert mock_socket.sendall.call_args_list[1] == call(
bytes(
[
espota2.CLIENT_FEATURE_SUPPORTS_COMPRESSION
| espota2.CLIENT_FEATURE_SUPPORTS_SHA256_AUTH
| espota2.CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL
]
)
)
# Verify ota type was sent
assert mock_socket.sendall.call_args_list[2] == call(
bytes([espota2.OTA_TYPE_UPDATE_APP])
)
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_device_rejects_with_unsupported_ota_type(
mock_socket: Mock, mock_file: io.BytesIO
) -> None:
"""End-to-end: device returns 0x8E after the size byte; perform_ota must
surface the human-readable 'unsupported OTA type' error from the lookup
table in check_error()."""
recv_responses = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_2_0]), # Version number
bytes([espota2.RESPONSE_FEATURE_FLAGS]), # Extended protocol marker
bytes(
[
espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION
| espota2.SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS
]
), # Feature flags
bytes([espota2.RESPONSE_AUTH_OK]), # No auth required
bytes([espota2.RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE]), # Reject at size step
]
mock_socket.recv.side_effect = recv_responses
with pytest.raises(
espota2.OTAError,
match="The requested OTA type is not supported by the device",
):
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"test.bin",
espota2.OTA_TYPE_UPDATE_APP,
)
# Verify the client did send the OTA type byte before the size step
assert mock_socket.sendall.call_args_list[2] == call(
bytes([espota2.OTA_TYPE_UPDATE_APP])
)
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_unsupported_type_rejected_early(
mock_socket: Mock, mock_file: io.BytesIO
) -> None:
"""ota_type values not in _SUPPORTED_OTA_TYPES are rejected before any I/O."""
with pytest.raises(espota2.OTAError, match="Unsupported OTA type 0xFF"):
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"test.bin",
0xFF,
)
# No bytes should have been transmitted to the device.
mock_socket.sendall.assert_not_called()
@pytest.mark.parametrize("bad_type", [-1, 256, 0x10000, "app", None, 1.5])
def test_perform_ota_rejects_out_of_range_type(
mock_socket: Mock, mock_file: io.BytesIO, bad_type: object
) -> None:
"""Out-of-range or non-int ota_type must raise OTAError, not ValueError."""
with pytest.raises(espota2.OTAError, match="Invalid ota_type"):
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"test.bin",
bad_type, # type: ignore[arg-type]
)
mock_socket.sendall.assert_not_called()
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_non_app_type_requires_extended_protocol(
mock_socket: Mock, mock_file: io.BytesIO, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Non-app OTA type must fail when device only supports the legacy protocol."""
monkeypatch.setattr(
espota2,
"_SUPPORTED_OTA_TYPES",
frozenset({espota2.OTA_TYPE_UPDATE_APP, 0xFF}),
)
recv_responses = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_2_0]), # Version number
bytes([espota2.RESPONSE_HEADER_OK]), # Legacy single-byte feature ack
]
mock_socket.recv.side_effect = recv_responses
with pytest.raises(
espota2.OTAError, match="Device does not support extended OTA protocol"
):
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"test.bin",
0xFF,
)
@pytest.mark.usefixtures("mock_time")
def test_perform_ota_non_app_type_requires_partition_access(
mock_socket: Mock, mock_file: io.BytesIO, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Non-app OTA type must fail when device advertises extended protocol but
not the partition-access feature."""
monkeypatch.setattr(
espota2,
"_SUPPORTED_OTA_TYPES",
frozenset({espota2.OTA_TYPE_UPDATE_APP, 0xFF}),
)
recv_responses = [
bytes([espota2.RESPONSE_OK]), # First byte of version response
bytes([espota2.OTA_VERSION_2_0]), # Version number
bytes([espota2.RESPONSE_FEATURE_FLAGS]), # Extended protocol marker
bytes(
[espota2.SERVER_FEATURE_SUPPORTS_COMPRESSION]
), # Compression only, no partition access
]
mock_socket.recv.side_effect = recv_responses
with pytest.raises(
espota2.OTAError, match="Device does not support partition access"
):
espota2.perform_ota(
mock_socket,
"testpass",
mock_file,
"test.bin",
0xFF,
)
def test_check_error_detects_errors_when_expect_is_none() -> None:
"""check_error must surface device error bytes even when expect is None.
Regression test: previously, receive_exactly(..., expect=None) calls (used
during feature negotiation and nonce reads) silently passed error bytes
through, turning clean device errors into confusing later failures.
"""
with pytest.raises(espota2.OTAError, match="Error: Authentication invalid"):
espota2.check_error([espota2.RESPONSE_ERROR_AUTH_INVALID], None)
def test_check_error_detects_empty_when_expect_is_none() -> None:
"""Empty data with expect=None must still raise (connection closed)."""
with pytest.raises(
espota2.OTAError, match="Device closed connection without responding"
):
espota2.check_error([], None)
def test_check_error_passes_non_error_when_expect_is_none() -> None:
"""Non-error bytes with expect=None must pass through silently."""
espota2.check_error([espota2.RESPONSE_OK], None)
espota2.check_error([espota2.RESPONSE_HEADER_OK], None)
espota2.check_error([espota2.RESPONSE_FEATURE_FLAGS], None)

View File

@@ -83,6 +83,7 @@ from esphome.const import (
PLATFORM_RP2040,
)
from esphome.core import CORE, EsphomeError
from esphome.espota2 import OTA_TYPE_UPDATE_APP
from esphome.util import BootselResult
from esphome.zeroconf import _await_discovery, discover_mdns_devices
@@ -1593,7 +1594,7 @@ def test_upload_program_ota_success(
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
)
mock_run_ota.assert_called_once_with(
["192.168.1.100"], 3232, "secret", expected_firmware
["192.168.1.100"], 3232, "secret", expected_firmware, OTA_TYPE_UPDATE_APP
)
@@ -1624,7 +1625,7 @@ def test_upload_program_ota_with_file_arg(
assert exit_code == 0
assert host == "192.168.1.100"
mock_run_ota.assert_called_once_with(
["192.168.1.100"], 3232, None, Path("custom.bin")
["192.168.1.100"], 3232, None, Path("custom.bin"), OTA_TYPE_UPDATE_APP
)
@@ -1682,7 +1683,7 @@ def test_upload_program_ota_with_mqtt_resolution(
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
)
mock_run_ota.assert_called_once_with(
["192.168.1.100"], 3232, None, expected_firmware
["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP
)
@@ -1730,7 +1731,7 @@ def test_upload_program_ota_with_mqtt_empty_broker(
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
)
mock_run_ota.assert_called_once_with(
["192.168.1.50"], 3232, None, expected_firmware
["192.168.1.50"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP
)
# Verify warning was logged
assert "MQTT IP discovery failed" in caplog.text
@@ -3207,7 +3208,11 @@ def test_upload_program_ota_static_ip_with_mqttip(
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
)
mock_run_ota.assert_called_once_with(
["192.168.1.100", "192.168.2.50"], 3232, None, expected_firmware
["192.168.1.100", "192.168.2.50"],
3232,
None,
expected_firmware,
OTA_TYPE_UPDATE_APP,
)
@@ -3250,7 +3255,11 @@ def test_upload_program_ota_multiple_mqttip_resolves_once(
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
)
mock_run_ota.assert_called_once_with(
["192.168.2.50", "192.168.2.51", "192.168.1.100"], 3232, None, expected_firmware
["192.168.2.50", "192.168.2.51", "192.168.1.100"],
3232,
None,
expected_firmware,
OTA_TYPE_UPDATE_APP,
)
@@ -3415,7 +3424,7 @@ def test_upload_program_ota_mqtt_timeout_fallback(
tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin"
)
mock_run_ota.assert_called_once_with(
["192.168.1.100"], 3232, None, expected_firmware
["192.168.1.100"], 3232, None, expected_firmware, OTA_TYPE_UPDATE_APP
)

View File

@@ -311,6 +311,105 @@ def test_run_platformio_cli_sets_environment_variables(
assert "arg" in args
@pytest.mark.parametrize(
("platform", "input_path", "expected"),
[
# win32: drive-letter extended-length prefix is stripped
(
"win32",
"\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
),
# win32: UNC extended-length prefix is translated to a regular UNC path
(
"win32",
"\\\\?\\UNC\\server\\share\\python.exe",
"\\\\server\\share\\python.exe",
),
# win32: paths without the prefix are returned unchanged
(
"win32",
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
),
# non-win32: prefix is left alone (no-op)
("linux", "\\\\?\\C:\\python.exe", "\\\\?\\C:\\python.exe"),
("darwin", "/usr/bin/python3", "/usr/bin/python3"),
],
)
def test_strip_win_long_path_prefix(
platform: str, input_path: str, expected: str
) -> None:
r"""``\\?\`` and ``\\?\UNC\`` prefixes are stripped only on win32."""
with patch("esphome.platformio_api.sys.platform", platform):
assert platformio_api._strip_win_long_path_prefix(input_path) == expected
def test_run_platformio_cli_strips_win_long_path_prefix(
setup_core: Path, mock_run_external_process: Mock
) -> None:
r"""Windows ``\\?\`` prefix on sys.executable does not leak into the subprocess.
The NSIS-installed esphome.exe launcher starts Python with
``sys.executable`` already prefixed by the extended-length path marker.
That prefix would otherwise propagate into PlatformIO's ``PYTHONEXE`` and
break SCons-emitted command lines run through ``cmd.exe``.
"""
CORE.build_path = str(setup_core / "build" / "test")
prefixed_exe = (
"\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe"
)
stripped_exe = (
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe"
)
with (
patch.dict(os.environ, {}, clear=False),
patch("esphome.platformio_api.sys.platform", "win32"),
patch("esphome.platformio_api.sys.executable", prefixed_exe),
):
# Pop any pre-existing PYTHONEXEPATH so the assertion below reflects
# what run_platformio_cli set, not whatever the test runner's
# environment happened to contain.
os.environ.pop("PYTHONEXEPATH", None)
mock_run_external_process.return_value = 0
platformio_api.run_platformio_cli("test", "arg")
# The subprocess is invoked with the stripped executable path.
mock_run_external_process.assert_called_once()
args = mock_run_external_process.call_args[0]
assert args[0] == stripped_exe
# PYTHONEXEPATH is exported with the stripped path so PlatformIO's
# get_pythonexe_path() picks it up in the subprocess.
assert os.environ["PYTHONEXEPATH"] == stripped_exe
def test_run_platformio_cli_does_not_set_pythonexepath_without_strip(
setup_core: Path, mock_run_external_process: Mock
) -> None:
r"""PYTHONEXEPATH is not touched when sys.executable has no ``\\?\`` prefix.
Setting it unconditionally would clobber a user-provided value (or
interfere with non-Windows tooling that has no prefix to strip).
"""
CORE.build_path = str(setup_core / "build" / "test")
plain_exe = "/usr/bin/python3"
with (
patch.dict(os.environ, {}, clear=False),
patch("esphome.platformio_api.sys.platform", "linux"),
patch("esphome.platformio_api.sys.executable", plain_exe),
):
os.environ.pop("PYTHONEXEPATH", None)
mock_run_external_process.return_value = 0
platformio_api.run_platformio_cli("test", "arg")
mock_run_external_process.assert_called_once()
args = mock_run_external_process.call_args[0]
assert args[0] == plain_exe
assert "PYTHONEXEPATH" not in os.environ
def test_run_platformio_cli_run_builds_command(
setup_core: Path, mock_run_platformio_cli: Mock
) -> None: