mirror of
https://github.com/esphome/esphome.git
synced 2026-06-26 02:55:31 +00:00
Compare commits
41 Commits
dnm_test_a
...
test-devic
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bc6a4bbda | ||
|
|
520371c4a2 | ||
|
|
ed00f5f36b | ||
|
|
365d93f01b | ||
|
|
8046ff7e1e | ||
|
|
5e9db1c8c6 | ||
|
|
81d147ff9e | ||
|
|
58cb7effd4 | ||
|
|
3dd60c5713 | ||
|
|
f073c1cabe | ||
|
|
5cc447e0da | ||
|
|
0980630f68 | ||
|
|
b8dfffdf06 | ||
|
|
f6e39d305d | ||
|
|
08e5cb5576 | ||
|
|
faa61696e0 | ||
|
|
9999913d07 | ||
|
|
92aa98f680 | ||
|
|
3d69169141 | ||
|
|
24fdfcf1a1 | ||
|
|
550444dc34 | ||
|
|
ba7c06785a | ||
|
|
b708d1a826 | ||
|
|
148d478dec | ||
|
|
45e78e4114 | ||
|
|
3b3e003aa3 | ||
|
|
2f3e16b482 | ||
|
|
e085cb50d9 | ||
|
|
2fbfb4c385 | ||
|
|
61261b4a59 | ||
|
|
d48aad8c4d | ||
|
|
f1d3be4bda | ||
|
|
2758aa5517 | ||
|
|
a8b0133ec1 | ||
|
|
1398dcebb4 | ||
|
|
096d0c4279 | ||
|
|
e127268dac | ||
|
|
f0bffed3c0 | ||
|
|
1a871e231d | ||
|
|
47765bd2d0 | ||
|
|
8066325e0b |
4
.github/workflows/auto-label-pr.yml
vendored
4
.github/workflows/auto-label-pr.yml
vendored
@@ -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
|
||||
|
||||
79
.github/workflows/ci.yml
vendored
79
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -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}}"
|
||||
|
||||
2
.github/workflows/lock.yml
vendored
2
.github/workflows/lock.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 &> {
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 1–100 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
|
||||
|
||||
111
esphome/components/esp8266/hal.cpp
Normal file
111
esphome/components/esp8266/hal.cpp
Normal 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 1–100 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
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
65
esphome/components/host/hal.cpp
Normal file
65
esphome/components/host/hal.cpp
Normal 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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
53
esphome/components/libretiny/hal.cpp
Normal file
53
esphome/components/libretiny/hal.cpp
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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_,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
41
esphome/components/rp2040/hal.cpp
Normal file
41
esphome/components/rp2040/hal.cpp
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
@@ -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();
|
||||
|
||||
63
esphome/components/zephyr/hal.cpp
Normal file
63
esphome/components/zephyr/hal.cpp
Normal 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
|
||||
36
esphome/components/zephyr/hal.h
Normal file
36
esphome/components/zephyr/hal.h
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Constants used by esphome."""
|
||||
"""Constants used by ESPHome."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
@@ -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};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
280
tests/benchmarks/components/api/bench_proto_proxy.cpp
Normal file
280
tests/benchmarks/components/api/bench_proto_proxy.cpp
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
14
tests/components/audio/common.yaml
Normal file
14
tests/components/audio/common.yaml
Normal 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:
|
||||
1
tests/components/audio/test.esp32-idf.yaml
Normal file
1
tests/components/audio/test.esp32-idf.yaml
Normal file
@@ -0,0 +1 @@
|
||||
<<: !include common.yaml
|
||||
@@ -7,3 +7,4 @@ media_source:
|
||||
initial_static_delay: 5ms
|
||||
static_delay_adjustable: true
|
||||
fixed_delay: 480us
|
||||
decode_memory: internal
|
||||
|
||||
92
tests/integration/fixtures/climate_control_action.yaml
Normal file
92
tests/integration/fixtures/climate_control_action.yaml
Normal 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"
|
||||
84
tests/integration/test_climate_control_action.py
Normal file
84
tests/integration/test_climate_control_action.py
Normal 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
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user