Compare commits

..

15 Commits

Author SHA1 Message Date
J. Nick Koston 5a7b1c411e [cli] DRY up --prebuilt-dir helpers
Two small simplifications surfaced by re-reading the diff with a 'is
this DRY?' lens:

- Extract _missing_prebuilt_flash_tool(): the
  _ensure_platform_packages_for_prebuilt_upload helper had two
  near-identical 'if platform check; if finder() is not None return 0;
  tool_label = ...' blocks. Lift the platform->(tool_name) selection
  into a tiny helper so the early-return is one line and the install
  body is no longer interleaved with the platform dispatch.

- Collapse the two near-duplicate 'firmware not found' return points in
  upload_using_picotool. Both branches just selected an error message
  and returned 1; reorder so the message selection happens first and
  there's a single 'return 1'.

No behavior change; pure refactor. 587 unit tests pass.

Issue: esphome/device-builder#572
2026-05-11 16:00:42 -05:00
J. Nick Koston bcac422e9e [cli] Address self-review on --prebuilt-dir
- CORE.firmware_bin priority is now platform-aware: RP2040 + libretiny
  prefer firmware.uf2 over firmware.bin (picotool / ltchiptool need the
  UF2 header for address/family info); ESP* prefer firmware.bin. The
  prior code returned .bin first for all platforms, which would have
  silently flashed the wrong artifact on RP2040 if a hand-staged dir
  shipped both files. New tests guard both directions.

- _rp2040_serial_reset_to_bootsel: check picotool exists *before*
  triggering the 1200bps touch. If picotool is missing, the touch
  would have left the device stranded in BOOTSEL with nothing able to
  flash it; with this order the device stays on the old firmware and
  can be retried.

- upload_using_ltchiptool: error message now mentions both firmware.uf2
  and firmware.bin since CORE.firmware_bin resolves either.

- prepare_platform_for_upload: return type tightened to int (capture_stdout
  is hardcoded False; assert the run helper returns int so a future caller
  that flips capture_stdout fails loudly instead of silently treating a
  string as success). Caller in __main__.py is now a one-liner.

- _load_idedata: narrative comment in the prebuilt branch shortened.
  _resolve_prebuilt_idedata_paths docstring now notes the
  POSIX-absolute-on-Windows quirk ("/foo/bar" is rooted but not absolute
  on win32; hand-staged dirs need OS-appropriate absolute paths).

- New defensive-coverage tests: _resolve_prebuilt_idedata_paths with
  missing prog_path, no extra section, empty flash_images list.

Issue: esphome/device-builder#572
Issue: esphome/device-builder#570
2026-05-11 13:32:36 -05:00
J. Nick Koston c870ad328e [cli] Fix Windows pytest: use platform-absolute path in --prebuilt-dir test
The 'absolute paths pass through unchanged' test for _load_idedata
hardcoded a POSIX-style absolute path ('/somewhere/else/bootloader.bin').
On Windows, Path.is_absolute() returns False for that shape -- absolute
paths there require a drive letter -- so _resolve_prebuilt_idedata_paths
classified it as relative and prepended CORE.prebuilt_dir, breaking the
test's assertion.

Replace the hardcoded POSIX path with a tmp_path-rooted one so it's
platform-absolute on every runner. No production code change; this is a
test-only fix surfaced by the windows-latest pytest matrix on PR #16348.

Note: the macOS 3.14 failure on the same CI run is a flaky timing test
(tests/dashboard/test_web_server.py::test_dashboard_subscriber_entries_update_interval,
50ms sleep expecting 2+ iterations at 10ms) unrelated to this PR.

Issue: esphome/device-builder#572
2026-05-11 11:09:36 -05:00
J. Nick Koston 1ba8b838da [cli] Address Copilot review on --prebuilt-dir
- get_ltchiptool_path: use 'Scripts/' on Windows, 'bin/' elsewhere when
  falling back to PlatformIO's libretiny penv. CPython venvs put scripts
  under Scripts/ on win32 and bin/ on POSIX; the prior hardcoded 'bin'
  would never have found ltchiptool on Windows.
- _load_idedata: wrap json.loads on the prebuilt idedata.json in a
  try/except and re-raise as EsphomeError with a one-line diagnostic so
  the failure mode is a clean error instead of an unhandled
  JSONDecodeError stack trace. Update the surrounding comment to match
  the new behavior.
- CORE.prebuilt_dir docstring: drop the dead 'docs/architecture/...'
  pointer (no docs/ tree in this repo); point at esphome-docs#6600 and
  device-builder#572 instead.

New regression test:
- test_load_idedata_prebuilt_malformed_json_raises_esphomeerror

Updated test:
- test_get_ltchiptool_path_pio_penv now uses Scripts/ on win32 to match
  the new platform-aware lookup.

Issue: esphome/device-builder#572
Issue: esphome/device-builder#570
2026-05-11 10:58:17 -05:00
J. Nick Koston 956c2a9780 Merge remote-tracking branch 'upstream/dev' into core-prebuilt-dir-upload
# Conflicts:
#	esphome/__main__.py
2026-05-11 10:55:31 -05:00
J. Nick Koston 5cc7719ae4 [cli] Use pio run -t idedata to set up libretiny penv on cold hosts
The prior commit invoked 'pio pkg install' for the on-demand prep step,
which installs the libretiny platform package but does NOT recreate the
~/.platformio/penv/.libretiny/ virtualenv -- that's set up by libretiny's
ConfigurePythonVenv SCons step, which only fires during 'pio run'. So a
cold host (penv missing) still failed to find ltchiptool after the install
returned 'Already up-to-date'.

Wet test against bw15-device.yaml on a host with the libretiny penv
deleted:
  esphome upload bw15-device.yaml --device /dev/null --prebuilt-dir ...
  INFO ltchiptool not found on this host; installing the PlatformIO
       rtl87xx platform package ...
  Resolving bw15-device dependencies...
  Already up-to-date.
  ERROR ltchiptool not found. ...

Replace pkg install with 'pio run -t idedata'. The idedata target runs
SConscript without the actual compile target, so penv creation (libretiny)
and tool-package install (RP2040) happen as side effects of the SCons
configure phase -- but the build itself is skipped. Cost is a few seconds
of SCons configure, paid once per cold host.

Rename run_pkg_install -> prepare_platform_for_upload to match the new
semantics.

Issue: esphome/device-builder#572
Issue: esphome/device-builder#570
2026-05-11 00:30:06 -05:00
J. Nick Koston aab7177f07 [cli] Add write_cpp-failure test for --prebuilt-dir pkg-install prep
Symmetric with test_upload_program_prebuilt_dir_pkg_install_failure_aborts_upload:
covers the early-return path when write_cpp fails (codegen error, disk
full, validation regression) before pkg install runs. Closes the
coverage matrix for _ensure_platform_packages_for_prebuilt_upload's
two failure points.

Issue: esphome/device-builder#572
2026-05-10 23:58:16 -05:00
J. Nick Koston 7674170600 [cli] Auto-install PlatformIO platform when --prebuilt-dir needs its flash tool
Revert ltchiptool as a hard dependency so the vast majority of ESPHome
users (ESP32 / ESP8266 / nRF52 OTA) don't pay for libretiny tooling.
Instead, on-demand install just the PlatformIO platform package when a
--prebuilt-dir upload needs a flash tool that isn't on disk yet.

Mechanism: when upload_program receives --prebuilt-dir for a libretiny
or RP2040 target and the corresponding flash tool (ltchiptool / picotool)
isn't found by get_ltchiptool_path / _find_picotool, run the same prep
that 'esphome compile' would (write_cpp + write platformio.ini), then
invoke 'pio pkg install -e <env>' instead of 'pio run'. PlatformIO
downloads the platform without compiling, the flash tool ends up on disk,
and the upload dispatch picks it up on the next lookup.

After install, the tool path lookups (PATH + PIO penv for ltchiptool,
PIO packages dir for picotool) succeed and the upload helper takes over.
ESP32 / ESP8266 / nRF52 paths are unchanged (esptool is already bundled
in requirements.txt; smpclient too for nRF52 mcumgr).

Issue: esphome/device-builder#572
Issue: esphome/device-builder#570
2026-05-10 23:48:05 -05:00
J. Nick Koston 758189fe56 [deps] Bundle ltchiptool for libretiny prebuilt-dir uploads
`esphome upload --prebuilt-dir` on a libretiny device routes through
ltchiptool to bypass the PlatformIO build-tree requirement. On hosts
that have never compiled a libretiny config locally (the dashboard's
transparent-install use case) the libretiny PlatformIO platform's
penv at ~/.platformio/penv/.libretiny/bin/ltchiptool doesn't exist
yet, leaving the upload to fail with an actionable but unwelcome
"install ltchiptool" hint.

Bundle ltchiptool as a direct dependency, mirroring esptool which is
already shipped even though only ESP users need it. `pip install
esphome` is now sufficient for libretiny serial uploads from a
prebuilt artifact set; get_ltchiptool_path's PATH lookup picks up
the pip-installed binary first, with the PIO penv fallback still in
place for environments that vendor esphome without the extra.

Issue: esphome/device-builder#572
Issue: esphome/device-builder#570
2026-05-10 23:40:56 -05:00
J. Nick Koston f4607cb521 [cli] Accept basenames in prebuilt idedata.json
The dashboard's source-routed runner ships idedata.json with bare
basenames in prog_path and extra.flash_images[*].path (the receiver's
build-host absolute paths don't resolve on the offloader, and the
in-memory Web Serial consumer keys by basename). Without this change,
the dashboard would have to write a fresh idedata.json into the staging
tmpdir on every install just to flip basenames to absolute paths.

Resolve relative paths in the prebuilt idedata against CORE.prebuilt_dir
so both the dashboard's wire format and a hand-built directory with
absolute paths work. cc_path is left alone because it points at a
PlatformIO toolchain binary outside the prebuilt dir; the offloader's
own PIO install provides the matching binary.

Issue: esphome/device-builder#570
Issue: esphome/device-builder#572
2026-05-10 23:21:23 -05:00
J. Nick Koston 0adfd08270 [cli] Address review feedback on --prebuilt-dir
- upload_using_ltchiptool: rename unused config arg to _config and note
  the signature parity with upload_using_platformio/upload_using_picotool
  so dispatch stays symmetric.
- upload_program: explicit exit_code = 1 when _rp2040_serial_reset_to_bootsel
  fails, instead of relying on the function-level default.
- upload_using_picotool: distinct error message for the prebuilt-dir case
  that names both candidates (idedata ELF + prebuilt firmware) instead of
  just pointing at the missing ELF.
- _load_idedata: document the prebuilt-dir idedata.json contract more
  loudly (absolute paths under prebuilt_dir, no schema validation, dashboard
  owns the rewrites).
- --prebuilt-dir help text: drop the placeholder docs URL; describe the
  dashboard-internal intent inline so users who hit the flag in --help
  understand they don't want it.
- New test: upload_using_esptool with ESP-IDF toolchain + --prebuilt-dir
  uses <prebuilt-dir>/firmware.factory.bin at offset 0x0.

Issue: esphome/device-builder#572
2026-05-10 23:18:48 -05:00
J. Nick Koston d96ad02b9f [nrf52] Honor --prebuilt-dir for mcumgr/BLE OTA uploads
nRF52 has its own upload_program that runs before the default dispatch
in esphome.__main__. The mcumgr/BLE OTA path reads the MCUboot-signed
update image from CORE.relative_pioenvs_path(name, 'zephyr',
'app_update.bin'), which assumes a local Zephyr build tree.

Consult CORE.prebuilt_artifact_path('app_update.bin') first so the
dashboard's transparent BLE install on a Bluetooth proxy can flash a
prebuilt update image without compiling locally.

Serial uploads on non-MCUboot bootloaders still go through
adafruit-nrfutil via _upload_using_platformio and are out of scope here;
they need their own bypass to work with --prebuilt-dir (tracked
separately).

Issue: esphome/device-builder#572
2026-05-10 23:07:04 -05:00
J. Nick Koston a6a0a404ae [cli] Flash RP2040 serial prebuilt-dir uploads via 1200bps touch + picotool
Mirror the libretiny/ltchiptool shape for RP2040 serial when
--prebuilt-dir is set: open the user-supplied serial port at 1200 baud
(arduino-pico's USB CDC interprets the open/close as a request to reboot
into BOOTSEL), poll picotool until the BOOTSEL device shows up on the
USB bus, then dispatch to upload_using_picotool with the prebuilt .uf2.

This removes the last path that required --prebuilt-dir to contain a
platformio.ini + .pioenvs/<name>/ tree, so upload_using_platformio is
now only invoked when no prebuilt dir is set (i.e. the existing
compile+upload flow on a developer machine).

Issue: esphome/device-builder#572
2026-05-10 23:04:18 -05:00
J. Nick Koston b8336cddf2 [cli] Flash libretiny prebuilt-dir uploads via ltchiptool
The libretiny upload path on `upload_program` SERIAL dispatch re-invokes
PlatformIO (`pio run -t upload -t nobuild`), which needs a full build
tree and `platformio.ini`. That makes it incompatible with a dashboard
that only has prebuilt artifacts.

Bypass PlatformIO for the libretiny+SERIAL+--prebuilt-dir case by calling
`ltchiptool flash write -d <port> <firmware.uf2>` directly. The .uf2
encodes the chip family in its header so no extra config is needed.

ltchiptool ships with the libretiny PlatformIO platform under
~/.platformio/penv/.libretiny/bin/ltchiptool; `get_ltchiptool_path()`
prefers PATH first (pip install ltchiptool) and falls back to the
PlatformIO penv. Without --prebuilt-dir the existing PlatformIO path
remains in place, so this is purely additive.

Issue: esphome/device-builder#572
2026-05-10 22:52:17 -05:00
J. Nick Koston 255d4c6b65 [cli] Add esphome upload --prebuilt-dir <path>
Adds a --prebuilt-dir flag to esphome upload that points the per-platform
upload helpers at a directory of prebuilt artifacts shipped from a paired
build server, instead of re-deriving paths from the local build tree.

Covers every upload-dispatch shape:
- ESP32 / ESP8266 serial (esptool) reads firmware.bin + extras via the
  prebuilt idedata.json the dashboard ships next to the artifacts.
- ESP32 / ESP8266 OTA (native API + web_server) reads CORE.firmware_bin,
  CORE.partition_table_bin and CORE.bootloader_bin which now consult the
  prebuilt-dir first.
- RP2040 BOOTSEL (picotool) falls back from the idedata ELF (absent in
  the flat layout) to the prebuilt firmware.uf2.
- RP2040 serial / libretiny serial / OTA (PlatformIO upload -t nobuild)
  point platformio at the prebuilt build tree via CORE.build_path so the
  -t upload -t nobuild path finds platformio.ini and .pioenvs/<name>/.

Issue: esphome/device-builder#572
2026-05-10 22:28:19 -05:00
220 changed files with 2857 additions and 7420 deletions
+4 -14
View File
@@ -398,23 +398,13 @@ This document provides essential context for AI models interacting with this pro
│ ├── i2c/ # I2C bus
│ └── spi/ # SPI bus
└── components/[component]/
├── common.yaml # Component-only config (no bus definitions)
├── test.esp32-idf.yaml # config + compile
├── test.esp8266-ard.yaml # config + compile
── test-variant.esp32-idf.yaml # variant test, config + compile
├── validate.esp32-idf.yaml # config-only (never compiled)
└── validate-legacy.esp32-idf.yaml # config-only variant
├── common.yaml # Component-only config (no bus definitions)
├── test.esp32-idf.yaml
├── test.esp8266-ard.yaml
── test.rp2040-ard.yaml
```
Run them using `script/test_build_components`. Use `-c <component>` to test specific components and `-t <target>` for specific platforms.
* **Config-only test files (`validate.*.yaml`):** Use this prefix when a YAML file only needs to exercise schema/validation paths and does not need to be compiled. CI runs `validate.*.yaml` files with `esphome config` only and skips them during compile. The grammar mirrors `test.*.yaml`:
- `validate.<platform>.yaml` — base config-only test
- `validate-<variant>.<platform>.yaml` — config-only variant
Use this for things like deprecated-syntax migration tests, schema edge cases, or platform-specific validation branches where building firmware adds no signal. A component may have any mix of `test.*.yaml` and `validate.*.yaml` files. Validate files never participate in bus-grouping; each one runs as its own `esphome config` invocation.
When a PR's only edits to a component are `validate.*.yaml` files (no source changes, no `test.*.yaml` changes, and the component isn't pulled in as a dependency of another changed component), CI skips the compile stage for that component entirely and only runs config validation. This is decided in `script/determine-jobs.py` via `_component_change_is_validate_only` and surfaced as the `validate_only_components` output that the `test-build-components-split` job consumes.
* **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`:
```yaml
# test.esp32-idf.yaml — use packages for buses
+1 -1
View File
@@ -1 +1 @@
593fd53fa09944a59af3f38521e31d87fe10b60326b8d82bb76413c5149b312c
96c95feaa60831da5f43e3c6a7c7a3a237e17c5d12995a730dbc3884c8dcd11c
+1 -1
View File
@@ -1 +1 @@
../AGENTS.md
../.ai/instructions.md
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
+7 -52
View File
@@ -252,8 +252,6 @@ jobs:
python-linters: ${{ steps.determine.outputs.python-linters }}
import-time: ${{ steps.determine.outputs.import-time }}
device-builder: ${{ steps.determine.outputs.device-builder }}
native-idf: ${{ steps.determine.outputs.native-idf }}
native-idf-components: ${{ steps.determine.outputs.native-idf-components }}
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 }}
@@ -263,7 +261,6 @@ jobs:
cpp-unit-tests-run-all: ${{ steps.determine.outputs.cpp-unit-tests-run-all }}
cpp-unit-tests-components: ${{ steps.determine.outputs.cpp-unit-tests-components }}
component-test-batches: ${{ steps.determine.outputs.component-test-batches }}
validate-only-components: ${{ steps.determine.outputs.validate-only-components }}
benchmarks: ${{ steps.determine.outputs.benchmarks }}
steps:
- name: Check out code from GitHub
@@ -299,8 +296,6 @@ jobs:
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 "native-idf=$(echo "$output" | jq -r '.native_idf')" >> $GITHUB_OUTPUT
echo "native-idf-components=$(echo "$output" | jq -r '.native_idf_components')" >> $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
@@ -310,7 +305,6 @@ jobs:
echo "cpp-unit-tests-run-all=$(echo "$output" | jq -r '.cpp_unit_tests_run_all')" >> $GITHUB_OUTPUT
echo "cpp-unit-tests-components=$(echo "$output" | jq -c '.cpp_unit_tests_components')" >> $GITHUB_OUTPUT
echo "component-test-batches=$(echo "$output" | jq -c '.component_test_batches')" >> $GITHUB_OUTPUT
echo "validate-only-components=$(echo "$output" | jq -c '.validate_only_components')" >> $GITHUB_OUTPUT
echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT
- name: Save components graph cache
if: github.ref == 'refs/heads/dev'
@@ -423,10 +417,7 @@ jobs:
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # v4.15.1
with:
run: |
. venv/bin/activate
${{ steps.build.outputs.binary }}
pytest tests/benchmarks/python/ --codspeed --no-cov
run: ${{ steps.build.outputs.binary }}
mode: simulation
clang-tidy-single:
@@ -784,45 +775,13 @@ jobs:
echo "Config validation passed! Starting compilation..."
echo ""
# Compute the compile-stage component list. Components whose only
# changes are validate.*.yaml files are config-only -- their source
# and test fixtures didn't move, so rebuilding firmware adds no
# signal. Subtract them from this batch before invoking compile.
validate_only_json='${{ needs.determine-jobs.outputs.validate-only-components }}'
if [ -z "$validate_only_json" ]; then
validate_only_json='[]'
fi
if ! validate_only_csv=$(echo "$validate_only_json" | jq -r 'join(",")'); then
echo "::error::Failed to render validate-only-components as CSV from: $validate_only_json"
exit 1
fi
if [ -z "$validate_only_csv" ]; then
compile_csv="$components_csv"
else
components_sorted=$(echo "$components_csv" | tr ',' '\n' | sort -u)
validate_sorted=$(echo "$validate_only_csv" | tr ',' '\n' | sort -u)
if ! diff_out=$(comm -23 <(echo "$components_sorted") <(echo "$validate_sorted")); then
echo "::error::Failed to compute compile component subset."
exit 1
fi
compile_csv=$(echo "$diff_out" | paste -sd ',' -)
skipped=$(comm -12 <(echo "$components_sorted") <(echo "$validate_sorted") | paste -sd ',' -)
if [ -n "$skipped" ]; then
echo "Validate-only components in this batch (skipping compile): $skipped"
fi
fi
# Show disk space before compilation
echo "Disk space before compilation:"
df -h
echo ""
if [ -n "$compile_csv" ]; then
# Run compilation with grouping and isolation
python3 script/test_build_components.py -e compile -c "$compile_csv" -f --isolate "$directly_changed_csv"
else
echo "All components in this batch are validate-only -- skipping compile stage."
fi
# Run compilation with grouping and isolation
python3 script/test_build_components.py -e compile -c "$components_csv" -f --isolate "$directly_changed_csv"
test-native-idf:
name: Test components with native ESP-IDF
@@ -830,14 +789,10 @@ jobs:
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.native-idf == 'true'
if: github.event_name == 'pull_request'
env:
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
# Comma-joined subset of the native-IDF representative component list,
# computed by script/determine-jobs.py (native_idf_components_to_test).
# Single source of truth -- the full list lives in
# script/determine-jobs.py::NATIVE_IDF_TEST_COMPONENTS.
TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.native-idf-components }}
TEST_COMPONENTS: esp32,api,heatpumpir,bme280_i2c,bh1750,aht10,esp32_ble,esp32_ble_beacon,esp32_ble_client,esp32_ble_server,esp32_ble_tracker,ble_client,ble_presence,ble_rssi,ble_scanner
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -849,7 +804,7 @@ jobs:
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache ESPHome
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.esphome-idf
key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }}
@@ -908,7 +863,7 @@ jobs:
- name: Save ESPHome cache
if: github.ref == 'refs/heads/dev'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ~/.esphome-idf
key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }}
@@ -35,7 +35,7 @@ jobs:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
@@ -1,113 +0,0 @@
name: Add Dashboard Deprecation Comment
on:
pull_request_target:
types: [opened, synchronize]
# All API calls (pulls.listFiles + issues.{list,create,update}Comment) are performed with
# the App token minted below, so the workflow's GITHUB_TOKEN does not need any scopes.
permissions: {}
jobs:
dashboard-deprecation-comment:
name: Dashboard deprecation comment
runs-on: ubuntu-latest
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
# pulls.listFiles + issues.{list,create,update}Comment on PRs. For PR resources
# the issues.*Comment APIs require the pull-requests scope, not issues.
permission-pull-requests: write
- name: Add dashboard deprecation comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const commentMarker = "<!-- This comment was generated automatically by the dashboard-deprecation-comment workflow. -->";
const commentBody = `Thanks for opening this PR!
Heads up: the legacy ESPHome dashboard (\`esphome/dashboard/\` and \`tests/dashboard/\`) is **deprecated** and is being replaced by [ESPHome Device Builder](https://github.com/esphome/device-builder). We are not adding new features to the legacy dashboard and it will eventually be removed from this repository.
What this means for your PR:
- **New features / enhancements**: please port the change to [esphome/device-builder](https://github.com/esphome/device-builder) instead. We are unlikely to review or merge new dashboard features here.
- **Bug fixes**: small fixes may still be considered, but please check first whether the same issue exists in Device Builder, where the fix will have a longer life.
- **Security issues**: please do not file a public PR. Report privately via [GitHub security advisories](https://github.com/esphome/esphome/security/advisories/new) so we can coordinate a fix.
We appreciate the contribution and apologize for the friction; flagging this early so your time isn't spent on a change that may not land.
---
(Added by the PR bot)
${commentMarker}`;
async function getDashboardChanges(github, owner, repo, prNumber) {
const changedFiles = await github.paginate(
github.rest.pulls.listFiles,
{
owner: owner,
repo: repo,
pull_number: prNumber,
per_page: 100,
}
);
return changedFiles.filter(file =>
file.filename.startsWith('esphome/dashboard/') ||
file.filename.startsWith('tests/dashboard/')
);
}
async function findBotComment(github, owner, repo, prNumber) {
const comments = await github.paginate(
github.rest.issues.listComments,
{
owner: owner,
repo: repo,
issue_number: prNumber,
per_page: 100,
}
);
return comments.find(comment =>
comment.body.includes(commentMarker) && comment.user.type === "Bot"
);
}
const prNumber = context.payload.pull_request.number;
const { owner, repo } = context.repo;
const dashboardChanges = await getDashboardChanges(github, owner, repo, prNumber);
const existingComment = await findBotComment(github, owner, repo, prNumber);
if (dashboardChanges.length === 0) {
// PR doesn't (or no longer) touches the legacy dashboard. If we previously
// commented (e.g. files were removed in a later push), leave the comment in
// place for history rather than thrash on edit/delete.
return;
}
if (existingComment) {
if (existingComment.body === commentBody) {
return;
}
await github.rest.issues.updateComment({
owner: owner,
repo: repo,
comment_id: existingComment.id,
body: commentBody,
});
} else {
await github.rest.issues.createComment({
owner: owner,
repo: repo,
issue_number: prNumber,
body: commentBody,
});
}
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
+70 -2
View File
@@ -212,6 +212,74 @@ jobs:
docker buildx imagetools create $(jq -Rcnr 'inputs | . / "," | map("-t " + .) | join(" ")' <<< "${{ steps.tags.outputs.tags}}") \
$(printf '${{ steps.tags.outputs.image }}@sha256:%s ' *)
deploy-ha-addon-repo:
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
runs-on: ubuntu-latest
needs:
- init
- deploy-manifest
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
owner: esphome
repositories: home-assistant-addon
permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token)
- name: Trigger Workflow
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
let description = "ESPHome";
if (context.eventName == "release") {
description = ${{ toJSON(github.event.release.body) }};
}
github.rest.actions.createWorkflowDispatch({
owner: "esphome",
repo: "home-assistant-addon",
workflow_id: "bump-version.yml",
ref: "main",
inputs: {
version: "${{ needs.init.outputs.tag }}",
content: description
}
})
deploy-esphome-schema:
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
runs-on: ubuntu-latest
needs: [init]
environment: ${{ needs.init.outputs.deploy_env }}
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
owner: esphome
repositories: esphome-schema
permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token)
- name: Trigger Workflow
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
github.rest.actions.createWorkflowDispatch({
owner: "esphome",
repo: "esphome-schema",
workflow_id: "generate-schemas.yml",
ref: "main",
inputs: {
version: "${{ needs.init.outputs.tag }}",
}
})
version-notifier:
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
runs-on: ubuntu-latest
@@ -221,7 +289,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
@@ -234,7 +302,7 @@ jobs:
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
await github.rest.actions.createWorkflowDispatch({
github.rest.actions.createWorkflowDispatch({
owner: "esphome",
repo: "version-notifier",
workflow_id: "notify.yml",
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
+1 -1
View File
@@ -1 +1 @@
AGENTS.md
.ai/instructions.md
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.5.2
PROJECT_NUMBER = 2026.5.0-dev
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
+1 -1
View File
@@ -1 +1 @@
AGENTS.md
.ai/instructions.md
+3 -7
View File
@@ -13,16 +13,12 @@ RUN git config --system --add safe.directory "*" \
&& git config --system advice.detachedHead false
# Install build tools for Python packages that require compilation
# (e.g., ruamel.yaml.clib used by ESP-IDF's idf-component-manager).
# Also install libusb-1.0 at runtime so the ESP-IDF tools installer can
# validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without
# it idf_tools.py rejects the openocd install with exit 127 and aborts
# the whole framework setup.
# (e.g., ruamel.yaml.clibz used by ESP-IDF's idf-component-manager)
RUN if command -v apk > /dev/null; then \
apk add --no-cache build-base libusb; \
apk add --no-cache build-base; \
else \
apt-get update \
&& apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*; \
fi
+236 -82
View File
@@ -50,7 +50,6 @@ from esphome.const import (
CONF_TOPIC,
CONF_USERNAME,
CONF_WEB_SERVER,
CONF_WIFI,
ENV_NOGITIGNORE,
KEY_CORE,
KEY_TARGET_PLATFORM,
@@ -70,6 +69,7 @@ from esphome.util import (
PICOTOOL_PACKAGE,
FlashImage,
detect_rp2040_bootsel,
get_ltchiptool_path,
get_picotool_path,
get_serial_ports,
is_picotool_usb_permission_error,
@@ -734,22 +734,11 @@ def write_cpp_file() -> int:
def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
# Keep this gate here, NOT in config validation: device-builder needs
# `esphome config` to keep succeeding with placeholders so onboarding can run.
if CONF_WIFI in config:
from esphome.components.wifi import check_placeholder_credentials
check_placeholder_credentials(config)
# NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py
# If you change this format, update the regex in that script as well
_LOGGER.info("Compiling app... Build path: %s", CORE.build_path)
module = importlib.import_module("esphome.components." + CORE.target_platform)
platform_run_compile = getattr(module, "run_compile", None)
if platform_run_compile is not None and platform_run_compile(args, config):
pass
elif CORE.using_toolchain_esp_idf:
if CORE.using_toolchain_esp_idf:
from esphome.espidf import toolchain
rc = toolchain.run_compile(config, CORE.verbose)
@@ -865,9 +854,15 @@ def upload_using_esptool(
elif CORE.using_toolchain_esp_idf:
from esphome.espidf import toolchain
flash_images = [
FlashImage(path=toolchain.get_factory_firmware_path(), offset="0x0")
]
# For ESP-IDF the upload is a single factory image at 0x0 (it bundles
# bootloader + partitions + app). The prebuilt-dir form ships that
# single file at the canonical name so the dashboard can flash it
# without re-deriving the ESP-IDF build path.
factory_path = (
CORE.prebuilt_artifact_path("firmware.factory.bin")
or toolchain.get_factory_firmware_path()
)
flash_images = [FlashImage(path=factory_path, offset="0x0")]
else:
from esphome.platformio import toolchain
@@ -943,9 +938,56 @@ def upload_using_esptool(
return run_esptool(115200)
def upload_using_ltchiptool(_config: ConfigType, port: str) -> int:
"""Upload a libretiny ``.uf2`` directly via ``ltchiptool flash write``.
Bypasses PlatformIO so the dashboard's transparent install can flash a
libretiny device from just a prebuilt ``.uf2`` and a serial port; no
local build tree, ``platformio.ini`` or libretiny package install on the
machine running ``esphome upload`` is required (ltchiptool itself is
still required, but it ships with the libretiny PlatformIO platform or
can be ``pip install``-ed independently).
Chip family is auto-detected from the UF2 header so we don't need to
plumb through ``CORE.data[KEY_LIBRETINY][KEY_FAMILY]``.
The ``_config`` parameter is unused but kept for signature parity with
``upload_using_platformio`` / ``upload_using_picotool`` so the dispatch
in ``upload_program`` can stay symmetric.
"""
firmware = CORE.firmware_bin
if not firmware.is_file():
_LOGGER.error(
"LibreTiny firmware file not found at %s. "
"Make sure the project has been compiled first, or that "
"--prebuilt-dir points at a directory containing firmware.uf2 "
"(or firmware.bin -- libretiny emits the same UF2 content under "
"both names).",
firmware,
)
return 1
ltchiptool = get_ltchiptool_path()
if ltchiptool is None:
_LOGGER.error(
"ltchiptool not found. Install the libretiny PlatformIO platform "
"(esphome compile <yaml> with a libretiny board pulls it in) or "
"run `pip install ltchiptool` in this environment."
)
return 1
_LOGGER.info("Uploading firmware to LibreTiny device via ltchiptool...")
cmd = [str(ltchiptool), "flash", "write", "-d", port, str(firmware)]
return run_external_process(*cmd)
def upload_using_platformio(config: ConfigType, port: str) -> int:
from esphome.platformio import toolchain
# `upload_program` routes around this helper when --prebuilt-dir is set
# (libretiny→ltchiptool, RP2040→1200bps-touch+picotool), so PlatformIO
# is only invoked when a local build tree is available.
# RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for
# the upload target, but 'nobuild' skips the build phase that creates it.
# Create it here so the upload doesn't fail.
@@ -984,14 +1026,31 @@ def upload_using_picotool(config: ConfigType) -> int:
from esphome.platformio import toolchain
idedata = toolchain.get_idedata(config)
firmware_elf = Path(idedata.firmware_elf_path)
if not firmware_elf.is_file():
_LOGGER.error(
"Firmware ELF file not found at %s. "
"Make sure the project has been compiled first.",
firmware_elf,
)
# --prebuilt-dir ships canonical artifacts at the root of the directory,
# not the full PlatformIO build tree, so the ELF may not be present.
# picotool's "load" target accepts .uf2 / .bin / .elf, so fall back to
# CORE.firmware_bin (which resolves to the prebuilt firmware.uf2 when set)
# when no ELF is available.
elf_path = Path(idedata.firmware_elf_path)
if elf_path.is_file():
firmware_file: Path = elf_path
elif CORE.prebuilt_dir is not None and CORE.firmware_bin.is_file():
firmware_file = CORE.firmware_bin
else:
if CORE.prebuilt_dir is not None:
_LOGGER.error(
"No firmware found for picotool: neither %s (from idedata) "
"nor %s (prebuilt) exists.",
elf_path,
CORE.firmware_bin,
)
else:
_LOGGER.error(
"Firmware ELF file not found at %s. "
"Make sure the project has been compiled first.",
elf_path,
)
return 1
picotool = get_picotool_path(idedata.cc_path)
@@ -1009,7 +1068,7 @@ def upload_using_picotool(config: ConfigType) -> int:
# so progress bars display in real-time with \r updates.
# Capture stderr only so we can detect permission errors.
result = subprocess.run(
[str(picotool), "load", "-v", "-x", str(firmware_elf)],
[str(picotool), "load", "-v", "-x", str(firmware_file)],
stderr=subprocess.PIPE,
timeout=60,
check=False,
@@ -1038,6 +1097,61 @@ def upload_using_picotool(config: ConfigType) -> int:
return 0
def _rp2040_serial_reset_to_bootsel(port: str, timeout: float = 10.0) -> bool:
"""Reboot an arduino-pico RP2040 from running firmware into BOOTSEL mode.
arduino-pico's USB CDC handler treats a 1200bps "touch" (open the port at
1200 baud, then close it) as a request to reboot into the rp2040
bootloader, exposing the device as a picotool-loadable BOOTSEL endpoint.
This is the same mechanism the arduino-pico PlatformIO recipe uses for
serial uploads, lifted out so --prebuilt-dir uploads don't need to
re-invoke PlatformIO.
Returns True once a BOOTSEL device shows up on the USB bus.
"""
import serial
# Look picotool up *before* triggering the reset. If it's missing, the
# touch would leave the device stranded in BOOTSEL with nothing able to
# flash it; bail out early so the user can recover (the device is still
# running the old firmware and re-enumerates as the same serial port).
picotool = _find_picotool()
if picotool is None:
_LOGGER.error(
"picotool not found; cannot flash RP2040 after BOOTSEL reset. "
"Ensure the RP2040 PlatformIO platform is installed (%s).",
PICOTOOL_PACKAGE,
)
return False
_LOGGER.info("Rebooting %s into BOOTSEL via 1200bps touch...", port)
try:
ser = serial.Serial()
ser.baudrate = 1200
ser.port = port
ser.open()
# Small wait so the firmware sees the open before we close it; on a
# fast host the open+close can otherwise happen inside a single USB
# frame and the touch is missed.
time.sleep(0.1)
ser.close()
except (OSError, serial.SerialException) as err:
_LOGGER.error("Failed to open %s at 1200 baud for BOOTSEL reset: %s", port, err)
return False
start = time.monotonic()
while time.monotonic() - start < timeout:
if detect_rp2040_bootsel(picotool).device_count > 0:
return True
time.sleep(0.2)
_LOGGER.error(
"RP2040 did not enter BOOTSEL within %.0fs after 1200bps touch on %s.",
timeout,
port,
)
return False
def _wait_for_serial_port(
port: str | None = None,
timeout: float = 30.0,
@@ -1095,10 +1209,74 @@ def check_permissions(port: str):
)
def _missing_prebuilt_flash_tool() -> str | None:
"""Return the name of the platform-specific flash tool that's expected
but not yet on disk, or None if no install step is needed.
ESP* paths use bundled esptool / smpclient. Libretiny + RP2040 paths
use ltchiptool / picotool, which ship inside the PlatformIO platform
package and aren't pip-installable on their own.
"""
if CORE.is_libretiny and get_ltchiptool_path() is None:
return "ltchiptool"
if CORE.target_platform == PLATFORM_RP2040 and _find_picotool() is None:
return "picotool"
return None
def _ensure_platform_packages_for_prebuilt_upload(config: ConfigType) -> int:
"""Make sure the per-platform flash tool is available before a
``--prebuilt-dir`` upload dispatches.
On a host that has never compiled the target platform locally (the
dashboard's transparent-install scenario), the flash tool isn't on
disk yet. Mirror what ``esphome compile`` would do up to the
platform-install step (codegen + write platformio.ini), then run
``pio run -t idedata`` so the platform is downloaded without paying
for an actual compile.
Returns 0 if no install was needed or the install succeeded; non-zero
on failure so the upload bails out with a clear status.
"""
tool_name = _missing_prebuilt_flash_tool()
if tool_name is None:
return 0
_LOGGER.info(
"%s not found on this host; configuring the PlatformIO %s "
"platform so the upload can flash the prebuilt firmware...",
tool_name,
CORE.target_platform,
)
rc = write_cpp(config)
if rc != 0:
return rc
from esphome.platformio import toolchain
return toolchain.prepare_platform_for_upload(config, CORE.verbose)
def upload_program(
config: ConfigType, args: ArgsProtocol, devices: list[str]
) -> tuple[int, str | None]:
host = devices[0]
# --prebuilt-dir routes every per-platform upload helper at a directory of
# prebuilt artifacts instead of the local build tree. Validate once here
# so failures surface before we dispatch into platform-specific code that
# would otherwise produce confusing "file not found" errors deep in the
# esptool / picotool / PlatformIO call stacks.
prebuilt_dir = getattr(args, "prebuilt_dir", None)
if prebuilt_dir is not None:
prebuilt_path = Path(prebuilt_dir).expanduser()
if not prebuilt_path.is_dir():
raise EsphomeError(f"--prebuilt-dir {prebuilt_dir} is not a directory")
CORE.prebuilt_dir = prebuilt_path
rc = _ensure_platform_packages_for_prebuilt_upload(config)
if rc != 0:
return rc, None
try:
module = importlib.import_module("esphome.components." + CORE.target_platform)
if getattr(module, "upload_program")(config, args, host):
@@ -1137,6 +1315,27 @@ def upload_program(
if CORE.target_platform in (PLATFORM_ESP32, PLATFORM_ESP8266):
file = getattr(args, "file", None)
exit_code = upload_using_esptool(config, host, file, args.upload_speed)
elif CORE.is_libretiny and CORE.prebuilt_dir is not None:
# Dashboard transparent-install case: flash a prebuilt .uf2 with
# ltchiptool directly so the build tree isn't required. Without
# --prebuilt-dir, the normal PlatformIO path is still used so this
# is purely additive and existing libretiny serial flows are
# unchanged.
exit_code = upload_using_ltchiptool(config, host)
elif CORE.target_platform == PLATFORM_RP2040 and CORE.prebuilt_dir is not None:
# RP2040 serial + --prebuilt-dir: 1200bps-touch reboot into BOOTSEL,
# then flash with picotool. Avoids the PlatformIO build-tree
# requirement that upload_using_platformio imposes, mirroring the
# libretiny→ltchiptool path. Without --prebuilt-dir the original
# PlatformIO path is preserved for back-compat.
if _rp2040_serial_reset_to_bootsel(host):
exit_code = upload_using_picotool(config)
else:
# Touch reset failed: the helper already logged a specific
# error (port open failure, picotool missing, or BOOTSEL
# never enumerated). Be explicit about the failed exit code
# rather than relying on the function-level default of 1.
exit_code = 1
elif CORE.target_platform == PLATFORM_RP2040 or CORE.is_libretiny:
exit_code = upload_using_platformio(config, host)
# else: Unknown target platform, exit_code remains 1
@@ -1423,15 +1622,6 @@ def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
return 0
def command_config_hash(args: ArgsProtocol, config: ConfigType) -> int | None:
# generating code might modify config, so it must be done in order to generate
# a hash that will match what was generated when compiling and then running
# on the device
generate_cpp_contents(config)
safe_print(f"0x{CORE.config_hash:08x}")
return 0
def command_vscode(args: ArgsProtocol) -> int | None:
from esphome import vscode
@@ -1967,7 +2157,6 @@ PRE_CONFIG_ACTIONS = {
POST_CONFIG_ACTIONS = {
"config": command_config,
"config-hash": command_config_hash,
"compile": command_compile,
"upload": command_upload,
"logs": command_logs,
@@ -2081,13 +2270,6 @@ def parse_args(argv):
"--show-secrets", help="Show secrets in output.", action="store_true"
)
parser_config_hash = subparsers.add_parser(
"config-hash", help="Calculate the hash of the configuration."
)
parser_config_hash.add_argument(
"configuration", help="Your YAML configuration file(s).", nargs="+"
)
parser_compile = subparsers.add_parser(
"compile", help="Read the configuration and compile a program."
)
@@ -2121,6 +2303,16 @@ def parse_args(argv):
"--file",
help="Manually specify the binary file to upload.",
)
parser_upload.add_argument(
"--prebuilt-dir",
help=(
"Advanced: directory of prebuilt artifacts to flash instead of "
"re-deriving paths from the local build tree. Intended for the "
"ESPHome dashboard's transparent install flow on hosts that "
"don't compile firmware themselves. End users should not need "
"this flag."
),
)
parser_upload.add_argument(
"--ota-platform",
choices=[CONF_ESPHOME, CONF_WEB_SERVER],
@@ -2199,13 +2391,6 @@ def parse_args(argv):
parser_run.add_argument(
"--no-logs", help="Disable starting logs.", action="store_true"
)
parser_run.add_argument(
"--no-states",
action="store_true",
help="Do not show entity state changes in log output.",
)
parser_run.add_argument(
"--reset",
"-r",
@@ -2442,41 +2627,10 @@ def run_esphome(argv):
# Commands that don't need fresh external components: logs just connects
# to the device, and clean is about to delete the build directory.
skip_external = args.command in ("logs", "clean")
command_line_substitutions = dict(args.substitution) if args.substitution else {}
# Fast path for upload/logs: reuse the validated-config cache the
# last compile wrote. Falls back to read_config when missing/stale.
# Skipped when -s overrides are passed, since the cache was written
# against the previous substitution set.
config: ConfigType | None = None
cache_eligible = (
args.command in ("upload", "logs") and not command_line_substitutions
config = read_config(
dict(args.substitution) if args.substitution else {},
skip_external_update=skip_external,
)
if cache_eligible:
from esphome.compiled_config import load_compiled_config
config = load_compiled_config(conf_path)
if config is not None:
_LOGGER.info(
"Loaded validated config cache for %s, skipping validation.",
conf_path.name,
)
if config is None:
config = read_config(
command_line_substitutions,
skip_external_update=skip_external,
)
# Refresh the cache so the next upload/logs hits the fast path
# instead of re-running read_config. Skip when the storage
# sidecar is absent (no compile has run): the cache would
# never be loaded back, so writing secrets to disk is wasted.
if cache_eligible and config is not None:
from esphome.compiled_config import save_compiled_config
from esphome.storage_json import ext_storage_path
if ext_storage_path(conf_path.name).exists():
save_compiled_config(config)
if config is None:
return 2
CORE.config = config
+39 -111
View File
@@ -3,22 +3,18 @@
import json
from pathlib import Path
from esphome.components.esp32 import get_esp32_variant, idf_version
import esphome.config_validation as cv
from esphome.components.esp32 import get_esp32_variant
from esphome.core import CORE
from esphome.helpers import mkdir_p, write_file_if_changed
from esphome.writer import update_storage_json
def get_available_components() -> list[str] | None:
"""Get list of built-in ESP-IDF components from project_description.json.
"""Get list of available ESP-IDF components from project_description.json.
Excludes ``src``, IDF-managed components (``managed_components/``), and
converted PIO libs (``pio_components/``). Returns ``None`` if the build
dir or ``project_description.json`` isn't ready yet.
Returns only internal ESP-IDF components, excluding external/managed
components (from idf_component.yml).
"""
if CORE.build_path is None:
return None
project_desc = Path(CORE.build_path) / "build" / "project_description.json"
if not project_desc.exists():
return None
@@ -35,9 +31,9 @@ def get_available_components() -> list[str] | None:
if name == "src":
continue
# Exclude IDF-managed and converted-PIO components (external).
# Exclude managed/external components
comp_dir = info.get("dir", "")
if "managed_components" in comp_dir or "pio_components" in comp_dir:
if "managed_components" in comp_dir:
continue
result.append(name)
@@ -52,68 +48,17 @@ def has_discovered_components() -> bool:
return get_available_components() is not None
def get_project_cmakelists(minimal: bool = False) -> str:
"""Generate the top-level CMakeLists.txt for ESP-IDF project.
When ``minimal`` is true, omit ``ESPHOME_PROJECT_BUILTIN_COMPONENTS``
since ``project_description.json`` may be stale on the first write.
"""
def get_project_cmakelists() -> str:
"""Generate the top-level CMakeLists.txt for ESP-IDF project."""
# Get IDF target from ESP32 variant (e.g., ESP32S3 -> esp32s3)
variant = get_esp32_variant()
idf_target = variant.lower().replace("-", "")
# esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and
# removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get
# --format=raw because the legacy mode doesn't support it.
size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else ""
# Project-wide compile options: -D defines and -W warning flags (skip
# -Wl, linker flags — those go on the src component via
# target_link_options below). Emitted via idf_build_set_property so the
# flags propagate to every IDF component (including managed ones like
# esphome__micro-mp3) rather than just src/. Required so suppressions
# like ``-Wno-error=maybe-uninitialized`` actually silence warnings in
# third-party components we don't author.
project_compile_opts = [
flag
for flag in sorted(CORE.build_flags)
if flag.startswith("-D")
or (flag.startswith("-W") and not flag.startswith("-Wl,"))
]
# Extract compile definitions from build flags (-DXXX -> XXX)
compile_defs = [flag for flag in sorted(CORE.build_flags) if flag.startswith("-D")]
extra_compile_options = "\n".join(
f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)'
for flag in project_compile_opts
)
# Per-project list exposed as a CMake variable so converted PIO libs
# can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking
# project-specific names into their cached CMakeLists.
#
# Emit via idf_build_set_property (not plain set()) so the value is
# serialised into build_properties.temp.cmake and visible to IDF's
# early requirements-expansion pass (component_get_requirements.cmake
# runs as a separate CMake script invocation that doesn't load the
# project's top-level CMakeLists; without this, ${ESPHOME_PROJECT_
# MANAGED_COMPONENTS} in a converted-lib REQUIRES expands to empty).
from esphome.components.esp32 import get_managed_component_require_names
managed_components_property = "\n".join(
f"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS {name} APPEND)"
for name in get_managed_component_require_names()
)
# Built-in IDF components exposed via our own property (not IDF's
# __COMPONENT_REQUIRES_COMMON, which would append them to every
# component's REQUIRES including real IDF components). Referenced by
# src/CMakeLists and by each converted PIO lib's CMakeLists. Skipped
# on minimal writes because project_description.json may be stale.
builtin_components_property = (
""
if minimal
else "\n".join(
f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)"
for name in sorted(get_available_components() or [])
)
f'idf_build_set_property(COMPILE_OPTIONS "{compile_def}" APPEND)'
for compile_def in compile_defs
)
return f"""\
@@ -143,67 +88,50 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
{extra_compile_options}
{managed_components_property}
{builtin_components_property}
project({CORE.name})
# Emit raw JSON size data for ESPHome to read post-build.
add_custom_command(
TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD
COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=raw
-o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json
${{CMAKE_PROJECT_NAME}}.map
WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}}
VERBATIM
)
"""
def get_component_cmakelists() -> str:
"""Generate the main component CMakeLists.txt.
def get_component_cmakelists(minimal: bool = False) -> str:
"""Generate the main component CMakeLists.txt."""
idf_requires = [] if minimal else (get_available_components() or [])
requires_str = " ".join(idf_requires)
REQUIRES pulls in the discovered built-in IDF components via the
project-level variables set in the top-level CMakeLists.
"""
# Extract linker options (-Wl, flags). Compile flags (-D, -W) are
# emitted project-wide via idf_build_set_property in
# get_project_cmakelists so they reach every component, not just src/.
# Extract compile options (-W flags, excluding linker flags)
compile_opts = [
flag
for flag in CORE.build_flags
if flag.startswith("-W") and not flag.startswith("-Wl,")
]
compile_opts_str = "\n ".join(sorted(compile_opts)) if compile_opts else ""
# Extract linker options (-Wl, flags)
link_opts = [flag for flag in CORE.build_flags if flag.startswith("-Wl,")]
link_opts_str = "\n ".join(sorted(link_opts)) if link_opts else ""
return f"""\
# Auto-generated by ESPHome
# CONFIGURE_DEPENDS asks CMake to re-check the glob each build so test
# runs that reuse the build dir don't compile stale source paths. It's
# invalid in script mode (cmake -P), which is how IDF's
# component_get_requirements.cmake includes us, so skip it there.
if(CMAKE_SCRIPT_MODE_FILE)
file(GLOB_RECURSE app_sources
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
)
else()
file(GLOB_RECURSE app_sources CONFIGURE_DEPENDS
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
)
endif()
file(GLOB_RECURSE app_sources
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
)
idf_component_register(
SRCS ${{app_sources}}
INCLUDE_DIRS "." "esphome"
REQUIRES ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}}
REQUIRES {requires_str}
)
# Apply C++ standard
target_compile_features(${{COMPONENT_LIB}} PUBLIC cxx_std_20)
# ESPHome compile options
target_compile_options(${{COMPONENT_LIB}} PUBLIC
{compile_opts_str}
)
# ESPHome linker options
target_link_options(${{COMPONENT_LIB}} PUBLIC
{link_opts_str}
@@ -224,11 +152,11 @@ def write_project(minimal: bool = False) -> None:
# Write top-level CMakeLists.txt
write_file_if_changed(
CORE.relative_build_path("CMakeLists.txt"),
get_project_cmakelists(minimal=minimal),
get_project_cmakelists(),
)
# Write component CMakeLists.txt in src/
write_file_if_changed(
CORE.relative_src_path("CMakeLists.txt"),
get_component_cmakelists(),
get_component_cmakelists(minimal=minimal),
)
-76
View File
@@ -1,76 +0,0 @@
"""Validated-config cache for the upload/logs fast path.
compile dumps the validated config to <data_dir>/storage/<file>.validated.yaml;
the next upload/logs for that YAML reuses it instead of running the full
read_config pipeline. YAML round-trip (yaml_util.dump/load_yaml) keeps
!lambda/!include/IDs/paths intact; mtime gates staleness.
"""
from __future__ import annotations
import logging
from pathlib import Path
from esphome.core import CORE
from esphome.helpers import write_file
from esphome.storage_json import StorageJSON, ext_storage_path
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
def compiled_config_path(config_filename: str) -> Path:
"""Path to the cached validated config alongside the storage sidecar."""
return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml"
def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool:
"""True iff the cache file exists and isn't older than the source."""
try:
return cache_path.stat().st_mtime >= source_path.stat().st_mtime
except OSError:
return False
def save_compiled_config(config: ConfigType) -> None:
"""Write the validated-config cache. Always-write so mtime stays fresh.
Mode 0600 because show_secrets=True resolves !secret inline.
Failures are non-fatal: the fast path falls back to read_config.
"""
from esphome import yaml_util
try:
rendered = yaml_util.dump(config, show_secrets=True)
write_file(compiled_config_path(CORE.config_filename), rendered, private=True)
except Exception as err: # pylint: disable=broad-except
_LOGGER.debug("Skipping compiled config cache write: %s", err)
def load_compiled_config(conf_path: Path) -> ConfigType | None:
"""Load the cached validated config and apply storage metadata to CORE.
Returns None (caller falls back to read_config) when the cache is
missing, older than the source YAML, unparseable, or the sidecar
is incomplete.
"""
cache_path = compiled_config_path(conf_path.name)
if not _cache_is_fresh(cache_path, conf_path):
return None
from esphome import yaml_util
try:
config = yaml_util.load_yaml(cache_path, clear_secrets=False)
except Exception: # pylint: disable=broad-except
return None
storage = StorageJSON.load(ext_storage_path(conf_path.name))
if storage is None:
return None
# apply_to_core assumes a real compile wrote the sidecar; wizard-only
# sidecars leave both of these unset and can't drive upload/logs.
if not storage.core_platform and not storage.target_platform:
return None
storage.apply_to_core()
return config
-1
View File
@@ -2026,7 +2026,6 @@ message VoiceAssistantAudio {
bytes data = 1 [(pointer_to_buffer) = true];
bool end = 2;
bytes data2 = 3 [(pointer_to_buffer) = true];
}
enum VoiceAssistantTimerEvent {
@@ -1,6 +1,5 @@
#include "api_connection.h"
#ifdef USE_API
#include "api_connection_buffer.h" // for encode_to_buffer / get_batch_delay_ms_ inlines
#ifdef USE_API_NOISE
#include "api_frame_helper_noise.h"
#endif
@@ -1306,9 +1305,6 @@ void APIConnection::on_voice_assistant_announce_request(const VoiceAssistantAnno
bool APIConnection::send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg) {
VoiceAssistantConfigurationResponse resp;
if (!this->check_voice_assistant_api_connection_()) {
// send_message encodes synchronously, so this stack local outlives the encode
const std::vector<std::string> empty_wake_words;
resp.active_wake_words = &empty_wake_words;
return this->send_message(resp);
}
+40 -11
View File
@@ -11,8 +11,7 @@
#endif
#include "api_pb2.h"
#include "api_pb2_service.h"
#include "list_entities.h"
#include "subscribe_state.h"
#include "api_server.h"
#include "esphome/core/application.h"
#include "esphome/core/component.h"
#ifdef USE_ESP32_CRASH_HANDLER
@@ -37,9 +36,6 @@ class ComponentIterator;
namespace esphome::api {
// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h.
class APIServer;
// Keepalive timeout in milliseconds
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
// Maximum number of entities to process in a single batch during initial state/info sending
@@ -415,10 +411,44 @@ class APIConnection final : public APIServerConnectionBase {
// Non-template buffer management for send_message
bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg);
// Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites.
// Defined in api_connection_buffer.h (needs APIServer complete).
static uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
const void *msg, APIConnection *conn, uint32_t remaining_size);
// Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes.
// ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites.
static inline uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
const void *msg, APIConnection *conn,
uint32_t remaining_size) {
#ifdef HAS_PROTO_MESSAGE_DUMP
if (conn->flags_.log_only_mode) {
auto *proto_msg = static_cast<const ProtoMessage *>(msg);
DumpBuffer dump_buf;
conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
return 1;
}
#endif
const uint8_t footer_size = conn->helper_->frame_footer_size();
// First message uses max padding (already in buffer), subsequent use exact header size
size_t to_add;
if (conn->flags_.batch_first_message) {
conn->flags_.batch_first_message = false;
conn->batch_header_size_ = conn->helper_->frame_header_padding();
to_add = calculated_size;
} else {
conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
to_add = calculated_size + conn->batch_header_size_ + footer_size;
}
// Check if it fits (using actual header size, not max padding)
uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
if (total_calculated_size > remaining_size)
return 0;
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
shared_buf.resize(shared_buf.size() + to_add);
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
return total_calculated_size;
}
// Noinline version of encode_to_buffer for cold paths (entity info, zero-payload messages).
// All cold callers share this single copy instead of each getting an ALWAYS_INLINE expansion.
@@ -762,8 +792,7 @@ class APIConnection final : public APIServerConnectionBase {
// Read by process_batch_multi_ to pass into MessageInfo.
uint8_t batch_header_size_{0};
// Defined in api_connection_buffer.h (needs APIServer complete).
uint32_t get_batch_delay_ms_() const;
uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
// Message will use 8 more bytes than the minimum size, and typical
// MTU is 1500. Sometimes users will see as low as 1460 MTU.
// If its IPv6 the header is 40 bytes, and if its IPv4
@@ -1,54 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_API
// Inline APIConnection methods that need APIServer complete. Include this
// instead of api_connection.h when calling encode_to_buffer or get_batch_delay_ms_.
#include "api_connection.h"
#include "api_server.h"
namespace esphome::api {
inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t calculated_size,
MessageEncodeFn encode_fn, const void *msg,
APIConnection *conn, uint32_t remaining_size) {
#ifdef HAS_PROTO_MESSAGE_DUMP
if (conn->flags_.log_only_mode) {
auto *proto_msg = static_cast<const ProtoMessage *>(msg);
DumpBuffer dump_buf;
conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
return 1;
}
#endif
const uint8_t footer_size = conn->helper_->frame_footer_size();
// First message uses max padding (already in buffer), subsequent use exact header size
size_t to_add;
if (conn->flags_.batch_first_message) {
conn->flags_.batch_first_message = false;
conn->batch_header_size_ = conn->helper_->frame_header_padding();
to_add = calculated_size;
} else {
conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
to_add = calculated_size + conn->batch_header_size_ + footer_size;
}
// Check if it fits (using actual header size, not max padding)
uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
if (total_calculated_size > remaining_size)
return 0;
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
shared_buf.resize(shared_buf.size() + to_add);
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
return total_calculated_size;
}
inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
} // namespace esphome::api
#endif
-7
View File
@@ -2893,11 +2893,6 @@ bool VoiceAssistantAudio::decode_length(uint32_t field_id, ProtoLengthDelimited
this->data_len = value.size();
break;
}
case 3: {
this->data2 = value.data();
this->data2_len = value.size();
break;
}
default:
return false;
}
@@ -2907,14 +2902,12 @@ uint8_t *VoiceAssistantAudio::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len);
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->end);
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->data2, this->data2_len);
return pos;
}
uint32_t VoiceAssistantAudio::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_length(1, this->data_len);
size += ProtoSize::calc_bool(1, this->end);
size += ProtoSize::calc_length(1, this->data2_len);
return size;
}
bool VoiceAssistantTimerEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) {
+1 -3
View File
@@ -2436,15 +2436,13 @@ class VoiceAssistantEventResponse final : public ProtoDecodableMessage {
class VoiceAssistantAudio final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 106;
static constexpr uint8_t ESTIMATED_SIZE = 40;
static constexpr uint8_t ESTIMATED_SIZE = 21;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("voice_assistant_audio"); }
#endif
const uint8_t *data{nullptr};
uint16_t data_len{0};
bool end{false};
const uint8_t *data2{nullptr};
uint16_t data2_len{0};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
-1
View File
@@ -2174,7 +2174,6 @@ const char *VoiceAssistantAudio::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("VoiceAssistantAudio"));
dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len);
dump_field(out, ESPHOME_PSTR("end"), this->end);
dump_bytes_field(out, ESPHOME_PSTR("data2"), this->data2, this->data2_len);
return out.c_str();
}
const char *VoiceAssistantTimerEventResponse::dump_to(DumpBuffer &out) const {
+5
View File
@@ -30,6 +30,11 @@ APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-c
APIServer::APIServer() { global_api_server = this; }
// Custom deleter defined here so `delete` sees the complete APIConnection type.
// This prevents libc++ from emitting an "incomplete type" error when other
// translation units only have the forward declaration of APIConnection.
void APIServer::APIConnectionDeleter::operator()(APIConnection *p) const { delete p; }
void APIServer::socket_failed_(const LogString *msg) {
ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno);
this->destroy_socket_();
+10 -4
View File
@@ -3,8 +3,6 @@
#include "esphome/core/defines.h"
#ifdef USE_API
#include "api_buffer.h"
// Must precede clients_ so APIConnection is complete for default_delete (libc++).
#include "api_connection.h"
#include "api_noise_context.h"
#include "api_pb2.h"
#include "api_pb2_service.h"
@@ -14,6 +12,8 @@
#include "esphome/core/controller.h"
#include "esphome/core/log.h"
#include "esphome/core/string_ref.h"
#include "list_entities.h"
#include "subscribe_state.h"
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
#endif
@@ -191,9 +191,15 @@ class APIServer final : public Component,
bool is_connected_with_state_subscription() const;
// Range-for view over the populated slice [0, api_connection_count_). Read-only with respect
// to ownership; callers get `const unique_ptr&` so they can invoke non-const methods on the
// to ownership callers get `const unique_ptr&` so they can invoke non-const methods on the
// APIConnection but cannot reset/move the slot and break the count invariant.
using APIConnectionPtr = std::unique_ptr<APIConnection>;
// Custom deleter is defined out-of-line in api_server.cpp so libc++ does not
// eagerly instantiate `delete static_cast<APIConnection *>(p)` here, where
// only the forward declaration of APIConnection is visible (incomplete type).
struct APIConnectionDeleter {
void operator()(APIConnection *p) const;
};
using APIConnectionPtr = std::unique_ptr<APIConnection, APIConnectionDeleter>;
class ActiveClientsView {
const APIConnectionPtr *begin_;
const APIConnectionPtr *end_;
+1 -1
View File
@@ -395,7 +395,7 @@ async def to_code(config):
)
if data.mp3_support:
cg.add_define("USE_AUDIO_MP3_SUPPORT")
add_idf_component(name="esphome/micro-mp3", ref="0.2.1")
add_idf_component(name="esphome/micro-mp3", ref="0.2.0")
_emit_memory_pair(
data.mp3.buffer_memory,
"CONFIG_MP3_DECODER_PREFER_PSRAM",
@@ -207,137 +207,6 @@ void ConstAudioSourceBuffer::consume(size_t bytes) {
this->data_start_ += bytes;
}
std::unique_ptr<RingBufferAudioSource> RingBufferAudioSource::create(
std::shared_ptr<ring_buffer::RingBuffer> ring_buffer, size_t max_fill_bytes, uint8_t alignment_bytes) {
if (ring_buffer == nullptr || max_fill_bytes == 0 || alignment_bytes == 0 || alignment_bytes > MAX_ALIGNMENT_BYTES) {
return nullptr;
}
return std::unique_ptr<RingBufferAudioSource>(
new RingBufferAudioSource(std::move(ring_buffer), max_fill_bytes, alignment_bytes));
}
RingBufferAudioSource::~RingBufferAudioSource() {
if (this->acquired_item_ != nullptr) {
this->ring_buffer_->receive_release(this->acquired_item_);
this->acquired_item_ = nullptr;
}
}
void RingBufferAudioSource::release_item_() {
if (this->acquired_item_ == nullptr) {
return;
}
if (this->item_trailing_length_ > 0) {
// Copy the trailing sub-frame bytes into the splice buffer before returning the item; the next
// fill() will complete the frame from the head of the next chunk.
std::memcpy(this->splice_buffer_, this->item_trailing_ptr_, this->item_trailing_length_);
this->splice_length_ = this->item_trailing_length_;
this->item_trailing_ptr_ = nullptr;
this->item_trailing_length_ = 0;
}
this->ring_buffer_->receive_release(this->acquired_item_);
this->acquired_item_ = nullptr;
}
void RingBufferAudioSource::consume(size_t bytes) {
bytes = std::min(bytes, this->current_available_);
this->current_data_ += bytes;
this->current_available_ -= bytes;
// Promotion of queued data is deferred to fill() so callers see new data as a fresh return value
// rather than appearing silently after consume(). When the held item has nothing left depending
// on it (no exposed bytes and no queued region), release it now so the ring buffer can be
// reclaimed by writers even if fill() is never called again.
if (this->current_available_ == 0 && this->queued_length_ == 0) {
this->release_item_();
}
}
bool RingBufferAudioSource::has_buffered_data() const {
// splice_length_ is deliberately not considered here. It holds an incomplete frame whose completion
// bytes must still arrive through the ring buffer, which ring_buffer_->available() already reports.
// Counting it separately would strand a drain loop when a stream ends mid-frame and those completion
// bytes never come.
return (this->current_available_ > 0) || (this->queued_length_ > 0) || (this->ring_buffer_->available() > 0);
}
size_t RingBufferAudioSource::fill(TickType_t ticks_to_wait, bool /*pre_shift*/) {
if (this->current_available_ > 0) {
// Caller has not finished consuming the current exposure
return 0;
}
// If a queued region (the aligned remainder of the new chunk after a splice frame) is waiting,
// promote it to the exposed region and report its size as fresh data.
if (this->queued_length_ > 0) {
this->current_data_ = this->queued_data_;
this->current_available_ = this->queued_length_;
this->queued_data_ = nullptr;
this->queued_length_ = 0;
return this->current_available_;
}
// Nothing exposed and nothing queued: release the previously held item (saving any sub-frame tail
// to splice_buffer_) and acquire a new chunk.
this->release_item_();
size_t chunk_length = 0;
void *item = this->ring_buffer_->receive_acquire(chunk_length, this->max_fill_bytes_, ticks_to_wait);
if (item == nullptr) {
return 0;
}
uint8_t *chunk_data = static_cast<uint8_t *>(item);
bool exposing_splice_frame = false;
// Complete any pending splice frame from the head of the new chunk.
if (this->splice_length_ > 0) {
const size_t needed = static_cast<size_t>(this->alignment_bytes_) - this->splice_length_;
if (chunk_length < needed) {
// Not enough data to complete the spliced frame yet; absorb everything and wait for more.
std::memcpy(this->splice_buffer_ + this->splice_length_, chunk_data, chunk_length);
this->splice_length_ += chunk_length;
this->ring_buffer_->receive_release(item);
return 0;
}
std::memcpy(this->splice_buffer_ + this->splice_length_, chunk_data, needed);
chunk_data += needed;
chunk_length -= needed;
this->splice_length_ = 0;
exposing_splice_frame = true;
}
this->acquired_item_ = item;
// Split the remaining chunk into its aligned region and a (possibly zero) sub-frame trailing tail.
const size_t trailing = (this->alignment_bytes_ > 1) ? (chunk_length % this->alignment_bytes_) : 0;
const size_t aligned_bytes = chunk_length - trailing;
if (trailing > 0) {
this->item_trailing_ptr_ = chunk_data + aligned_bytes;
this->item_trailing_length_ = trailing;
}
if (exposing_splice_frame) {
// Expose the spliced frame from splice_buffer_, queuing the chunk's aligned region for the next
// fill() call.
this->current_data_ = this->splice_buffer_;
this->current_available_ = this->alignment_bytes_;
this->queued_data_ = chunk_data;
this->queued_length_ = aligned_bytes;
return this->alignment_bytes_;
}
if (aligned_bytes == 0) {
// The entire chunk is a sub-frame tail (only possible when alignment exceeds chunk size). Save it
// to the splice buffer and release the item so the next fill() can complete the frame.
this->release_item_();
return 0;
}
this->current_data_ = chunk_data;
this->current_available_ = aligned_bytes;
return aligned_bytes;
}
} // namespace esphome::audio
#endif
@@ -214,86 +214,6 @@ class ConstAudioSourceBuffer : public AudioReadableBuffer {
size_t length_{0};
};
/// @brief Zero-copy audio source that reads directly from a ring buffer's internal storage.
///
/// Optionally enforces a minimum read alignment (e.g. one audio frame). When alignment_bytes > 1, the
/// source transparently stitches frames that straddle the ring buffer's wrap boundary by buffering the
/// trailing partial frame from one chunk and joining it with the head of the next chunk in a small
/// internal splice buffer, so callers always see frame-aligned data.
///
/// Not thread-safe. The underlying ring_buffer::RingBuffer supports one producer and one consumer
/// running concurrently, but a given RingBufferAudioSource (its acquired item, splice buffer, and
/// queued region) must be used by only one thread, and that thread is the ring buffer's consumer.
class RingBufferAudioSource : public AudioReadableBuffer {
public:
/// Maximum supported alignment. Sized to cover 32-bit samples across up to 2 channels (8 bytes).
static constexpr size_t MAX_ALIGNMENT_BYTES = 8;
/// @brief Creates a new ring-buffer-backed audio source after validating its parameters.
/// @param ring_buffer The ring buffer to read from. Must be non-null.
/// @param max_fill_bytes Soft cap on bytes acquired per fill() call. Must be > 0.
/// @param alignment_bytes Minimum exposed-region alignment in bytes (defaults to 1, i.e. byte-aligned).
/// Pass bytes_per_frame to make every exposed region a whole number of frames. Must be in
/// [1, MAX_ALIGNMENT_BYTES].
/// @return unique_ptr if parameters are valid, nullptr otherwise
static std::unique_ptr<RingBufferAudioSource> create(std::shared_ptr<ring_buffer::RingBuffer> ring_buffer,
size_t max_fill_bytes, uint8_t alignment_bytes = 1);
~RingBufferAudioSource() override;
// AudioReadableBuffer interface
const uint8_t *data() const override { return this->current_data_; }
size_t available() const override { return this->current_available_; }
void consume(size_t bytes) override;
bool has_buffered_data() const override;
/// pre_shift is ignored: there is no intermediate transfer buffer to compact, so an unconsumed
/// exposure stays in place and fill() returns 0 until it is fully consumed.
size_t fill(TickType_t ticks_to_wait, bool pre_shift) override;
/// @brief Returns a mutable pointer to the currently exposed audio data.
/// The pointer may reference the ring buffer's internal storage or, when exposing a stitched frame
/// across a wrap boundary, an internal splice buffer. In either case mutations are safe but data
/// should be discarded after use, since the underlying storage will be reused on the next fill().
/// Use only when the caller is the sole consumer of this source.
uint8_t *mutable_data() { return this->current_data_; }
protected:
/// @brief Constructs a new ring-buffer-backed audio source. Use create() instead, which validates
/// arguments before construction.
explicit RingBufferAudioSource(std::shared_ptr<ring_buffer::RingBuffer> ring_buffer, size_t max_fill_bytes,
uint8_t alignment_bytes)
: ring_buffer_(std::move(ring_buffer)), max_fill_bytes_(max_fill_bytes), alignment_bytes_(alignment_bytes) {}
/// @brief Releases the currently held ring buffer item, first copying any trailing sub-frame bytes
/// into the splice buffer so they can be stitched with the next chunk.
void release_item_();
std::shared_ptr<ring_buffer::RingBuffer> ring_buffer_;
size_t max_fill_bytes_;
void *acquired_item_{nullptr};
uint8_t *current_data_{nullptr};
// Sub-frame trailing bytes inside the held item that will be copied to splice_buffer_ on release.
uint8_t *item_trailing_ptr_{nullptr};
// After the currently-exposed splice frame is consumed, fill() will promote this region (the aligned
// remainder of the new chunk) to the exposed region. queued_length_ == 0 when nothing is queued.
uint8_t *queued_data_{nullptr};
// Splice buffer holds the start of a partial frame whose remainder lives at the head of the next
// chunk. While splice_length_ > 0, the buffer is incomplete and waiting for completion bytes.
uint8_t splice_buffer_[MAX_ALIGNMENT_BYTES];
size_t current_available_{0};
size_t queued_length_{0};
// item_trailing_length_ and splice_length_ are bounded by MAX_ALIGNMENT_BYTES.
uint8_t alignment_bytes_;
uint8_t item_trailing_length_{0};
uint8_t splice_length_{0};
};
} // namespace esphome::audio
#endif
@@ -135,26 +135,12 @@ void BluetoothConnection::loop() {
// - For V3_WITH_CACHE: Services are never sent, disable after INIT state
// - For V3_WITHOUT_CACHE: Disable only after service discovery is complete
// (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent)
// Never disable while DISCONNECTING — BLEClientBase::loop() needs to keep running so the
// 10s safety timeout can force IDLE if CLOSE_EVT is never delivered.
if (this->state() != espbt::ClientState::INIT && this->state() != espbt::ClientState::DISCONNECTING &&
(this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
this->send_service_ == DONE_SENDING_SERVICES)) {
if (this->state() != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
this->send_service_ == DONE_SENDING_SERVICES)) {
this->disable_loop();
}
}
void BluetoothConnection::on_disconnect_complete(esp_err_t reason) {
// Called from both the CLOSE_EVT handler and the DISCONNECTING safety timeout in the
// base class. Free the proxy slot, notify the API client, and reset send_service_.
// address_ may already be 0 if reset_connection_ ran earlier on this teardown.
if (this->address_ == 0) {
return;
}
ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, reason);
this->reset_connection_(reason);
}
void BluetoothConnection::reset_connection_(esp_err_t reason) {
// Send disconnection notification
this->proxy_->send_device_connection(this->address_, false, 0, reason);
@@ -386,6 +372,14 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason);
break;
}
case ESP_GATTC_CLOSE_EVT: {
ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_,
param->close.reason);
// Now the GATT connection is fully closed and controller resources are freed
// Safe to mark the connection slot as available
this->reset_connection_(param->close.reason);
break;
}
case ESP_GATTC_OPEN_EVT: {
if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) {
this->reset_connection_(param->open.status);
@@ -33,8 +33,6 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase {
protected:
friend class BluetoothProxy;
void on_disconnect_complete(esp_err_t reason) override;
bool supports_efficient_uuids_() const;
void send_service_for_discovery_();
void reset_connection_(esp_err_t reason);
@@ -1,6 +1,5 @@
#include "bluetooth_proxy.h"
#include "esphome/components/api/api_server.h"
#include "esphome/core/log.h"
#include "esphome/core/macros.h"
#include "esphome/core/application.h"
@@ -161,7 +161,7 @@ void BME680BSECComponent::dump_config() {
" IAQ Mode: %s\n"
" Supply Voltage: %sV\n"
" Sample Rate: %s\n"
" State Save Interval: %" PRIu32 "ms",
" State Save Interval: %ims",
this->temperature_offset_, this->iaq_mode_ == IAQ_MODE_STATIC ? "Static" : "Mobile",
this->supply_voltage_ == SUPPLY_VOLTAGE_3V3 ? "3.3" : "1.8",
BME680_BSEC_SAMPLE_RATE_LOG(this->sample_rate_), this->state_save_interval_ms_);
@@ -461,7 +461,7 @@ int8_t BME680BSECComponent::write_bytes_wrapper(uint8_t devid, uint8_t a_registe
}
void BME680BSECComponent::delay_ms(uint32_t period) {
ESP_LOGV(TAG, "Delaying for %" PRIu32 "ms", period);
ESP_LOGV(TAG, "Delaying for %ums", period);
delay(period);
}
+48 -139
View File
@@ -46,7 +46,7 @@ from esphome.const import (
Toolchain,
__version__,
)
from esphome.core import CORE, EsphomeError, HexInt, Library
from esphome.core import CORE, HexInt, Library
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.coroutine import CoroPriority, coroutine_with_priority
from esphome.espidf.component import generate_idf_component
@@ -113,7 +113,6 @@ ARDUINO_FRAMEWORK_NAME = "framework-arduinoespressif32"
ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}"
ARDUINO_LIBS_NAME = f"{ARDUINO_FRAMEWORK_NAME}-libs"
ARDUINO_LIBS_PKG = f"pioarduino/{ARDUINO_LIBS_NAME}"
ARDUINO_ESP32_COMPONENT_NAME = "espressif/arduino-esp32"
LOG_LEVELS_IDF = [
"NONE",
@@ -589,18 +588,6 @@ def add_idf_component(
}
def get_managed_component_require_names() -> list[str]:
"""Return sorted IDF require names for components added via
``add_idf_component`` (``owner/name`` -> ``owner__name``).
The build_gen layer (``build_gen.espidf.get_project_cmakelists``)
feeds this list into ``ESPHOME_PROJECT_MANAGED_COMPONENTS`` so
converted PIO libraries can REQUIRE them by name at configure time.
"""
components_registry = CORE.data.get(KEY_ESP32, {}).get(KEY_COMPONENTS, {})
return sorted(name.replace("/", "__") for name in components_registry)
def exclude_builtin_idf_component(name: str) -> None:
"""Exclude an ESP-IDF component from the build.
@@ -793,15 +780,19 @@ PLATFORM_VERSION_LOOKUP = {
}
def _resolve_framework_version(value: ConfigType) -> cv.Version:
"""Resolve a named or raw framework version and validate the minimum.
def _check_pio_versions(config):
config = config.copy()
value = config[CONF_FRAMEWORK]
Normalises value[CONF_VERSION] to its string form and returns the parsed
cv.Version. Shared between the PIO and esp-idf toolchain paths; toolchain-
specific concerns (source defaults, platform_version) live in the per-
toolchain functions.
"""
if value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP:
if CONF_SOURCE in value or CONF_PLATFORM_VERSION in value:
raise cv.Invalid(
"Version needs to be explicitly set when a custom source or platform_version is used."
)
platform_lookup = PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]]
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup))
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
version = ARDUINO_FRAMEWORK_VERSION_LOOKUP[value[CONF_VERSION]]
else:
@@ -814,38 +805,7 @@ def _resolve_framework_version(value: ConfigType) -> cv.Version:
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
if version < cv.Version(3, 0, 0):
raise cv.Invalid("Only Arduino 3.0+ is supported.")
recommended = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"]
else:
if version < cv.Version(5, 0, 0):
raise cv.Invalid("Only ESP-IDF 5.0+ is supported.")
recommended = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]
if version != recommended:
_LOGGER.warning(
"The selected framework version is not the recommended one. "
"If there are connectivity or build issues please remove the manual version."
)
return version
def _check_pio_versions(config: ConfigType) -> ConfigType:
config = config.copy()
value = config[CONF_FRAMEWORK]
is_named_version = value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP
if is_named_version and (CONF_SOURCE in value or CONF_PLATFORM_VERSION in value):
raise cv.Invalid(
"Version needs to be explicitly set when a custom source or platform_version is used."
)
if is_named_version:
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(
str(PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]])
)
version = _resolve_framework_version(value)
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
recommended_version = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"]
platform_lookup = ARDUINO_PLATFORM_VERSION_LOOKUP.get(version)
value[CONF_SOURCE] = value.get(
CONF_SOURCE, _format_framework_arduino_version(version)
@@ -853,6 +813,9 @@ def _check_pio_versions(config: ConfigType) -> ConfigType:
if _is_framework_url(value[CONF_SOURCE]):
value[CONF_SOURCE] = f"{ARDUINO_FRAMEWORK_PKG}@{value[CONF_SOURCE]}"
else:
if version < cv.Version(5, 0, 0):
raise cv.Invalid("Only ESP-IDF 5.0+ is supported.")
recommended_version = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]
platform_lookup = ESP_IDF_PLATFORM_VERSION_LOOKUP.get(version)
value[CONF_SOURCE] = value.get(
CONF_SOURCE,
@@ -868,6 +831,12 @@ def _check_pio_versions(config: ConfigType) -> ConfigType:
)
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup))
if version != recommended_version:
_LOGGER.warning(
"The selected framework version is not the recommended one. "
"If there are connectivity or build issues please remove the manual version."
)
if value[CONF_PLATFORM_VERSION] != _parse_pio_platform_version(
str(PLATFORM_VERSION_LOOKUP["recommended"])
):
@@ -879,26 +848,19 @@ def _check_pio_versions(config: ConfigType) -> ConfigType:
return config
def _check_esp_idf_versions(config: ConfigType) -> ConfigType:
config = config.copy()
def _check_esp_idf_versions(config):
config = _check_pio_versions(config)
value = config[CONF_FRAMEWORK]
# platform_version is a PlatformIO concept; drop it if a user carried it
# over from a PIO-style config. CONF_SOURCE, on the other hand, is kept:
# it lets a user override the framework tarball URL under the esp-idf
# toolchain (the espidf framework downloader consults it).
value.pop(CONF_PLATFORM_VERSION, None)
# Remove unwanted keys if present
for key in (CONF_SOURCE, CONF_PLATFORM_VERSION):
value.pop(key, None)
version = _resolve_framework_version(value)
# Official ESP-IDF frameworks don't use extra
version = cv.Version.parse(value[CONF_VERSION])
version = cv.Version(version.major, version.minor, version.patch)
if CONF_SOURCE in value:
_LOGGER.warning(
"A custom framework source is set. "
"If there are connectivity or build issues please remove the manual source."
)
# Official ESP-IDF frameworks don't use the 'extra' semver component.
value[CONF_VERSION] = str(cv.Version(version.major, version.minor, version.patch))
value[CONF_VERSION] = str(version)
return config
@@ -1744,31 +1706,6 @@ async def _add_yaml_idf_components(components: list[ConfigType]):
)
@coroutine_with_priority(CoroPriority.FINAL - 1)
async def _finalize_arduino_aware_flags():
"""Build flags that depend on whether arduino-esp32 is linked in.
Scheduler runs lower priority values later, so ``FINAL - 1`` fires
after every ``FINAL`` job (incl. ``_add_yaml_idf_components``) --
by then ``KEY_COMPONENTS`` is fully populated.
- Skip our esp_panic_handler wrap when Arduino is linked; Arduino
wraps the same symbol and the linker errors on the duplicate.
- Define USE_ARDUINO in the hybrid esp-idf+arduino-esp32-component
case so ESPHome's ``#ifdef USE_ARDUINO`` paths light up. The
framework=arduino branch already adds it inline in to_code.
"""
arduino_linked = (
CORE.using_arduino
or ARDUINO_ESP32_COMPONENT_NAME in CORE.data[KEY_ESP32][KEY_COMPONENTS]
)
if not arduino_linked:
cg.add_build_flag("-Wl,--wrap=esp_panic_handler")
cg.add_define("USE_ESP32_CRASH_HANDLER")
elif not CORE.using_arduino:
cg.add_build_flag("-DUSE_ARDUINO")
async def to_code(config):
framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
conf = config[CONF_FRAMEWORK]
@@ -1816,21 +1753,19 @@ async def to_code(config):
Path(__file__).parent / "iram_fix.py.script",
)
else:
# Demote IDF's blanket -Werror to warnings so third-party libs
# and user lambdas don't need a -Wno-error=<class> per warning.
# The sdkconfig knob disables IDF's rewrite to -Werror=all (which
# can't be globally undone); -Wno-error then handles the demotion.
add_idf_sdkconfig_option("CONFIG_COMPILER_DISABLE_DEFAULT_ERRORS", False)
cg.add_build_flag("-Wno-error")
# -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates
cg.add_build_flag("-Wno-missing-field-initializers")
cg.add_build_flag("-Wno-error=format")
cg.add_build_flag("-Wno-error=missing-field-initializers")
cg.add_build_flag("-Wno-error=volatile")
cg.set_cpp_standard("gnu++20")
cg.add_build_flag("-DUSE_ESP32")
cg.add_define("USE_NATIVE_64BIT_TIME")
cg.add_build_flag("-Wl,-z,noexecstack")
# Deferred so KEY_COMPONENTS is fully populated -- see the coroutine.
CORE.add_job(_finalize_arduino_aware_flags)
# Arduino already wraps esp_panic_handler for its own backtrace handler,
# so only add our wrap when using ESP-IDF framework to avoid linker conflicts.
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
cg.add_build_flag("-Wl,--wrap=esp_panic_handler")
cg.add_define("USE_ESP32_CRASH_HANDLER")
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
variant = config[CONF_VARIANT]
cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}")
@@ -2515,14 +2450,8 @@ def _write_sdkconfig():
)
want_opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
# Include the resolved framework version as a Kconfig comment so a
# version switch that happens to leave the option set unchanged still
# bumps this file's content -- which is what has_outdated_files()
# uses to decide whether to reconfigure.
framework_version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
contents = (
f"# ESPHOME_IDF_VERSION={framework_version}\n"
+ "\n".join(
"\n".join(
f"{name}={_format_sdkconfig_val(value)}"
for name, value in sorted(want_opts.items())
)
@@ -2537,8 +2466,9 @@ def _write_sdkconfig():
def _platformio_library_to_dependency(library: Library) -> tuple[str, dict[str, str]]:
dependency: dict[str, str] = {}
name, _version, path = generate_idf_component(library)
name, version, path = generate_idf_component(library)
dependency["override_path"] = str(path)
dependency["version"] = version
return name, dependency
@@ -2565,12 +2495,7 @@ def _write_idf_component_yml():
stubs_dir = CORE.relative_build_path("component_stubs")
stubs_dir.mkdir(exist_ok=True)
# Sort so the dict insertion order (and thus the generated
# src/idf_component.yml) is deterministic across runs; otherwise
# the manifest content shuffles every build, write_file_if_changed
# always writes, and ninja keeps triggering CMake re-runs on
# otherwise-cached rebuilds.
for component_name in sorted(components_to_stub):
for component_name in components_to_stub:
# Create stub directory with minimal CMakeLists.txt
stub_path = stubs_dir / _idf_component_stub_name(component_name)
stub_path.mkdir(exist_ok=True)
@@ -2590,7 +2515,7 @@ def _write_idf_component_yml():
if CORE.using_toolchain_esp_idf:
add_idf_component(
name=ARDUINO_ESP32_COMPONENT_NAME,
name="espressif/arduino-esp32",
ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]),
)
@@ -2657,29 +2582,13 @@ def copy_files():
def _decode_pc(config, addr):
# _decode_pc runs from the api log processor's asyncio callback, which
# only catches EsphomeError. Any other exception escaping here tears down
# the protocol and triggers an infinite reconnect/replay loop. Convert
# toolchain-resolution errors (e.g. missing build dir / cmake cache) into
# EsphomeError so the caller can disable decoding cleanly.
if CORE.using_toolchain_esp_idf:
from esphome.espidf import toolchain as idf_toolchain
from esphome.platformio import toolchain
try:
addr2line_path = idf_toolchain.get_addr2line_path()
firmware_elf_path = idf_toolchain.get_elf_path()
except RuntimeError as err:
raise EsphomeError(f"ESP-IDF toolchain not available: {err}") from err
else:
from esphome.platformio import toolchain
idedata = toolchain.get_idedata(config)
addr2line_path = idedata.addr2line_path
firmware_elf_path = idedata.firmware_elf_path
if not addr2line_path or not firmware_elf_path:
idedata = toolchain.get_idedata(config)
if not idedata.addr2line_path or not idedata.firmware_elf_path:
_LOGGER.debug("decode_pc no addr2line")
return
command = [str(addr2line_path), "-pfiaC", "-e", str(firmware_elf_path), addr]
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
try:
translation = subprocess.check_output(command, close_fds=False).decode().strip()
except Exception: # pylint: disable=broad-except
@@ -72,7 +72,6 @@ void BLEClientBase::loop() {
// never delivered CLOSE_EVT/DISCONNECT_EVT, services would leak without this call.
this->release_services();
this->set_idle_();
this->on_disconnect_complete(ESP_GATT_CONN_TIMEOUT);
}
}
@@ -419,7 +418,6 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
this->log_gattc_lifecycle_event_("CLOSE");
this->release_services();
this->set_idle_();
this->on_disconnect_complete(param->close.reason);
break;
}
case ESP_GATTC_SEARCH_RES_EVT: {
@@ -140,12 +140,6 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
void log_gattc_warning_(const char *operation, esp_err_t err);
void log_connection_params_(const char *param_type);
void handle_connection_result_(esp_err_t ret);
/// Hook called once a connection has been fully torn down (after release_services() and
/// set_idle_()), from both the CLOSE_EVT handler and the DISCONNECTING safety timeout.
/// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state)
/// override this to release that state. `reason` is the controller reason code, or
/// ESP_GATT_CONN_TIMEOUT for the safety-timeout path.
virtual void on_disconnect_complete(esp_err_t reason) {}
/// Transition to IDLE and reset conn_id — call when the connection is fully dead.
void set_idle_() {
this->set_state(espbt::ClientState::IDLE);
@@ -155,10 +149,6 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
void set_disconnecting_() {
this->disconnecting_started_ = millis();
this->set_state(espbt::ClientState::DISCONNECTING);
// BluetoothConnection::loop() disables the component loop after service discovery
// completes, so the DISCONNECTING timeout check in loop() would never run if CLOSE_EVT
// gets lost. Re-enable the loop so the 10s safety timeout can force IDLE.
this->enable_loop();
}
// Compact error logging helpers to reduce flash usage
void log_error_(const char *message);
+1 -1
View File
@@ -249,7 +249,7 @@ async def to_code(config):
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.7")
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")
@@ -92,7 +92,7 @@ void Esp32HostedUpdate::setup() {
if (esp_hosted_get_coprocessor_fwversion(&ver_info) == ESP_OK) {
// 16 bytes: "255.255.255" (11 chars) + null + safety margin
char buf[16];
snprintf(buf, sizeof(buf), "%" PRIu32 ".%" PRIu32 ".%" PRIu32, ver_info.major1, ver_info.minor1, ver_info.patch1);
snprintf(buf, sizeof(buf), "%d.%d.%d", ver_info.major1, ver_info.minor1, ver_info.patch1);
this->update_info_.current_version = buf;
} else {
this->update_info_.current_version = "unknown";
@@ -120,8 +120,8 @@ void Esp32HostedUpdate::setup() {
this->state_ = update::UPDATE_STATE_NO_UPDATE;
}
} else {
ESP_LOGW(TAG, "Invalid app description magic word: 0x%08" PRIx32 " (expected 0x%08" PRIx32 ")",
app_desc->magic_word, static_cast<uint32_t>(ESP_APP_DESC_MAGIC_WORD));
ESP_LOGW(TAG, "Invalid app description magic word: 0x%08x (expected 0x%08x)", app_desc->magic_word,
ESP_APP_DESC_MAGIC_WORD);
this->state_ = update::UPDATE_STATE_NO_UPDATE;
}
} else {
+12 -12
View File
@@ -5,7 +5,6 @@
#include <Arduino.h>
#include <core_esp8266_features.h>
#include <coredecls.h>
extern "C" {
#include <user_interface.h>
@@ -72,22 +71,23 @@ uint32_t IRAM_ATTR HOT millis() {
return result;
}
// Delegate to Arduino's 1-arg esp_delay(), which uses os_timer + esp_suspend to
// suspend the cont task for `ms` milliseconds without polling millis(). This
// matches pre-2026.5.0 behavior (when esphome::delay() forwarded to ::delay())
// and lets the SDK run freely while we wait, which timing-sensitive
// interrupt-driven code (e.g. ESP8266 software-serial RX in components like
// fingerprint_grow) depends on. The poll-based busy-wait that this replaced
// rarely yielded inside short waits like delay(1), starving WiFi/SDK tasks and
// extending interrupt latency. Unlike ::delay(), esp_delay()'s 1-arg form does
// not call millis(), so the slow Arduino millis() body is not pulled into IRAM
// by this path (the --wrap=millis goal of #15662 is preserved).
// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object
// call to the original millis() that --wrap can't intercept, so calling ::delay()
// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still
// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and
// WiFi run correctly. Theoretically less power-efficient than Arduino's
// os_timer-based delay() for long waits, but nearly all ESPHome delays are short
// (sensor/I²C/SPI settling in the 1100 ms range) where the difference is
// negligible.
void HOT delay(uint32_t ms) {
if (ms == 0) {
optimistic_yield(1000);
return;
}
esp_delay(ms);
uint32_t start = millis();
while (millis() - start < ms) {
optimistic_yield(1000);
}
}
void arch_restart() {
@@ -108,8 +108,8 @@ void ESPHomeOTAComponent::dump_config() {
ESP_LOGCONFIG(TAG,
" Partition access allowed\n"
" Running app:\n"
" Partition address: 0x%" PRIX32 "\n"
" Used size: %zu bytes (0x%zX)",
" Partition address: 0x%X\n"
" Used size: %zu bytes (0x%X)",
this->running_app_offset_, this->running_app_size_, this->running_app_size_);
#ifdef USE_ESP32
@@ -378,7 +378,7 @@ void ESPHomeOTAComponent::handle_data_() {
}
ota_size = (static_cast<size_t>(buf[0]) << 24) | (static_cast<size_t>(buf[1]) << 16) |
(static_cast<size_t>(buf[2]) << 8) | buf[3];
ESP_LOGV(TAG, "Size is %zu bytes", ota_size);
ESP_LOGV(TAG, "Size is %u bytes", ota_size);
#ifndef USE_OTA_PARTITIONS
if (ota_type != ota::OTA_TYPE_UPDATE_APP) {
@@ -749,7 +749,7 @@ bool ESPHomeOTAComponent::handle_auth_send_() {
this->auth_buf_[0] = this->auth_type_;
hasher.get_hex(buf);
ESP_LOGV(TAG, "Auth: Nonce is %.*s", (int) hex_size, buf);
ESP_LOGV(TAG, "Auth: Nonce is %.*s", hex_size, buf);
}
// Try to write auth_type + nonce
@@ -809,13 +809,13 @@ bool ESPHomeOTAComponent::handle_auth_read_() {
hasher.add(nonce, hex_size * 2); // Add both nonce and cnonce (contiguous in buffer)
hasher.calculate();
ESP_LOGV(TAG, "Auth: CNonce is %.*s", (int) hex_size, cnonce);
ESP_LOGV(TAG, "Auth: CNonce is %.*s", hex_size, cnonce);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char computed_hash[SHA256_HEX_SIZE + 1]; // Buffer for hex-encoded hash (max expected length + null terminator)
hasher.get_hex(computed_hash);
ESP_LOGV(TAG, "Auth: Result is %.*s", (int) hex_size, computed_hash);
ESP_LOGV(TAG, "Auth: Result is %.*s", hex_size, computed_hash);
#endif
ESP_LOGV(TAG, "Auth: Response is %.*s", (int) hex_size, response);
ESP_LOGV(TAG, "Auth: Response is %.*s", hex_size, response);
// Compare response
bool matches = hasher.equals_hex(response);
@@ -19,7 +19,7 @@ void FastLEDLightOutput::dump_config() {
ESP_LOGCONFIG(TAG,
"FastLED light:\n"
" Num LEDs: %u\n"
" Max refresh rate: %" PRIu32,
" Max refresh rate: %u",
this->num_leds_, this->max_refresh_rate_.value_or(0));
}
void FastLEDLightOutput::write_state(light::LightState *state) {
@@ -206,7 +206,6 @@ uint8_t FingerprintGrowComponent::save_fingerprint_() {
break;
case ENROLL_MISMATCH:
ESP_LOGE(TAG, "Scans do not match");
[[fallthrough]];
default:
return this->data_[0];
}
@@ -15,16 +15,6 @@ void FT5x06Touchscreen::setup() {
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
}
// reading the chip registers to get max x/y does not seem to work.
if (this->display_ != nullptr) {
if (this->x_raw_max_ == this->x_raw_min_) {
this->x_raw_max_ = this->display_->get_native_width();
}
if (this->y_raw_max_ == this->y_raw_min_) {
this->y_raw_max_ = this->display_->get_native_height();
}
}
// wait 200ms after reset.
this->set_timeout(200, [this] { this->continue_setup_(); });
}
@@ -49,6 +39,15 @@ void FT5x06Touchscreen::continue_setup_() {
this->mark_failed();
return;
}
// reading the chip registers to get max x/y does not seem to work.
if (this->display_ != nullptr) {
if (this->x_raw_max_ == this->x_raw_min_) {
this->x_raw_max_ = this->display_->get_native_width();
}
if (this->y_raw_max_ == this->y_raw_min_) {
this->y_raw_max_ = this->display_->get_native_height();
}
}
}
void FT5x06Touchscreen::update_touches() {
@@ -72,7 +71,7 @@ void FT5x06Touchscreen::update_touches() {
uint16_t x = encode_uint16(data[i][0] & 0x0F, data[i][1]);
uint16_t y = encode_uint16(data[i][2] & 0xF, data[i][3]);
ESP_LOGV(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y);
ESP_LOGD(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y);
if (status == 0 || status == 2) {
this->add_raw_touch_position_(id, x, y);
}
+1 -1
View File
@@ -22,7 +22,7 @@ static constexpr uint8_t MEAS_CONF_HUM = 0x04; // Bits 2:1 = 10: humidity only
void HDC2080Component::setup() {
const uint8_t data = 0x00; // automatic measurement mode disabled, heater off
if (this->write_register(REG_RESET_DRDY_INT_CONF, &data, 1) != i2c::ERROR_OK) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
this->mark_failed(ESP_LOG_MSG_COMM_FAIL);
return;
}
}
+1
View File
@@ -125,6 +125,7 @@ async def to_code(config):
cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT]))
cg.add(var.set_max_temperature(config[CONF_MAX_TEMPERATURE]))
cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE]))
cg.add_build_flag("-Wno-error=overloaded-virtual")
cg.add_library("tonia/HeatpumpIR", "1.0.41")
if CORE.is_libretiny or CORE.is_esp32:
@@ -89,10 +89,10 @@ def _set_num_channels_from_config(config):
def _set_stream_limits(config):
if config.get(CONF_SPDIF_MODE, False):
# SPDIF mode: 16/24/32-bit audio and stereo at configured sample rate
# SPDIF mode: fixed to 16-bit stereo at configured sample rate
audio.set_stream_limits(
min_bits_per_sample=16,
max_bits_per_sample=32,
max_bits_per_sample=16,
min_channels=2,
max_channels=2,
min_sample_rate=config.get(CONF_SAMPLE_RATE),
@@ -213,6 +213,9 @@ def _final_validate(config):
)
if config[CONF_CHANNEL] != CONF_STEREO:
raise cv.Invalid("SPDIF mode only supports stereo channel configuration")
# bits_per_sample is converted to float by the schema
if config[CONF_BITS_PER_SAMPLE] != 16:
raise cv.Invalid("SPDIF mode only supports 16 bits per sample")
if not config[CONF_USE_APLL]:
raise cv.Invalid(
"SPDIF mode requires 'use_apll: true' for accurate clock generation"
@@ -138,30 +138,26 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
// Reset lockstep records queue so it starts paired with the (also-reset) i2s_event_queue_.
xQueueReset(this->write_records_queue_);
// The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info
const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1);
const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * SPDIF_DMA_BUFFERS_COUNT;
// Ensure ring buffer duration is at least the duration of all DMA buffers
const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_);
// For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames (~4 ms at 48 kHz),
// not the ~15 ms a standard I2S DMA buffer holds. Derive the DMA floor from actual block size.
// The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info
const size_t ring_buffer_size = this->current_stream_info_.ms_to_bytes(ring_buffer_duration);
// For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames
const uint32_t frames_to_fill_single_dma_buffer = SPDIF_BLOCK_SAMPLES;
const size_t bytes_to_fill_single_dma_buffer =
this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer);
const size_t dma_buffers_floor_bytes = bytes_to_fill_single_dma_buffer * SPDIF_DMA_BUFFERS_COUNT;
// Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and
// avoids unnecessary single-frame splices. Ensure it is at least large enough to cover all DMA buffers.
const size_t requested_ring_buffer_bytes =
(this->current_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame;
const size_t ring_buffer_size = std::max(dma_buffers_floor_bytes, requested_ring_buffer_bytes);
bool successful_setup = false;
std::unique_ptr<audio::RingBufferAudioSource> audio_source;
std::unique_ptr<audio::AudioSourceTransferBuffer> transfer_buffer =
audio::AudioSourceTransferBuffer::create(bytes_to_fill_single_dma_buffer);
{
if (transfer_buffer != nullptr) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, bytes_to_fill_single_dma_buffer,
static_cast<uint8_t>(bytes_per_frame));
if (audio_source != nullptr) {
if (temp_ring_buffer.use_count() == 1) {
transfer_buffer->set_source(temp_ring_buffer);
this->audio_ring_buffer_ = temp_ring_buffer;
successful_setup = true;
}
@@ -177,8 +173,7 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
// on_sent events drain in lockstep without crediting any audio frames.
this->spdif_encoder_->set_preload_mode(true);
for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) {
// i2s_channel_preload_data is non-blocking (returns immediately when the preload buffer fills), so no wait.
esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(0);
esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS));
if (preload_err != ESP_OK) {
break; // DMA preload buffer full or error
}
@@ -302,24 +297,24 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
if (!this->pause_state_) {
while (real_frames_in_block < SPDIF_BLOCK_SAMPLES) {
if (audio_source->available() == 0) {
size_t bytes_read = audio_source->fill(read_timeout_ticks, false);
if (transfer_buffer->available() == 0) {
size_t bytes_read = transfer_buffer->transfer_data_from_source(read_timeout_ticks);
if (bytes_read == 0) {
break; // No upstream data within the read budget; silence-pad the remainder.
}
uint8_t *new_data = audio_source->mutable_data();
uint8_t *new_data = transfer_buffer->get_buffer_end() - bytes_read;
this->apply_software_volume_(new_data, bytes_read);
this->swap_esp32_mono_samples_(new_data, bytes_read);
}
const uint32_t frames_still_needed = SPDIF_BLOCK_SAMPLES - real_frames_in_block;
const size_t bytes_still_needed = this->current_stream_info_.frames_to_bytes(frames_still_needed);
const size_t bytes_to_feed = std::min(audio_source->available(), bytes_still_needed);
const size_t bytes_to_feed = std::min(transfer_buffer->available(), bytes_still_needed);
uint32_t blocks_sent = 0;
size_t pcm_consumed = 0;
esp_err_t err = this->spdif_encoder_->write(audio_source->data(), bytes_to_feed, write_timeout_ticks,
&blocks_sent, &pcm_consumed);
esp_err_t err = this->spdif_encoder_->write(transfer_buffer->get_buffer_start(), bytes_to_feed,
write_timeout_ticks, &blocks_sent, &pcm_consumed);
if (err != ESP_OK) {
// A failed (or timed-out) send leaves an unsent block in the encoder's stitch buffer;
// resuming would credit the next iteration's bytes against an old block. Bail and
@@ -330,7 +325,7 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
}
if (pcm_consumed > 0) {
audio_source->consume(pcm_consumed);
transfer_buffer->decrease_buffer_length(pcm_consumed);
real_frames_in_block += this->current_stream_info_.bytes_to_frames(pcm_consumed);
}
if (blocks_sent > 0) {
@@ -392,7 +387,9 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
this->spdif_encoder_->reset();
}
audio_source.reset();
if (transfer_buffer != nullptr) {
transfer_buffer.reset();
}
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPED);
@@ -411,9 +408,8 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s
this->sample_rate_, audio_stream_info.get_sample_rate());
return ESP_ERR_NOT_SUPPORTED;
}
const uint8_t bits_per_sample = audio_stream_info.get_bits_per_sample();
if (bits_per_sample != 16 && bits_per_sample != 24 && bits_per_sample != 32) {
ESP_LOGE(TAG, "Only supports 16, 24, or 32 bits per sample (got %u)", (unsigned) bits_per_sample);
if (audio_stream_info.get_bits_per_sample() != 16) {
ESP_LOGE(TAG, "Only supports 16 bits per sample");
return ESP_ERR_NOT_SUPPORTED;
}
if (audio_stream_info.get_channels() != 2) {
@@ -421,8 +417,11 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s
return ESP_ERR_NOT_SUPPORTED;
}
// Tell the encoder what input width to expect. 32-bit input is truncated to 24-bit on the wire.
this->spdif_encoder_->set_bytes_per_sample(bits_per_sample / 8);
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO &&
(i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) {
ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration");
return ESP_ERR_NOT_SUPPORTED;
}
if (!this->parent_->try_lock()) {
ESP_LOGE(TAG, "Parent bus is busy");
@@ -99,7 +99,7 @@ void I2SAudioSpeakerBase::loop() {
}
if (event_group_bits & SpeakerEventGroupBits::ERR_ESP_NO_MEM) {
ESP_LOGE(TAG, "Speaker task setup failed (allocation, preload, or channel enable)");
ESP_LOGE(TAG, "Not enough memory");
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM);
}
@@ -19,6 +19,7 @@
namespace esphome::i2s_audio {
// Shared constants used by both standard and SPDIF speaker implementations
static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15;
static constexpr size_t TASK_STACK_SIZE = 4096;
static constexpr ssize_t TASK_PRIORITY = 19;
@@ -35,7 +36,9 @@ enum SpeakerEventGroupBits : uint32_t {
ERR_ESP_NO_MEM = (1 << 19),
ERR_DROPPED_EVENT = (1 << 20), // ISR overflowed the event queue, dropping a completion event
ERR_PARTIAL_WRITE = (1 << 21), // i2s_channel_write returned fewer bytes than requested
ERR_PARTIAL_WRITE = (1 << 21), // a DMA write returned fewer bytes than requested (or the encoder
// failed to commit a complete block), which breaks the lockstep
// invariant for every subsequent event
ERR_LOCKSTEP_DESYNC = (1 << 22), // i2s_event_queue_ and write_records_queue_ fell out of sync
ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits
@@ -16,16 +16,8 @@ namespace esphome::i2s_audio {
static const char *const TAG = "i2s_audio.speaker.std";
static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15;
static constexpr size_t DMA_BUFFERS_COUNT = 4;
// Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight,
// doubled so that a transient backlog never overruns the queue (which would desync the lockstep
// invariant between i2s_event_queue_ and write_records_queue_).
static constexpr size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT * 2;
// Generous timeout for ``i2s_channel_write`` blocking. A buffer frees roughly every
// DMA_BUFFER_DURATION_MS, so a multiple of that gives plenty of slack against scheduling jitter
// without masking real failures.
static constexpr TickType_t WRITE_TIMEOUT_TICKS = pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS * (DMA_BUFFERS_COUNT + 1));
static constexpr size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT + 1;
void I2SAudioSpeaker::dump_config() {
I2SAudioSpeakerBase::dump_config();
@@ -52,78 +44,31 @@ void I2SAudioSpeaker::run_speaker_task() {
const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_);
// The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info
const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1);
// Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and
// avoids unnecessary single-frame splices.
const size_t ring_buffer_size =
(this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame;
const uint32_t frames_per_dma_buffer = this->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS);
const size_t dma_buffer_bytes = this->current_stream_info_.frames_to_bytes(frames_per_dma_buffer);
const size_t ring_buffer_size = this->current_stream_info_.ms_to_bytes(ring_buffer_duration);
const uint32_t frames_to_fill_single_dma_buffer = this->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS);
const size_t bytes_to_fill_single_dma_buffer =
this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer);
bool successful_setup = false;
std::unique_ptr<audio::AudioSourceTransferBuffer> transfer_buffer =
audio::AudioSourceTransferBuffer::create(bytes_to_fill_single_dma_buffer);
std::unique_ptr<audio::RingBufferAudioSource> audio_source;
// Pre-zeroed buffer used to silence-pad each DMA descriptor whenever real audio doesn't fully fill it.
RAMAllocator<uint8_t> silence_allocator;
uint8_t *silence_buffer = silence_allocator.allocate(dma_buffer_bytes);
if (silence_buffer != nullptr) {
memset(silence_buffer, 0, dma_buffer_bytes);
if (transfer_buffer != nullptr) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
audio_source =
audio::RingBufferAudioSource::create(temp_ring_buffer, dma_buffer_bytes, static_cast<uint8_t>(bytes_per_frame));
if (audio_source != nullptr) {
// audio_source is nullptr if the ring buffer fails to allocate
if (temp_ring_buffer.use_count() == 1) {
transfer_buffer->set_source(temp_ring_buffer);
this->audio_ring_buffer_ = temp_ring_buffer;
successful_setup = true;
}
}
if (successful_setup) {
// Preload every DMA descriptor with silence and push a matching zero-real-frames record per buffer.
// This guarantees that every on_sent event has a corresponding write record from the start, so
// ``i2s_event_queue_`` and ``write_records_queue_`` stay in lockstep for the entire task lifetime.
for (size_t i = 0; i < DMA_BUFFERS_COUNT; i++) {
size_t bytes_loaded = 0;
esp_err_t err = i2s_channel_preload_data(this->tx_handle_, silence_buffer, dma_buffer_bytes, &bytes_loaded);
if (err != ESP_OK || bytes_loaded != dma_buffer_bytes) {
ESP_LOGV(TAG, "Failed to preload silence into DMA buffer %u (err=%d, loaded=%u)", (unsigned) i, (int) err,
(unsigned) bytes_loaded);
successful_setup = false;
break;
}
uint32_t zero_real_frames = 0;
if (xQueueSend(this->write_records_queue_, &zero_real_frames, 0) != pdTRUE) {
// Should never happen: the queue was just reset and is sized for DMA_BUFFERS_COUNT * 2 entries.
ESP_LOGV(TAG, "Failed to push preload write record");
successful_setup = false;
break;
}
}
}
if (successful_setup) {
// Register the on_sent callback BEFORE enabling the channel so the very first transmitted buffer
// generates a queued event that pairs with the first preloaded silence record.
const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb};
i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this);
if (i2s_channel_enable(this->tx_handle_) != ESP_OK) {
ESP_LOGV(TAG, "Failed to enable I2S channel");
successful_setup = false;
}
}
if (!successful_setup) {
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM);
} else {
bool stop_gracefully = false;
// Number of records currently in ``write_records_queue_`` that carry real audio. Used by graceful
// stop to wait until every real-audio buffer has been confirmed played by an ISR event.
uint32_t pending_real_buffers = 0;
bool tx_dma_underflow = true;
uint32_t frames_written = 0;
uint32_t last_data_received_time = millis();
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING);
@@ -132,21 +77,11 @@ void I2SAudioSpeaker::run_speaker_task() {
// - Paused, OR
// - No timeout configured, OR
// - Timeout hasn't elapsed since last data
//
// Always-fill model: every iteration writes exactly one DMA buffer's worth, mixing real audio
// and silence padding as needed. The blocking ``i2s_channel_write`` paces the loop at the DMA
// consumption rate, and every buffer write is matched 1:1 with a record on ``write_records_queue_``.
//
// While paused, the real-audio fill is skipped and the entire DMA buffer is filled with silence;
// the same blocking ``i2s_channel_write`` provides natural pacing (one buffer per ~DMA_BUFFER_DURATION_MS),
// so the lockstep invariant is preserved without burning CPU.
while (this->pause_state_ || !this->timeout_.has_value() ||
(millis() - last_data_received_time) <= this->timeout_.value()) {
uint32_t event_group_bits = xEventGroupGetBits(this->event_group_);
if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) {
// COMMAND_STOP is set both by user-initiated stop() and by the ISR when it drops a completion
// event (paired with ERR_DROPPED_EVENT so loop() can distinguish the two cases).
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP);
ESP_LOGV(TAG, "Exiting: COMMAND_STOP received");
break;
@@ -162,126 +97,89 @@ void I2SAudioSpeaker::run_speaker_task() {
break;
}
// Drain ISR-stamped completion events. Each event corresponds 1:1 with a write_records_queue_
// entry by construction (preloaded records at startup, plus exactly one record pushed per
// iteration alongside exactly one DMA-buffer-sized write).
int64_t write_timestamp;
bool lockstep_broken = false;
while (xQueueReceive(this->i2s_event_queue_, &write_timestamp, 0)) {
uint32_t real_frames = 0;
if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) {
// Should never happen: would indicate the lockstep invariant is broken.
ESP_LOGV(TAG, "Event without matching write record");
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC);
lockstep_broken = true;
// Receives timing events from the I2S on_sent callback. If actual audio data was sent in this event, it passes
// on the timing info via the audio_output_callback.
uint32_t frames_sent = frames_to_fill_single_dma_buffer;
if (frames_to_fill_single_dma_buffer > frames_written) {
tx_dma_underflow = true;
frames_sent = frames_written;
const uint32_t frames_zeroed = frames_to_fill_single_dma_buffer - frames_written;
write_timestamp -= this->current_stream_info_.frames_to_microseconds(frames_zeroed);
} else {
tx_dma_underflow = false;
}
frames_written -= frames_sent;
// Standard I2S mode: fire callback immediately for each event
if (frames_sent > 0) {
this->audio_output_callback_(frames_sent, write_timestamp);
}
}
if (this->pause_state_) {
// Pause state is accessed atomically, so thread safe
// Delay so the task yields, then skip transferring audio data
vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS));
continue;
}
// Wait half the duration of the data already written to the DMA buffers for new audio data
// The millisecond helper modifies the frames_written variable, so use the microsecond helper and divide by 1000
uint32_t read_delay = (this->current_stream_info_.frames_to_microseconds(frames_written) / 1000) / 2;
size_t bytes_read = transfer_buffer->transfer_data_from_source(pdMS_TO_TICKS(read_delay));
uint8_t *new_data = transfer_buffer->get_buffer_end() - bytes_read;
if (bytes_read > 0) {
this->apply_software_volume_(new_data, bytes_read);
this->swap_esp32_mono_samples_(new_data, bytes_read);
}
if (transfer_buffer->available() == 0) {
if (stop_gracefully && tx_dma_underflow) {
break;
}
if (real_frames > 0) {
pending_real_buffers--;
// Real audio is packed at the start of each DMA buffer with any silence padding on the
// tail, so the real audio finished playing earlier than the buffer-completion timestamp
// by the duration of the trailing zeros.
const uint32_t silence_frames = frames_per_dma_buffer - real_frames;
const int64_t adjusted_ts =
write_timestamp - this->current_stream_info_.frames_to_microseconds(silence_frames);
this->audio_output_callback_(real_frames, adjusted_ts);
vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS / 2));
} else {
size_t bytes_written = 0;
if (tx_dma_underflow) {
// Temporarily disable channel and callback to reset the I2S driver's internal DMA buffer queue
i2s_channel_disable(this->tx_handle_);
const i2s_event_callbacks_t null_callbacks = {.on_sent = nullptr};
i2s_channel_register_event_callback(this->tx_handle_, &null_callbacks, this);
i2s_channel_preload_data(this->tx_handle_, transfer_buffer->get_buffer_start(), transfer_buffer->available(),
&bytes_written);
} else {
// Audio is already playing, use regular write to add to the DMA buffers
i2s_channel_write(this->tx_handle_, transfer_buffer->get_buffer_start(), transfer_buffer->available(),
&bytes_written, DMA_BUFFER_DURATION_MS);
}
}
if (lockstep_broken) {
break;
}
// Graceful stop: exit only after the source's exposed chunk is drained, the underlying ring
// buffer has nothing left to hand over, and every real-audio buffer we submitted has been
// confirmed played. ``has_buffered_data()`` returns bytes still sitting in the ring buffer
// awaiting fill().
if (stop_gracefully && audio_source->available() == 0 && !this->has_buffered_data() &&
pending_real_buffers == 0) {
ESP_LOGV(TAG, "Exiting: graceful stop complete");
break;
}
// Compose exactly one DMA buffer's worth: drain as much real audio as the source currently
// exposes (may take multiple fill() calls when crossing a ring buffer wrap), then pad any
// remainder with silence. All writes pack into the next free DMA descriptor in order, so the
// descriptor ends up holding [real audio][silence padding].
size_t bytes_written_total = 0;
size_t real_bytes_total = 0;
bool partial_write_failure = false;
if (!this->pause_state_) {
while (bytes_written_total < dma_buffer_bytes) {
size_t bytes_read = audio_source->fill(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS) / 2, false);
if (bytes_read > 0) {
uint8_t *new_data = audio_source->mutable_data() + audio_source->available() - bytes_read;
this->apply_software_volume_(new_data, bytes_read);
this->swap_esp32_mono_samples_(new_data, bytes_read);
}
const size_t to_write = std::min(audio_source->available(), dma_buffer_bytes - bytes_written_total);
if (to_write == 0) {
// Ring buffer has nothing more to hand over right now; pad the rest of this DMA buffer
// with silence so the lockstep invariant (one write per iteration) is preserved.
break;
}
size_t bw = 0;
i2s_channel_write(this->tx_handle_, audio_source->data(), to_write, &bw, WRITE_TIMEOUT_TICKS);
if (bw != to_write) {
// A short real-audio write breaks DMA descriptor alignment for every subsequent event;
// the only safe recovery is to restart the task.
ESP_LOGV(TAG, "Partial real audio write: %u of %u bytes", (unsigned) bw, (unsigned) to_write);
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE);
partial_write_failure = true;
break;
}
audio_source->consume(bw);
bytes_written_total += bw;
real_bytes_total += bw;
}
if (real_bytes_total > 0) {
if (bytes_written > 0) {
last_data_received_time = millis();
frames_written += this->current_stream_info_.bytes_to_frames(bytes_written);
transfer_buffer->decrease_buffer_length(bytes_written);
if (tx_dma_underflow) {
tx_dma_underflow = false;
// Enable the on_sent callback and channel after preload
xQueueReset(this->i2s_event_queue_);
const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb};
i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this);
i2s_channel_enable(this->tx_handle_);
}
}
}
if (partial_write_failure) {
break;
}
const size_t silence_bytes = dma_buffer_bytes - bytes_written_total;
if (silence_bytes > 0) {
size_t bw = 0;
i2s_channel_write(this->tx_handle_, silence_buffer, silence_bytes, &bw, WRITE_TIMEOUT_TICKS);
if (bw != silence_bytes) {
// Same descriptor-alignment hazard as a partial real-audio write.
ESP_LOGV(TAG, "Partial silence write: %u of %u bytes", (unsigned) bw, (unsigned) silence_bytes);
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE);
break;
}
}
const uint32_t real_frames_in_buffer = this->current_stream_info_.bytes_to_frames(real_bytes_total);
// Push the matching write record. Capacity headroom in I2S_EVENT_QUEUE_COUNT guarantees this
// succeeds even with a transient backlog of unprocessed events; if it ever fails the lockstep
// invariant is broken and every subsequent timestamp would be silently wrong, so bail.
if (xQueueSend(this->write_records_queue_, &real_frames_in_buffer, 0) != pdTRUE) {
ESP_LOGV(TAG, "Exiting: write records queue full");
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC);
break;
}
if (real_frames_in_buffer > 0) {
pending_real_buffers++;
}
}
}
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING);
audio_source.reset();
if (silence_buffer != nullptr) {
silence_allocator.deallocate(silence_buffer, dma_buffer_bytes);
silence_buffer = nullptr;
if (transfer_buffer != nullptr) {
transfer_buffer.reset();
}
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPED);
@@ -402,7 +300,7 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream
return err;
}
// The speaker task will enable the channel after preloading.
i2s_channel_enable(this->tx_handle_);
return ESP_OK;
}
@@ -17,7 +17,7 @@ static constexpr uint8_t PREAMBLE_M = 0x1d; // Left channel (not block start)
static constexpr uint8_t PREAMBLE_W = 0x1b; // Right channel
// BMC encoding of 4 zero bits starting at phase HIGH: 00_11_00_11 = 0x33
// Used as a constant in the 16-bit subframe path, where bits 4-11 are always zero.
// Since both aux nibbles (bits 4-7, 8-11) are zero for 16-bit audio and phase is preserved, both are 0x33.
static constexpr uint32_t BMC_ZERO_NIBBLE = 0x33;
// Constexpr BMC encoder for compile-time LUT generation.
@@ -36,43 +36,21 @@ static constexpr uint16_t bmc_lut_encode(uint32_t data, uint8_t num_bits) {
return bmc;
}
// Compile-time parity helper (constexpr-friendly, runs only at LUT build time).
static constexpr uint32_t bmc_lut_parity(uint32_t value, uint32_t num_bits) {
uint32_t p = 0;
for (uint32_t b = 0; b < num_bits; b++)
p ^= (value >> b) & 1u;
return p;
}
// Combined BMC + phase-delta lookup tables.
// Each entry packs the BMC pattern (lower bits, phase=high start) together with
// a phase-mask delta in bits 16-31 (0xFFFF if the input has odd parity, else 0).
// XORing the delta into the running phase mask propagates parity across chunks
// without an explicit popcount.
// 4-bit BMC lookup table: 16 entries x uint32_t = 64 bytes in flash.
// Bits 0-7 : 8-bit BMC pattern (phase=high start)
// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0)
// 4-bit BMC lookup table: 16 entries (16 bytes in flash)
// Index: 4-bit data value (0-15), always phase=true start
static constexpr auto BMC_LUT_4 = [] {
std::array<uint32_t, 16> t{};
for (uint32_t i = 0; i < 16; i++) {
uint32_t bmc = bmc_lut_encode(i, 4);
uint32_t delta = bmc_lut_parity(i, 4) ? 0xFFFF0000u : 0u;
t[i] = bmc | delta;
}
std::array<uint8_t, 16> t{};
for (uint32_t i = 0; i < 16; i++)
t[i] = static_cast<uint8_t>(bmc_lut_encode(i, 4));
return t;
}();
// 8-bit BMC lookup table: 256 entries x uint32_t = 1024 bytes in flash.
// Bits 0-15 : 16-bit BMC pattern (phase=high start)
// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0)
// 8-bit BMC lookup table: 256 entries (512 bytes in flash)
// Index: 8-bit data value (0-255), always phase=true start
static constexpr auto BMC_LUT_8 = [] {
std::array<uint32_t, 256> t{};
for (uint32_t i = 0; i < 256; i++) {
uint32_t bmc = bmc_lut_encode(i, 8);
uint32_t delta = bmc_lut_parity(i, 8) ? 0xFFFF0000u : 0u;
t[i] = bmc | delta;
}
std::array<uint16_t, 256> t{};
for (uint32_t i = 0; i < 256; i++)
t[i] = bmc_lut_encode(i, 8);
return t;
}();
@@ -85,7 +63,7 @@ bool SPDIFEncoder::setup() {
}
ESP_LOGV(TAG, "Buffer allocated (%zu bytes)", SPDIF_BLOCK_SIZE_BYTES);
// Build initial channel status block with default sample rate and width
// Build initial channel status block with default sample rate
this->build_channel_status_();
this->reset();
@@ -95,7 +73,7 @@ bool SPDIFEncoder::setup() {
void SPDIFEncoder::reset() {
this->spdif_block_ptr_ = this->spdif_block_buf_.get();
this->frame_in_block_ = 0;
this->block_buf_is_silence_block_ = false;
this->is_left_channel_ = true;
}
void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) {
@@ -106,27 +84,31 @@ void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) {
}
}
void SPDIFEncoder::set_bytes_per_sample(uint8_t bytes_per_sample) {
if (bytes_per_sample != 2 && bytes_per_sample != 3 && bytes_per_sample != 4) {
ESP_LOGE(TAG, "Unsupported bytes per sample: %u", (unsigned) bytes_per_sample);
return;
}
if (this->bytes_per_sample_ != bytes_per_sample) {
this->bytes_per_sample_ = bytes_per_sample;
this->build_channel_status_();
// Discard any partial block built at the previous width so we never mix widths on the wire.
this->reset();
ESP_LOGD(TAG, "Input width set to %u-bit", (unsigned) bytes_per_sample * 8);
}
}
void SPDIFEncoder::build_channel_status_() {
// IEC 60958-3 Consumer Channel Status Block (192 bits = 24 bytes)
// Transmitted LSB-first within each byte, one bit per frame via C bit.
// Any cached silence block was built for the previous channel status; it is now stale.
this->block_buf_is_silence_block_ = false;
// Transmitted LSB-first within each byte, one bit per frame via C bit
//
// Byte 0: Control bits
// Bit 0: 0 = Consumer format (not professional AES3)
// Bit 1: 0 = PCM audio (not non-audio data like AC3)
// Bit 2: 0 = No copyright assertion
// Bits 3-5: 000 = No pre-emphasis
// Bits 6-7: 00 = Mode 0 (basic consumer format)
//
// Byte 1: Category code (0x00 = general, 0x01 = CD, etc.)
//
// Byte 2: Source/channel numbers
// Bits 0-3: Source number (0 = unspecified)
// Bits 4-7: Channel number (0 = unspecified)
//
// Byte 3: Sample frequency and clock accuracy
// Bits 0-3: Sample frequency code
// Bits 4-5: Clock accuracy (00 = Level II, ±1000 ppm, appropriate for ESP32)
// Bits 6-7: Reserved (0)
//
// Bytes 4-23: Reserved (zeros for basic compliance)
// Clear all bytes first
this->channel_status_.fill(0);
// Byte 0: Consumer, PCM audio, no copyright, no pre-emphasis, Mode 0
@@ -158,148 +140,132 @@ void SPDIFEncoder::build_channel_status_() {
// Byte 3: freq_code in bits 0-3, clock accuracy (00) in bits 4-5
this->channel_status_[3] = freq_code; // Clock accuracy bits 4-5 are already 0
// Byte 4: Word length encoding (IEC 60958-3 consumer)
// bit 0: max length flag (0 = max 20 bits, 1 = max 24 bits)
// bits 1-3: word length code relative to the max
// For our supported widths:
// 16-bit (max 20): 0b0010 = 0x02 -- "16 bits, max 20"
// 24-bit (max 24): 0b1101 = 0x0D -- "24 bits, max 24"
// 32-bit input is truncated to 24-bit on the wire, so use the 24-bit code.
uint8_t word_length_code;
switch (this->bytes_per_sample_) {
case 2:
word_length_code = 0x02;
break;
case 3: // Shared case
case 4:
word_length_code = 0x0D;
break;
default:
word_length_code = 0x00; // not specified
break;
// Bytes 4-23 remain zero (word length not specified, no original sample freq, etc.)
}
HOT void SPDIFEncoder::encode_sample_(const uint8_t *pcm_sample) {
// ============================================================================
// Build raw 32-bit subframe (IEC 60958 format)
// ============================================================================
// Bit layout:
// Bits 0-3: Preamble (handled separately, not in raw_subframe)
// Bits 4-7: Auxiliary audio data (zeros for 16-bit audio)
// Bits 8-11: Audio LSB extension (zeros for 16-bit audio)
// Bits 12-27: 16-bit audio sample (MSB-aligned in 20-bit audio field)
// Bit 28: V (Validity) - 0 = valid audio
// Bit 29: U (User data) - 0
// Bit 30: C (Channel status) - from channel status block
// Bit 31: P (Parity) - even parity over bits 4-31
// ============================================================================
// Place 16-bit audio sample at bits 12-27 (little-endian input: [0]=LSB, [1]=MSB)
uint32_t raw_subframe = (static_cast<uint32_t>(pcm_sample[1]) << 20) | (static_cast<uint32_t>(pcm_sample[0]) << 12);
// V = 0 (valid audio), U = 0 (no user data)
// C = channel status bit for current frame (same bit used for both L and R subframes)
bool c_bit = this->get_channel_status_bit_(this->frame_in_block_);
if (c_bit) {
raw_subframe |= (1U << 30);
}
this->channel_status_[4] = word_length_code;
}
// Extract the C bit for the given frame from channel_status_ and shift it into bit 30
// so it can be OR'd directly into a raw subframe.
ESPHOME_ALWAYS_INLINE static inline uint32_t c_bit_for_frame(const std::array<uint8_t, 24> &channel_status,
uint32_t frame) {
return static_cast<uint32_t>((channel_status[frame >> 3] >> (frame & 7)) & 1u) << 30;
}
// Calculate even parity over bits 4-30
// This ensures consistent BMC ending phase regardless of audio content
uint32_t bits_4_30 = (raw_subframe >> 4) & 0x07FFFFFF; // 27 bits (4-30)
uint32_t ones_count = __builtin_popcount(bits_4_30);
uint32_t parity = ones_count & 1; // 1 if odd count, 0 if even
raw_subframe |= parity << 31; // Set P bit to make total even
// ============================================================================
// IEC 60958 subframe bit layout
// ============================================================================
// Bits 0-3: Preamble (handled separately, not in raw_subframe)
// Bits 4-7: Auxiliary audio data / 24-bit audio LSB
// Bits 8-11: Audio LSB extension (zero for 16-bit, low nibble of audio for 24-bit)
// Bits 12-27: Audio sample (16 high bits in 16-bit mode, mid 16 bits in 24-bit mode)
// Bit 28: V (Validity) - 0 = valid audio
// Bit 29: U (User data) - 0
// Bit 30: C (Channel status) - from channel status block
// Bit 31: P (Parity) - even parity over bits 4-31
// ============================================================================
// Build a raw IEC 60958 subframe from PCM little-endian input of width Bps bytes.
// Caller is responsible for OR-ing in the C bit and parity.
template<uint8_t Bps> ESPHOME_ALWAYS_INLINE static inline uint32_t build_raw_subframe(const uint8_t *pcm_sample) {
static_assert(Bps == 2 || Bps == 3 || Bps == 4, "Unsupported bytes per sample");
if constexpr (Bps == 2) {
// 16-bit input: MSB-aligned in the 20-bit audio field, bits 12-27.
return (static_cast<uint32_t>(pcm_sample[1]) << 20) | (static_cast<uint32_t>(pcm_sample[0]) << 12);
} else if constexpr (Bps == 3) {
// 24-bit input: full 24-bit audio field, bits 4-27.
return (static_cast<uint32_t>(pcm_sample[2]) << 20) | (static_cast<uint32_t>(pcm_sample[1]) << 12) |
(static_cast<uint32_t>(pcm_sample[0]) << 4);
} else { // Bps == 4
// 32-bit input truncated to 24-bit: drop the lowest byte.
return (static_cast<uint32_t>(pcm_sample[3]) << 20) | (static_cast<uint32_t>(pcm_sample[2]) << 12) |
(static_cast<uint32_t>(pcm_sample[1]) << 4);
}
}
// BMC-encode a subframe and write the two output uint32 words to dst. Caller passes
// raw_subframe with the C bit set (bit 30) and the P bit cleared (bit 31 = 0). P is
// derived from the cumulative parity-mask delta of the per-byte LUT lookups.
//
// I2S halfword swap means word[0] transmits as: bits 24-31, 16-23, 8-15, 0-7.
// word[1] transmits as: bits 16-31, 0-15. Within each halfword, MSB-first.
// All preambles end at phase HIGH, so phase=true at the start of bit 4.
//
// P-bit derivation: BMC_LUT_*'s upper half encodes the parity of the input chunk. Each
// chunk's parity delta is shifted down (`lut >> 16`) into a phase_mask that lives in the
// low 16 bits, so the same value can also be XORed against subsequent BMC patterns to
// invert phase. XOR'ing those deltas through all chunks (with bit 31 = 0) yields the
// parity of bits 4-30 in the low bits of phase_mask -- the required value of the P bit
// for even total parity. The BMC of bit 31 lives in bit 0 of the high-byte BMC output
// (i = 7 maps to position (8-1-7)*2 = 0); flipping the source bit flips only the lower
// BMC bit (= phase XOR bit), so applying P is `bmc_24_31 ^= phase_mask & 1u`.
template<uint8_t Bps>
ESPHOME_ALWAYS_INLINE static inline void bmc_encode_subframe(uint32_t raw_subframe, uint8_t preamble, uint32_t *dst) {
if constexpr (Bps == 2) {
// 16-bit path: bits 4-11 are zero, encoded inline as BMC_ZERO_NIBBLE constants.
// Eight zero source bits with start phase=HIGH end at phase=HIGH (popcount of zeros is even),
// so encoding of bits 12-15 starts at phase=true. Zeros contribute 0 to parity.
uint32_t nibble = (raw_subframe >> 12) & 0xF;
uint32_t lut_n = BMC_LUT_4[nibble];
uint32_t bmc_12_15 = lut_n & 0xFFu;
uint32_t phase_mask = lut_n >> 16; // 0xFFFFu if odd parity, else 0
uint32_t byte_mid = (raw_subframe >> 16) & 0xFF;
uint32_t lut_m = BMC_LUT_8[byte_mid];
uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask;
phase_mask ^= lut_m >> 16;
uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition
uint32_t lut_h = BMC_LUT_8[byte_hi];
uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask;
phase_mask ^= lut_h >> 16;
// phase_mask now reflects parity of bits 4-30. Apply P by flipping bit 0 of bmc_24_31.
bmc_24_31 ^= phase_mask & 1u;
dst[0] = bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast<uint32_t>(preamble) << 24);
dst[1] = bmc_24_31 | (bmc_16_23 << 16);
// ============================================================================
// Select preamble based on position in block and channel
// ============================================================================
// B = block start (left channel, frame 0 of 192-frame block)
// M = left channel (frames 1-191)
// W = right channel (all frames)
uint8_t preamble;
if (this->is_left_channel_) {
preamble = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M;
} else {
// 24-bit (and 32-bit truncated) path: bits 4-11 are live audio.
uint32_t byte_lo = (raw_subframe >> 4) & 0xFF;
uint32_t lut_l = BMC_LUT_8[byte_lo];
uint32_t bmc_4_11 = lut_l & 0xFFFFu;
uint32_t phase_mask = lut_l >> 16; // 0xFFFFu if odd parity, else 0
uint32_t nibble = (raw_subframe >> 12) & 0xF;
uint32_t lut_n = BMC_LUT_4[nibble];
uint32_t bmc_12_15 = (lut_n & 0xFFu) ^ (phase_mask & 0xFFu);
phase_mask ^= lut_n >> 16;
uint32_t byte_mid = (raw_subframe >> 16) & 0xFF;
uint32_t lut_m = BMC_LUT_8[byte_mid];
uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask;
phase_mask ^= lut_m >> 16;
uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition
uint32_t lut_h = BMC_LUT_8[byte_hi];
uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask;
phase_mask ^= lut_h >> 16;
bmc_24_31 ^= phase_mask & 1u;
// word[0]: bits 24-31 = preamble, bits 8-23 = bmc(4-11), bits 0-7 = bmc(12-15)
// word[1]: bits 16-31 = bmc(16-23), bits 0-15 = bmc(24-31)
dst[0] = bmc_12_15 | (bmc_4_11 << 8) | (static_cast<uint32_t>(preamble) << 24);
dst[1] = bmc_24_31 | (bmc_16_23 << 16);
preamble = PREAMBLE_W;
}
}
template<uint8_t Bps> void SPDIFEncoder::encode_silence_frame_() {
static constexpr uint8_t SILENCE[4] = {0, 0, 0, 0};
uint32_t raw = build_raw_subframe<Bps>(SILENCE) | c_bit_for_frame(this->channel_status_, this->frame_in_block_);
uint8_t preamble_l = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M;
bmc_encode_subframe<Bps>(raw, preamble_l, this->spdif_block_ptr_);
bmc_encode_subframe<Bps>(raw, PREAMBLE_W, this->spdif_block_ptr_ + 2);
this->spdif_block_ptr_ += 4;
if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) {
this->frame_in_block_ = 0;
// ============================================================================
// BMC encode the data portion (bits 4-31) using lookup tables
// ============================================================================
// The I2S uses 16-bit halfword swap: bits 16-31 transmit before bits 0-15.
// This applies to BOTH word[0] and word[1].
//
// word[0] transmission order: [16-23] → [24-31] → [0-7] → [8-15]
// For correct S/PDIF subframe order (preamble → aux → audio):
// - bits 16-23: preamble (8 BMC bits)
// - bits 24-31: BMC(subframe bits 4-7) - first aux nibble
// - bits 0-7: BMC(subframe bits 8-11) - second aux nibble
// - bits 8-15: BMC(subframe bits 12-15) - audio low nibble
//
// word[1] transmission order: [16-31] → [0-15]
// For correct S/PDIF subframe order:
// - bits 16-31: BMC(subframe bits 16-23) - audio mid byte
// - bits 0-15: BMC(subframe bits 24-31) - audio high nibble + VUCP
// ============================================================================
// All preambles end at phase HIGH. Bits 4-11 are always zero for 16-bit audio;
// two zero nibbles flip phase 8 times total → back to HIGH.
// So bits 12-15 always start encoding at phase=true.
// Bits 12-15: 4-bit LUT lookup (always phase=true start)
uint32_t nibble = (raw_subframe >> 12) & 0xF;
uint32_t bmc_12_15 = BMC_LUT_4[nibble];
// Phase tracking via branchless XOR mask:
// - 0x0000 means phase=true (use LUT value directly)
// - 0xFFFF means phase=false (complement LUT value)
// End phase = start XOR (popcount & 1) since zero-bits flip phase,
// and for even bit widths: #zeros parity == popcount parity.
uint32_t phase_mask = -(__builtin_popcount(nibble) & 1u) & 0xFFFF;
// Bits 16-23: 8-bit LUT lookup with phase correction
uint32_t byte_mid = (raw_subframe >> 16) & 0xFF;
uint32_t bmc_16_23 = BMC_LUT_8[byte_mid] ^ phase_mask;
phase_mask ^= -(__builtin_popcount(byte_mid) & 1u) & 0xFFFF;
// Bits 24-31: 8-bit LUT lookup with phase correction
uint32_t byte_hi = (raw_subframe >> 24) & 0xFF;
uint32_t bmc_24_31 = BMC_LUT_8[byte_hi] ^ phase_mask;
// ============================================================================
// Combine with correct positioning for I2S transmission
// ============================================================================
// I2S with halfword swap: transmits bits 16-31, then bits 0-15.
// Within each halfword, MSB (highest bit) is transmitted first.
//
// For upper halfword (bits 16-31): bit 31 → bit 16
// For lower halfword (bits 0-15): bit 15 → bit 0
//
// Desired S/PDIF order: preamble → bmc_4_7 → bmc_8_11 → bmc_12_15
//
// word[0] layout for correct transmission:
// bits 24-31: preamble (transmitted 1st, as MSB of upper halfword)
// bits 16-23: BMC_ZERO_NIBBLE (transmitted 2nd, aux bits 4-7)
// bits 8-15: BMC_ZERO_NIBBLE (transmitted 3rd, aux bits 8-11)
// bits 0-7: bmc_12_15 (transmitted 4th, audio low nibble)
//
// word[1] layout:
// bits 16-31: bmc_16_23 (transmitted 5th)
// bits 0-15: bmc_24_31 (transmitted 6th)
this->spdif_block_ptr_[0] =
bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast<uint32_t>(preamble) << 24);
this->spdif_block_ptr_[1] = bmc_24_31 | (bmc_16_23 << 16);
this->spdif_block_ptr_ += 2;
// ============================================================================
// Update position tracking
// ============================================================================
if (!this->is_left_channel_) {
// Completed a stereo frame, advance frame counter
if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) {
this->frame_in_block_ = 0;
}
}
this->is_left_channel_ = !this->is_left_channel_;
}
esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) {
@@ -329,162 +295,79 @@ esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) {
return err;
}
template<uint8_t Bps>
HOT esp_err_t SPDIFEncoder::write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait,
uint32_t *blocks_sent, size_t *bytes_consumed) {
const uint8_t *pcm_data = src;
const uint8_t *const pcm_end = src + size;
uint32_t block_count = 0;
// Hot state lives in locals so the compiler can keep it in registers across the
// per-frame encoding work; byte writes through block_ptr may alias the member fields,
// which would block register allocation if the encoding read them directly from this->*.
uint32_t *block_ptr = this->spdif_block_ptr_;
uint32_t *const block_buf = this->spdif_block_buf_.get();
uint32_t *const block_end = block_buf + SPDIF_BLOCK_SIZE_U32;
uint32_t frame = this->frame_in_block_;
const std::array<uint8_t, 24> &channel_status = this->channel_status_;
auto save_state = [&]() {
this->spdif_block_ptr_ = block_ptr;
this->frame_in_block_ = static_cast<uint8_t>(frame);
};
auto report_out_params = [&]() {
if (blocks_sent != nullptr)
*blocks_sent = block_count;
if (bytes_consumed != nullptr)
*bytes_consumed = pcm_data - src;
};
// Send a completed block if the buffer is full, propagating any error.
// send_block_ resets this->spdif_block_ptr_ to block_buf on success and leaves it
// unchanged on error -- mirror both behaviors in our local block_ptr.
auto maybe_send = [&]() -> esp_err_t {
if (block_ptr >= block_end) {
esp_err_t err = this->send_block_(ticks_to_wait);
if (err != ESP_OK) {
save_state();
report_out_params();
return err;
}
block_ptr = block_buf;
++block_count;
}
return ESP_OK;
};
// Hot path: encode L+R pairs in two peeled sub-loops. Frame 0 carries the only
// buffer-full check and uses PREAMBLE_B (a block fills exactly when frame wraps from
// 191 back to 0). Frames 1..191 use PREAMBLE_M and need no buffer-full check or
// preamble branch. The encoding body is inlined here so block_ptr lives in a register
// for the duration of the loop.
while (pcm_data + 2 * Bps <= pcm_end) {
if (frame == 0) {
esp_err_t err = maybe_send();
if (err != ESP_OK)
return err;
uint32_t c_bit = c_bit_for_frame(channel_status, 0);
uint32_t raw_l = build_raw_subframe<Bps>(pcm_data) | c_bit;
uint32_t raw_r = build_raw_subframe<Bps>(pcm_data + Bps) | c_bit;
bmc_encode_subframe<Bps>(raw_l, PREAMBLE_B, block_ptr);
bmc_encode_subframe<Bps>(raw_r, PREAMBLE_W, block_ptr + 2);
block_ptr += 4;
frame = 1;
pcm_data += 2 * Bps;
}
// The inner loop runs until min(SPDIF_BLOCK_SAMPLES, frame + input_frames). The
// input-size bound is folded into end_frame so a single `frame < end_frame` test
// governs termination.
uint32_t input_frames = static_cast<uint32_t>(pcm_end - pcm_data) / (2u * Bps);
uint32_t end_frame = SPDIF_BLOCK_SAMPLES;
if (frame + input_frames < end_frame)
end_frame = frame + input_frames;
while (frame < end_frame) {
uint32_t c_bit = c_bit_for_frame(channel_status, frame);
uint32_t raw_l = build_raw_subframe<Bps>(pcm_data) | c_bit;
uint32_t raw_r = build_raw_subframe<Bps>(pcm_data + Bps) | c_bit;
bmc_encode_subframe<Bps>(raw_l, PREAMBLE_M, block_ptr);
bmc_encode_subframe<Bps>(raw_r, PREAMBLE_W, block_ptr + 2);
block_ptr += 4;
++frame;
pcm_data += 2 * Bps;
}
if (frame >= SPDIF_BLOCK_SAMPLES)
frame = 0;
size_t SPDIFEncoder::get_pending_pcm_bytes() const {
if (this->spdif_block_ptr_ == nullptr || this->spdif_block_buf_ == nullptr) {
return 0;
}
// Send any complete block that was just finished.
if (block_ptr >= block_end) {
esp_err_t err = this->send_block_(ticks_to_wait);
if (err != ESP_OK) {
save_state();
report_out_params();
return err;
}
block_ptr = block_buf;
++block_count;
}
save_state();
report_out_params();
return ESP_OK;
// Each PCM sample (2 bytes) produces 2 uint32_t values in the SPDIF buffer
// So pending uint32s / 2 = pending samples, and each sample is 2 bytes
size_t pending_uint32s = this->spdif_block_ptr_ - this->spdif_block_buf_.get();
size_t pending_samples = pending_uint32s / 2;
return pending_samples * 2; // 2 bytes per sample
}
HOT esp_err_t SPDIFEncoder::write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent,
size_t *bytes_consumed) {
if (size > 0) {
// Real PCM is about to be encoded into the buffer, so it is no longer a full-silence block.
this->block_buf_is_silence_block_ = false;
}
switch (this->bytes_per_sample_) {
case 2:
return this->write_typed_<2>(src, size, ticks_to_wait, blocks_sent, bytes_consumed);
case 3:
return this->write_typed_<3>(src, size, ticks_to_wait, blocks_sent, bytes_consumed);
case 4:
return this->write_typed_<4>(src, size, ticks_to_wait, blocks_sent, bytes_consumed);
default:
return ESP_ERR_INVALID_STATE;
}
}
const uint8_t *pcm_data = src;
const uint8_t *pcm_end = src + size;
uint32_t block_count = 0;
template<uint8_t Bps> esp_err_t SPDIFEncoder::flush_with_silence_typed_(TickType_t ticks_to_wait) {
// If a complete block is already pending (from a previous failed send), emit just that block.
// Otherwise pad the partial block with silence (or generate a full silence block if empty) and
// send. Always emits exactly one block on success.
if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
const bool was_empty = (this->spdif_block_ptr_ == this->spdif_block_buf_.get());
// Continuous-silence idle case: a full silence block is byte-identical every time for the
// active channel status, so when the buffer already holds one, re-send it as-is.
if (was_empty && this->block_buf_is_silence_block_) {
return this->send_block_(ticks_to_wait);
while (pcm_data < pcm_end) {
// Check if there's a pending complete block from a previous failed send
if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
esp_err_t err = this->send_block_(ticks_to_wait);
if (err != ESP_OK) {
if (blocks_sent != nullptr) {
*blocks_sent = block_count;
}
if (bytes_consumed != nullptr) {
*bytes_consumed = pcm_data - src;
}
return err;
}
++block_count;
}
// Pad with silence frames at the configured width.
while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
this->encode_silence_frame_<Bps>();
}
// The buffer is a reusable full-silence block only if it was built entirely from silence; a
// partial real-audio block padded out with silence is not.
this->block_buf_is_silence_block_ = was_empty;
// Encode one 16-bit sample
this->encode_sample_(pcm_data);
pcm_data += 2;
}
return this->send_block_(ticks_to_wait);
// Send any complete block that was just finished
if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
esp_err_t err = this->send_block_(ticks_to_wait);
if (err != ESP_OK) {
if (blocks_sent != nullptr) {
*blocks_sent = block_count;
}
if (bytes_consumed != nullptr) {
*bytes_consumed = pcm_data - src;
}
return err;
}
++block_count;
}
if (blocks_sent != nullptr) {
*blocks_sent = block_count;
}
if (bytes_consumed != nullptr) {
*bytes_consumed = size;
}
return ESP_OK;
}
esp_err_t SPDIFEncoder::flush_with_silence(TickType_t ticks_to_wait) {
switch (this->bytes_per_sample_) {
case 2:
return this->flush_with_silence_typed_<2>(ticks_to_wait);
case 3:
return this->flush_with_silence_typed_<3>(ticks_to_wait);
case 4:
return this->flush_with_silence_typed_<4>(ticks_to_wait);
default:
return ESP_ERR_INVALID_STATE;
// If a complete block is already pending (from a previous failed send), emit just that block.
// Otherwise pad the partial block with silence (or generate a full silence block if empty)
// and send. Always emits exactly one block on success.
if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
static const uint8_t SILENCE[2] = {0, 0};
while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
this->encode_sample_(SILENCE);
}
}
return this->send_block_(ticks_to_wait);
}
} // namespace esphome::i2s_audio
@@ -24,6 +24,8 @@ static constexpr uint16_t SPDIF_BLOCK_SIZE_BYTES = SPDIF_BLOCK_SAMPLES * (EMULAT
static constexpr uint32_t SPDIF_BLOCK_SIZE_U32 = SPDIF_BLOCK_SIZE_BYTES / sizeof(uint32_t); // 3072 bytes / 4 = 768
// I2S frame count for one SPDIF block (for new driver where frame = 8 bytes for 32-bit stereo)
static constexpr uint32_t SPDIF_BLOCK_I2S_FRAMES = SPDIF_BLOCK_SIZE_BYTES / 8; // 3072 / 8 = 384 frames
// PCM bytes needed for one complete SPDIF block (192 stereo frames * 2 bytes per sample * 2 channels)
static constexpr uint16_t SPDIF_PCM_BYTES_PER_BLOCK = SPDIF_BLOCK_SAMPLES * 2 * 2; // = 768 bytes
/// Callback signature for block completion (raw function pointer for minimal overhead)
/// @param user_ctx User context pointer passed during callback registration
@@ -62,16 +64,8 @@ class SPDIFEncoder {
/// @brief Check if currently in preload mode
bool is_preload_mode() const { return this->preload_mode_; }
/// @brief Set input PCM width: 2 = 16-bit, 3 = 24-bit, 4 = 32-bit (truncated to 24-bit on the wire).
/// Must be called before write() if input width changes from the default (16-bit). Triggers a
/// channel-status rebuild to reflect the new word length.
void set_bytes_per_sample(uint8_t bytes_per_sample);
/// @brief Get the configured input PCM width in bytes per sample
uint8_t get_bytes_per_sample() const { return this->bytes_per_sample_; }
/// @brief Convert PCM audio data to SPDIF BMC encoded data
/// @param src Source PCM audio data (stereo, width matches set_bytes_per_sample)
/// @param src Source PCM audio data (16-bit stereo)
/// @param size Size of source data in bytes
/// @param ticks_to_wait Timeout for blocking writes
/// @param blocks_sent Optional pointer to receive the number of complete SPDIF blocks sent
@@ -80,6 +74,17 @@ class SPDIFEncoder {
esp_err_t write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent = nullptr,
size_t *bytes_consumed = nullptr);
/// @brief Get the number of PCM bytes currently pending in the partial block buffer
/// @return Number of pending PCM bytes (0 to SPDIF_PCM_BYTES_PER_BLOCK - 1)
size_t get_pending_pcm_bytes() const;
/// @brief Get the number of PCM frames currently pending in the partial block buffer
/// @return Number of pending PCM frames (0 to SPDIF_BLOCK_SAMPLES - 1)
uint32_t get_pending_frames() const { return this->get_pending_pcm_bytes() / 4; }
/// @brief Check if there is a partial block pending
bool has_pending_data() const { return this->spdif_block_ptr_ != this->spdif_block_buf_.get(); }
/// @brief Emit one complete SPDIF block: pad any pending partial block with silence and send,
/// or send a full silence block if nothing is pending. Always produces exactly one block on success.
/// @param ticks_to_wait Timeout for blocking writes
@@ -90,7 +95,7 @@ class SPDIFEncoder {
void reset();
/// @brief Set the sample rate for Channel Status Block encoding
/// @param sample_rate Sample rate in Hz (e.g., 44100, 48000)
/// @param sample_rate Sample rate in Hz (e.g., 44100, 48000, 96000)
/// Call this before writing audio data to ensure correct channel status.
void set_sample_rate(uint32_t sample_rate);
@@ -98,19 +103,8 @@ class SPDIFEncoder {
uint32_t get_sample_rate() const { return this->sample_rate_; }
protected:
/// @brief Encode a single stereo silence frame at the current block position.
/// @note Used only by flush_with_silence_typed_ to pad; the hot write path inlines the
/// encoding body directly into write_typed_ to keep block_ptr / frame_in_block_ in registers.
template<uint8_t Bps> void encode_silence_frame_();
/// @brief Templated write loop. Called from the public write() via runtime dispatch on bytes_per_sample_.
template<uint8_t Bps>
HOT esp_err_t write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent,
size_t *bytes_consumed);
/// @brief Templated flush-with-silence. Pads the pending block with zeros at the configured width
/// (or builds a full silence block when nothing is pending) and sends it. Always emits one block.
template<uint8_t Bps> esp_err_t flush_with_silence_typed_(TickType_t ticks_to_wait);
/// @brief Encode a single 16-bit PCM sample into the current block position
HOT void encode_sample_(const uint8_t *pcm_sample);
/// @brief Send the completed block via the appropriate callback
esp_err_t send_block_(TickType_t ticks_to_wait);
@@ -118,6 +112,15 @@ class SPDIFEncoder {
/// @brief Build the channel status block from current configuration
void build_channel_status_();
/// @brief Get the channel status bit for a specific frame
/// @param frame Frame number (0-191)
/// @return The C bit value for this frame
ESPHOME_ALWAYS_INLINE inline bool get_channel_status_bit_(uint8_t frame) const {
// Channel status is 192 bits transmitted over 192 frames
// Bit N is transmitted in frame N, LSB-first within each byte
return (this->channel_status_[frame >> 3] >> (frame & 7)) & 1;
}
// Member ordering optimized to minimize padding (largest alignment first)
// 4-byte aligned members (pointers and uint32_t)
@@ -130,13 +133,9 @@ class SPDIFEncoder {
uint32_t sample_rate_{48000}; // Sample rate for Channel Status Block encoding
// 1-byte aligned members (grouped together to avoid internal padding)
uint8_t bytes_per_sample_{2}; // Input PCM width: 2/3/4 (16/24/32-bit). 32-bit truncates to 24-bit on the wire.
uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block
bool preload_mode_{false}; // Whether to use preload callback vs write callback
// True when spdif_block_buf_ currently holds a complete full-silence block valid for the active
// channel status. A full silence block is deterministic for a given sample rate and word length,
// so when this is set flush_with_silence() can re-send the buffer verbatim instead of re-encoding.
bool block_buf_is_silence_block_{false};
uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block
bool is_left_channel_{true}; // Alternates L/R for stereo samples
bool preload_mode_{false}; // Whether to use preload callback vs write callback
// Channel Status Block (192 bits = 24 bytes, transmitted over 192 frames)
// Placed last since std::array<uint8_t> has 1-byte alignment
+1 -1
View File
@@ -319,7 +319,7 @@ void Inkplate::fill(Color color) {
memset(this->partial_buffer_, fill, this->get_buffer_length_());
}
ESP_LOGV(TAG, "Fill finished (%" PRIu32 "ms)", millis() - start_time);
ESP_LOGV(TAG, "Fill finished (%ums)", millis() - start_time);
}
void Inkplate::display() {
@@ -106,6 +106,7 @@ void RfProxy::setup() {
void RfProxy::dump_config() {
ESP_LOGCONFIG(TAG,
"RF Proxy '%s'\n"
" Backend: remote_transmitter/receiver\n"
" Supports Transmitter: %s\n"
" Supports Receiver: %s",
this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()),
@@ -123,9 +124,7 @@ void RfProxy::dump_config() {
}
void RfProxy::control(const radio_frequency::RadioFrequencyCall &call) {
// RF: no IR carrier modulation. Any RF front-end coordination (state turnaround, retuning)
// happens via the radio_frequency entity's on_control trigger and remote_transmitter's
// on_transmit/on_complete triggers — wired up in user YAML.
// RF: no IR carrier modulation
transmit_raw_timings(this->transmitter_, 0, call);
}
+1 -4
View File
@@ -43,10 +43,7 @@ class IrRfProxy : public infrared::Infrared {
#endif // USE_IR_RF
#ifdef USE_RADIO_FREQUENCY
/// RfProxy - Radio Frequency platform implementation using remote_transmitter/receiver as backend.
/// Driver-agnostic: integration with specific RF front-end chips (CC1101, RFM69, etc.) is done
/// in YAML by wiring their actions to `remote_transmitter`'s on_transmit/on_complete triggers and
/// to this entity's on_control trigger (see radio_frequency component docs).
/// RfProxy - Radio Frequency platform implementation using remote_transmitter/receiver as backend
class RfProxy : public radio_frequency::RadioFrequency {
public:
RfProxy() = default;
@@ -35,19 +35,17 @@ def _final_validate(config: ConfigType) -> None:
if CONF_REMOTE_TRANSMITTER_ID not in config:
return
transmitter_id = config[CONF_REMOTE_TRANSMITTER_ID]
full_config = fv.full_config.get()
transmitter_path = full_config.get_path_for_id(config[CONF_REMOTE_TRANSMITTER_ID])[
:-1
]
transmitter_path = full_config.get_path_for_id(transmitter_id)[:-1]
transmitter_config = full_config.get_config_for_path(transmitter_path)
duty_percent = transmitter_config.get(CONF_CARRIER_DUTY_PERCENT)
if duty_percent is not None and duty_percent != 100:
raise cv.Invalid(
f"Transmitter '{config[CONF_REMOTE_TRANSMITTER_ID]}' must have "
f"'{CONF_CARRIER_DUTY_PERCENT}' set to 100% for RF transmission. "
"Dedicated RF hardware handles modulation; applying a carrier duty cycle "
"would corrupt the signal"
f"Transmitter '{transmitter_id}' must have '{CONF_CARRIER_DUTY_PERCENT}' "
"set to 100% for RF transmission. Dedicated RF hardware handles modulation; "
"applying a carrier duty cycle would corrupt the signal"
)
+11 -13
View File
@@ -11,19 +11,11 @@
#include "esphome/core/time_64.h"
// IRAM_ATTR places a function in executable RAM so it is callable from an
// ISR even while flash is busy (XIP stall, OTA, logger flash write). All
// LibreTiny families that need it share the same .sram.text input section
// name; how that section is routed into RAM differs per family:
// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
// RTL8710B: patch_linker.py.script injects KEEP(*(.sram.text*)) at the
// top of .ram_image2.data (which IS in ltchiptool's
// sections_ram). The stock linker has KEEP(*(.image2.ram.text*))
// in .ram_image2.text but that output section is NOT in
// ltchiptool's AmebaZ elf2bin sections_ram list, so code routed
// there is dropped from the flashed binary.
// LN882H: patch_linker.py.script injects KEEP(*(.sram.text*)) into
// .flash_copysection (> RAM0 AT> FLASH), after KEEP(*(.vectors))
// so the Cortex-M4 vector table stays 512-byte-aligned for VTOR.
// ISR even while flash is busy (XIP stall, OTA, logger flash write).
// Each family uses a section its stock linker already routes to RAM:
// RTL8710B → .image2.ram.text, RTL8720C → .sram.text. LN882H is the
// exception: its stock linker has no matching glob, so patch_linker.py
// injects KEEP(*(.sram.text*)) into .flash_copysection at pre-link.
//
// BK72xx (all variants) are left as a no-op: their SDK wraps flash
// operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for
@@ -34,7 +26,13 @@
// layer.
#if defined(USE_BK72XX)
#define IRAM_ATTR
#elif defined(USE_LIBRETINY_VARIANT_RTL8710B)
// Stock linker consumes *(.image2.ram.text*) into .ram_image2.text (> BD_RAM).
#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text")))
#else
// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
// LN882H: patch_linker.py.script injects *(.sram.text*) into
// .flash_copysection (> RAM0 AT> FLASH).
#define IRAM_ATTR __attribute__((noinline, section(".sram.text")))
#endif
#define PROGMEM
@@ -6,22 +6,14 @@ import re
import subprocess
# ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a per-family
# section routed into RAM-executable memory (see esphome/core/hal.h). The
# input section name is always .sram.text; only the output section it lands
# in differs per family.
# section routed into RAM-executable memory (see esphome/core/hal.h).
#
# This script is NOT loaded on BK72xx (IRAM_ATTR is a no-op there; the SDK
# masks FIQ+IRQ around flash writes). On the remaining families:
# - RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
# - RTL8710B: stock linker has KEEP(*(.image2.ram.text*)) in .ram_image2.text,
# but ltchiptool's AmebaZ elf2bin (soc/ambz/binary.py) does NOT list
# .ram_image2.text in sections_ram, so code there is silently dropped from
# the flashed image. Inject KEEP(*(.sram.text*)) at the top of
# .ram_image2.data (which IS extracted) instead.
# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it.
# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it.
# - LN882H: stock linker has no glob for ".sram.text", so we inject
# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH)
# immediately after KEEP(*(.vectors)), so the vector table stays at
# __copysection_ram0_start (0x20000000) for correct Cortex-M4 VTOR alignment.
# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH).
#
# All families also get a post-link summary showing where IRAM_ATTR landed.
@@ -35,25 +27,7 @@ _KEEP_LINE = (
"__esphome_sram_text_end = .; "
+ _MARKER + "\n"
)
# Inject after KEEP(*(.vectors)) so the vector table stays at
# __copysection_ram0_start (0x20000000). Cortex-M4 VTOR requires a 512-byte-
# aligned address; injecting before the vectors would push them to an
# unaligned offset and mis-route every IRQ handler.
_LN_COPY = re.compile(r"(KEEP\(\*\(\.vectors\)\)[^\n]*\n)")
# Inject at the top of .ram_image2.data, before __data_start__ so our code
# does not fall inside the data range markers. .ram_image2.data is one of the
# sections ltchiptool's AmebaZ elf2bin extracts; BD_RAM is rwx so the code is
# executable. AmbZ has no C runtime .data copy loop (the bootloader loads
# image2 into BD_RAM whole) so the inline code is not clobbered after boot.
#
# The regex is intentionally strict (no attribute / ALIGN between the section
# name and the opening brace, brace on its own line). If a future AmbZ SDK
# linker template changes this format, _pre_link raises RuntimeError on the
# unpatched .ld file(s), and the RTL8710B CI compile job in
# tests/test_build_components fails on the PR, surfacing the mismatch loudly
# rather than silently shipping a binary with IRAM_ATTR code dropped from
# one or both OTA slots.
_AMBZ_DATA = re.compile(r"(\.ram_image2\.data\s*:\s*\n?\s*\{\s*\n)")
_LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)")
def _detect(env):
@@ -82,7 +56,7 @@ KNOWN_VARIANTS = frozenset({
def _inject_keep(host_section):
"""Return a patcher that injects _KEEP_LINE after `host_section` match."""
"""Return a patcher that injects _KEEP_LINE at the top of `host_section`."""
def patch(content):
if _MARKER in content:
return content
@@ -91,11 +65,12 @@ def _inject_keep(host_section):
# Variants not listed here intentionally have no .ld patcher:
# - RTL8720C: stock linker already consumes *(.sram.text*) into .ram.code_text.
# - RTL8710B: hal.h uses section(".image2.ram.text") which the stock linker
# already routes into .ram_image2.text (> BD_RAM).
# - RTL8720C: stock linker already consumes *(.sram.text*).
# - BK72xx (all): SDK masks FIQ+IRQ around flash writes, IRAM_ATTR is no-op.
_PATCHERS_BY_VARIANT = {
"LN882H": (_inject_keep(_LN_COPY),),
"RTL8710B": (_inject_keep(_AMBZ_DATA),),
}
@@ -106,14 +81,13 @@ def _patchers_for(variant):
def _pre_link(target, source, env):
build_dir = env.subst("$BUILD_DIR")
ld_files = [f for f in os.listdir(build_dir) if f.endswith(".ld")]
patched = []
unpatched = []
patched = 0
for name in ld_files:
path = os.path.join(build_dir, name)
with open(path, "r", encoding="utf-8") as fh:
original = fh.read()
if _MARKER in original:
patched.append(name)
patched += 1
continue
content = original
for fn in _patchers:
@@ -122,9 +96,7 @@ def _pre_link(target, source, env):
with open(path, "w", encoding="utf-8") as fh:
fh.write(content)
print("ESPHome: patched {} for IRAM_ATTR placement".format(name))
patched.append(name)
else:
unpatched.append(name)
patched += 1
if not patched:
raise RuntimeError(
"ESPHome: no .ld in {} was patched for IRAM_ATTR. Update the "
@@ -132,20 +104,6 @@ def _pre_link(target, source, env):
build_dir
)
)
# Every .ld in the build must be patched. RTL8710B generates one .ld per
# OTA slot (xip1, xip2); if only one matches, the unpatched slot would
# ship with IRAM_ATTR code dropped to zeros and brick the device on the
# boot after an OTA into that slot.
if unpatched:
raise RuntimeError(
"ESPHome: {} of {} .ld file(s) in {} were not patched for "
"IRAM_ATTR: {}. The regex in patch_linker.py.script "
"(_PATCHERS_BY_VARIANT[{!r}]) matched the others but not "
"these. Update the regex to cover all linker scripts.".format(
len(unpatched), len(ld_files), build_dir,
", ".join(unpatched), _variant,
)
)
# Substrings matched against demangled names as a fallback on RTL8720C,
+7 -7
View File
@@ -506,13 +506,13 @@ async def _late_logger_init(config: ConfigType) -> None:
def validate_printf(value):
# https://stackoverflow.com/questions/30011379/how-can-i-parse-a-c-format-string-in-python
cfmt = r"""
( # start of capture group 1
% # literal "%"
(?:[-+0 #]{0,5}) # optional flags
(?:\d+|\*)? # width
(?:\.(?:\d+|\*))? # precision
(?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size
[cCdiouxXeEfgGaAnpsSZ] # type
( # start of capture group 1
% # literal "%"
(?:[-+0 #]{0,5}) # optional flags
(?:\d+|\*)? # width
(?:\.(?:\d+|\*))? # precision
(?:h|l|ll|w|I|I32|I64)? # size
[cCdiouxXeEfgGaAnpsSZ] # type
)
""" # noqa
matches = re.findall(cfmt, value[CONF_FORMAT], flags=re.VERBOSE)
+2 -30
View File
@@ -1,7 +1,6 @@
import importlib
from pathlib import Path
import pkgutil
import re
from esphome.automation import Trigger, build_automation, validate_automation
import esphome.codegen as cg
@@ -31,14 +30,12 @@ import esphome.config_validation as cv
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_BUFFER_SIZE,
CONF_ESPHOME,
CONF_GROUP,
CONF_ID,
CONF_LAMBDA,
CONF_LOG_LEVEL,
CONF_ON_IDLE,
CONF_PAGES,
CONF_PLATFORMIO_OPTIONS,
CONF_ROTATION,
CONF_TIMEOUT,
CONF_TRIGGER_ID,
@@ -54,8 +51,6 @@ from . import defines as df, lv_validation as lvalid, widgets
from .automation import layers_to_code, lvgl_update
from .defines import (
CONF_ALIGN_TO_LAMBDA_ID,
LOGGER,
add_lv_use,
get_focused_widgets,
get_lv_images_used,
get_refreshed_widgets,
@@ -72,7 +67,6 @@ from .keypads import KEYPADS_CONFIG, keypads_to_code
from .lv_validation import lv_bool
from .lvcode import LvContext, LvglComponent, lv_event_t_ptr, lvgl_static
from .schemas import (
BASE_PROPS,
DISP_BG_SCHEMA,
FULL_STYLE_SCHEMA,
STYLE_REMAP,
@@ -102,7 +96,6 @@ from .widgets import (
get_screen_active,
set_obj_properties,
)
from .widgets.img import CONF_IMAGE
# Import only what we actually use directly in this file
from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code
@@ -155,28 +148,9 @@ def generate_lv_conf_h():
all_defines = set(
df.LV_DEFINES + tuple(f"LV_USE_{w.upper()}" for w in WIDGET_TYPES)
)
build_flags = (
CORE.config[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS).get("build_flags", [])
)
if not isinstance(build_flags, list):
build_flags = [build_flags]
# Extract define names from build flags like '-DLV_USE_CHART=1', '-D LV_USE_CHART',
# or multiple defines in one string.
define_pattern = r'-D\s*([A-Z_][A-Z0-9_]*)(?:=[^\s\'"\]]*)?'
defines_from_flags = {
m.group(1) for flag in build_flags for m in re.finditer(define_pattern, flag)
}
# Get the defines that are actually used based on the config,
# Get the defines that are actually used based on the config
lv_defines = df.get_defines()
clashes = defines_from_flags & lv_defines.keys()
if clashes:
LOGGER.warning(
"Some defines are set both by ESPHome build flags and by LVGL configuration which may lead to unexpected behavior: %s",
sorted(list(clashes)),
)
unused_defines = all_defines - lv_defines.keys() - defines_from_flags
unused_defines = all_defines - set(lv_defines)
# Create the content of lv_conf.h with the used defines set to their value, and the unused defines disabled
definitions = [as_macro(m, v) for m, v in lv_defines.items()] + [
as_macro(m, "0") for m in unused_defines
@@ -436,8 +410,6 @@ async def to_code(configs):
# This must be done after all widgets are created
styles_used = df.get_styles_used()
if any(BASE_PROPS.get(x) is lvalid.lv_image for x in styles_used):
add_lv_use(CONF_IMAGE)
for use in df.get_lv_uses():
df.add_define(f"LV_USE_{use.upper()}")
cg.add_define(f"USE_LVGL_{use.upper()}")
+2 -9
View File
@@ -20,6 +20,7 @@ from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import Expression, SafeExpType
LOGGER = logging.getLogger(__name__)
lvgl_ns = cg.esphome_ns.namespace("lvgl")
DOMAIN = "lvgl"
KEY_COLOR_FORMATS = "color_formats"
@@ -399,13 +400,6 @@ LV_EVENT_MAP = {
LV_PRESS_EVENTS = ("PRESS", "PRESSING", "RELEASE")
VALUE_ON_CHANGE = "on_change"
VALUE_ON_UPDATE = "on_update"
VALUE_ON_VALUE = "on_value"
VALUE_ON_RELEASE = "on_release"
LV_VALUE_EVENTS = (VALUE_ON_CHANGE, VALUE_ON_UPDATE, VALUE_ON_VALUE, VALUE_ON_RELEASE)
def is_press_event(event: str) -> bool:
return event.removeprefix("on_").upper() in LV_PRESS_EVENTS
@@ -794,7 +788,6 @@ CONF_SKIP = "skip"
CONF_SYMBOL = "symbol"
CONF_TAB_ID = "tab_id"
CONF_TABS = "tabs"
CONF_THEME = "theme"
CONF_TICK_STYLE = "tick_style"
CONF_TIME_FORMAT = "time_format"
CONF_TILE = "tile"
@@ -806,7 +799,7 @@ CONF_TOUCHSCREENS = "touchscreens"
CONF_TRANSFORM_ROTATION = "transform_rotation"
CONF_TRANSFORM_SCALE = "transform_scale"
CONF_TRANSPARENCY_KEY = "transparency_key"
CONF_TRIGGER = "trigger"
CONF_THEME = "theme"
CONF_UPDATE_ON_RELEASE = "update_on_release"
CONF_UPDATE_WHEN_DISPLAY_IDLE = "update_when_display_idle"
CONF_VISIBLE_ROW_COUNT = "visible_row_count"
+14 -14
View File
@@ -9,13 +9,13 @@ CONF_IF_NAN = "if_nan"
# noqa
f_regex = re.compile(
r"""
( # start of capture group 1
% # literal "%"
[-+0 #]{0,5} # optional flags
(?:\d+|\*)? # width
(?:\.(?:\d+|\*))? # precision
(?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size
f # type
( # start of capture group 1
% # literal "%"
[-+0 #]{0,5} # optional flags
(?:\d+|\*)? # width
(?:\.(?:\d+|\*))? # precision
(?:h|l|ll|w|I|I32|I64)? # size
f # type
)
""",
flags=re.VERBOSE,
@@ -23,13 +23,13 @@ f_regex = re.compile(
# noqa
c_regex = re.compile(
r"""
( # start of capture group 1
% # literal "%"
[-+0 #]{0,5} # optional flags
(?:\d+|\*)? # width
(?:\.(?:\d+|\*))? # precision
(?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size
[cCdiouxXeEfgGaAnpsSZ] # type
( # start of capture group 1
% # literal "%"
[-+0 #]{0,5} # optional flags
(?:\d+|\*)? # width
(?:\.(?:\d+|\*))? # precision
(?:h|l|ll|w|I|I32|I64)? # size
[cCdiouxXeEfgGaAnpsSZ] # type
)
""",
flags=re.VERBOSE,
+1 -2
View File
@@ -20,8 +20,7 @@ from esphome.cpp_generator import (
)
from esphome.yaml_util import ESPHomeDataBase
from .defines import literal
from .types import lvgl_ns
from .defines import literal, lvgl_ns
LVGL_COMP = "lv_component" # used as a lambda argument in lvgl_comp()
+4 -6
View File
@@ -74,11 +74,11 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) {
lv_style_set_text_font(style, font->get_lv_font());
}
#endif
#ifdef USE_IMAGE
#ifdef USE_LVGL_IMAGE
#if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE)
#if LV_USE_IMAGE
// Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda.
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); }
#endif // LV_USE_IMAGE
inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) {
::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector);
@@ -93,8 +93,7 @@ inline void lv_style_set_bg_image_src(lv_style_t *style, image::Image *image) {
inline void lv_style_set_bitmap_mask_src(lv_style_t *style, image::Image *image) {
::lv_style_set_bitmap_mask_src(style, image->get_lv_image_dsc());
}
#endif
#endif // USE_LVGL_IMAGE
#ifdef USE_LVGL_ANIMIMG
inline void lv_animimg_set_src(lv_obj_t *img, std::vector<image::Image *> images) {
auto *dsc = static_cast<std::vector<lv_image_dsc_t *> *>(lv_obj_get_user_data(img));
@@ -110,7 +109,6 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector<image::Image *> images
lv_animimg_set_src(img, (const void **) dsc->data(), dsc->size());
}
#endif // USE_LVGL_ANIMIMG
#endif // USE_IMAGE
#ifdef USE_LVGL_METER
int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value);
+13 -24
View File
@@ -1,16 +1,10 @@
import esphome.codegen as cg
from esphome.components import number
import esphome.config_validation as cv
from esphome.const import CONF_ON_RELEASE, CONF_RESTORE_VALUE
from esphome.const import CONF_RESTORE_VALUE
from esphome.cpp_generator import MockObj
from ..defines import (
CONF_ANIMATED,
CONF_TRIGGER,
CONF_UPDATE_ON_RELEASE,
CONF_WIDGET,
LOGGER,
)
from ..defines import CONF_ANIMATED, CONF_UPDATE_ON_RELEASE, CONF_WIDGET
from ..lv_validation import animated
from ..lvcode import (
EVENT_ARG,
@@ -20,8 +14,7 @@ from ..lvcode import (
lv_obj,
lvgl_static,
)
from ..schemas import TRIGGER_EVENT_MAP, VALUE_TRIGGER_SCHEMA
from ..types import LvNumber, lvgl_ns
from ..types import LV_EVENT, LvNumber, lvgl_ns
from ..widgets import get_widgets, wait_for_widgets
LVGLNumber = lvgl_ns.class_("LVGLNumber", number.Number, cg.Component)
@@ -29,22 +22,14 @@ LVGLNumber = lvgl_ns.class_("LVGLNumber", number.Number, cg.Component)
CONFIG_SCHEMA = number.number_schema(LVGLNumber).extend(
{
cv.Required(CONF_WIDGET): cv.use_id(LvNumber),
**VALUE_TRIGGER_SCHEMA,
cv.Optional(CONF_ANIMATED, default=True): animated,
cv.Optional(CONF_UPDATE_ON_RELEASE): cv.boolean,
cv.Optional(CONF_UPDATE_ON_RELEASE, default=False): cv.boolean,
cv.Optional(CONF_RESTORE_VALUE, default=False): cv.boolean,
}
)
async def to_code(config):
trigger = config[CONF_TRIGGER]
if CONF_UPDATE_ON_RELEASE in config:
LOGGER.warning(
"Option 'update_on_release' is deprecated and will be removed in 2026.11.0 - use 'trigger: on_release' instead"
)
if config[CONF_UPDATE_ON_RELEASE]:
trigger = CONF_ON_RELEASE
widget = await get_widgets(config, CONF_WIDGET)
widget = widget[0]
await wait_for_widgets()
@@ -55,13 +40,19 @@ async def to_code(config):
"value", MockObj("v") * MockObj(widget.get_scale()), config[CONF_ANIMATED]
)
lv_obj.send_event(widget.obj, UPDATE_EVENT, cg.nullptr)
event_code = (
LV_EVENT.VALUE_CHANGED
if not config[CONF_UPDATE_ON_RELEASE]
else LV_EVENT.RELEASED
)
var = await number.new_number(
config,
await control.get_lambda(),
await value.get_lambda(),
event_code,
config[CONF_RESTORE_VALUE],
max_value=await widget.type.get_max(widget.config),
min_value=await widget.type.get_min(widget.config),
max_value=widget.type.get_max(widget.config),
min_value=widget.type.get_min(widget.config),
step=widget.type.get_step(widget.config),
)
async with LambdaContext(EVENT_ARG) as event:
@@ -69,8 +60,6 @@ async def to_code(config):
await cg.register_component(var, config)
cg.add(
lvgl_static.add_event_cb(
widget.obj,
await event.get_lambda(),
*TRIGGER_EVENT_MAP[trigger],
widget.obj, await event.get_lambda(), UPDATE_EVENT, event_code
)
)
+7 -2
View File
@@ -10,8 +10,12 @@ namespace esphome::lvgl {
class LVGLNumber : public number::Number, public Component {
public:
LVGLNumber(std::function<void(float)> control_lambda, std::function<float()> value_lambda, bool restore)
: control_lambda_(std::move(control_lambda)), value_lambda_(std::move(value_lambda)), restore_(restore) {}
LVGLNumber(std::function<void(float)> control_lambda, std::function<float()> value_lambda, lv_event_code_t event,
bool restore)
: control_lambda_(std::move(control_lambda)),
value_lambda_(std::move(value_lambda)),
event_(event),
restore_(restore) {}
void setup() override {
float value = this->value_lambda_();
@@ -38,6 +42,7 @@ class LVGLNumber : public number::Number, public Component {
}
std::function<void(float)> control_lambda_;
std::function<float()> value_lambda_;
lv_event_code_t event_;
bool restore_;
ESPPreferenceObject pref_{};
};
+2 -23
View File
@@ -10,7 +10,6 @@ from esphome.const import (
CONF_GROUP,
CONF_ID,
CONF_ON_BOOT,
CONF_ON_UPDATE,
CONF_ON_VALUE,
CONF_STATE,
CONF_TEXT,
@@ -30,13 +29,7 @@ from .defines import (
CONF_SCROLL_SNAP_Y,
CONF_SCROLLBAR_MODE,
CONF_TIME_FORMAT,
CONF_TRIGGER,
LV_GRAD_DIR,
LV_VALUE_EVENTS,
VALUE_ON_CHANGE,
VALUE_ON_RELEASE,
VALUE_ON_UPDATE,
VALUE_ON_VALUE,
get_remapped_uses,
is_press_event,
)
@@ -48,9 +41,8 @@ from .layout import (
grid_alignments,
)
from .lv_validation import lv_color, lv_font, lv_gradient, lv_image, opacity
from .lvcode import UPDATE_EVENT, LvglComponent, lv_event_t_ptr
from .lvcode import LvglComponent, lv_event_t_ptr
from .types import (
LV_EVENT,
LVEncoderListener,
LvType,
lv_group_t,
@@ -363,19 +355,6 @@ SET_STATE_SCHEMA = cv.Schema(
FLAG_SCHEMA = cv.Schema({cv.Optional(flag): lvalid.lv_bool for flag in df.OBJ_FLAGS})
FLAG_LIST = cv.ensure_list(df.LV_OBJ_FLAG.one_of)
VALUE_TRIGGER_SCHEMA = {
cv.Optional(CONF_TRIGGER, default=CONF_ON_VALUE): cv.one_of(
*LV_VALUE_EVENTS, lower=True
),
}
TRIGGER_EVENT_MAP = {
VALUE_ON_CHANGE: (LV_EVENT.VALUE_CHANGED,),
VALUE_ON_UPDATE: (UPDATE_EVENT,),
VALUE_ON_VALUE: (LV_EVENT.VALUE_CHANGED, UPDATE_EVENT),
VALUE_ON_RELEASE: (LV_EVENT.RELEASED,),
}
def part_schema(parts):
"""
@@ -391,7 +370,7 @@ def part_schema(parts):
def automation_schema(typ: LvType):
events = df.LV_EVENT_TRIGGERS + df.SWIPE_TRIGGERS
if typ.has_on_value:
events = events + (CONF_ON_VALUE, CONF_ON_UPDATE)
events = events + (CONF_ON_VALUE,)
args = typ.get_arg_type()
def get_trigger_args(event):
+12 -6
View File
@@ -1,16 +1,21 @@
from esphome.components.sensor import Sensor, new_sensor, sensor_schema
import esphome.config_validation as cv
from ..defines import CONF_TRIGGER, CONF_WIDGET
from ..lvcode import EVENT_ARG, LambdaContext, LvContext, lv_add, lvgl_static
from ..schemas import TRIGGER_EVENT_MAP, VALUE_TRIGGER_SCHEMA
from ..types import LvNumber
from ..defines import CONF_WIDGET
from ..lvcode import (
EVENT_ARG,
UPDATE_EVENT,
LambdaContext,
LvContext,
lv_add,
lvgl_static,
)
from ..types import LV_EVENT, LvNumber
from ..widgets import Widget, get_widgets, wait_for_widgets
CONFIG_SCHEMA = sensor_schema(Sensor).extend(
{
cv.Required(CONF_WIDGET): cv.use_id(LvNumber),
**VALUE_TRIGGER_SCHEMA,
}
)
@@ -28,6 +33,7 @@ async def to_code(config):
lvgl_static.add_event_cb(
widget.obj,
await lamb.get_lambda(),
*TRIGGER_EVENT_MAP[config[CONF_TRIGGER]],
LV_EVENT.VALUE_CHANGED,
UPDATE_EVENT,
)
)
-2
View File
@@ -9,7 +9,6 @@ from .defines import (
CONF_THEME,
LValidator,
add_lv_use,
get_styles_used,
get_theme_widget_map,
literal,
)
@@ -26,7 +25,6 @@ def has_style_props(config) -> bool:
async def style_set(svar, style):
for prop, validator in ALL_STYLES.items():
if (value := style.get(prop)) is not None:
get_styles_used().add(prop)
if isinstance(validator, LValidator):
value = await validator.process(value)
if isinstance(value, list):
-8
View File
@@ -3,7 +3,6 @@ import esphome.codegen as cg
from esphome.const import (
CONF_ID,
CONF_ON_BOOT,
CONF_ON_UPDATE,
CONF_ON_VALUE,
CONF_TRIGGER_ID,
CONF_X,
@@ -93,13 +92,6 @@ async def generate_triggers():
UPDATE_EVENT,
)
for conf in config.get(CONF_ON_UPDATE, ()):
await add_trigger(
conf,
w,
UPDATE_EVENT,
)
await add_on_boot_triggers(config.get(CONF_ON_BOOT, ()))
+2 -1
View File
@@ -3,6 +3,8 @@ from esphome.const import CONF_TEXT, CONF_VALUE
from esphome.cpp_generator import MockObj
from esphome.cpp_types import Component, esphome_ns
from .defines import lvgl_ns
class LvType(cg.MockObjClass):
def __init__(self, *args, **kwargs):
@@ -45,7 +47,6 @@ PlainTrigger = esphome_ns.class_("Trigger<>", automation.Trigger.template())
DrawEndTrigger = esphome_ns.class_(
"Trigger<uint32_t, uint32_t>", automation.Trigger.template(cg.uint32, cg.uint32)
)
lvgl_ns = cg.esphome_ns.namespace("lvgl")
IdleTrigger = lvgl_ns.class_("IdleTrigger", automation.Trigger.template())
ObjUpdateAction = lvgl_ns.class_("ObjUpdateAction", automation.Action)
LvglCondition = lvgl_ns.class_("LvglCondition", automation.Condition)
+6 -7
View File
@@ -48,7 +48,6 @@ from ..defines import (
join_enums,
literal,
)
from ..lv_validation import lv_int
from ..lvcode import (
LvConditional,
add_line_marks,
@@ -208,10 +207,10 @@ class WidgetType:
"""
return ()
async def get_max(self, config: dict):
def get_max(self, config: dict):
return sys.maxsize
async def get_min(self, config: dict):
def get_min(self, config: dict):
return -sys.maxsize
def get_step(self, config: dict):
@@ -638,8 +637,8 @@ async def widget_to_code(w_cnfig, w_type: WidgetType | str, parent) -> Widget:
class NumberType(WidgetType):
async def get_max(self, config: dict):
return await lv_int.process(config.get(CONF_MAX_VALUE, 100))
def get_max(self, config: dict):
return int(config.get(CONF_MAX_VALUE, 100))
async def get_min(self, config: dict):
return await lv_int.process(config.get(CONF_MIN_VALUE, 0))
def get_min(self, config: dict):
return int(config.get(CONF_MIN_VALUE, 0))
+2 -2
View File
@@ -125,10 +125,10 @@ class SpinboxType(WidgetType):
def get_uses(self):
return CONF_TEXTAREA, CONF_LABEL
async def get_max(self, config: dict):
def get_max(self, config: dict):
return config[CONF_RANGE_TO]
async def get_min(self, config: dict):
def get_min(self, config: dict):
return config[CONF_RANGE_FROM]
def get_step(self, config: dict):
@@ -23,13 +23,7 @@ static const size_t DATA_TIMEOUT_MS = 50;
static const uint32_t RING_BUFFER_DURATION_MS = 120;
#ifdef CONFIG_IDF_TARGET_ESP32P4
// ESP32-P4 PIE-optimized esp-nn kernels (e.g. depthwise_conv_s8_ch1_pie) require
// significantly more stack than other variants, causing stack protection faults at 3072.
static const uint32_t INFERENCE_TASK_STACK_SIZE = 8192;
#else
static const uint32_t INFERENCE_TASK_STACK_SIZE = 3072;
#endif
static const UBaseType_t INFERENCE_TASK_PRIORITY = 3;
enum EventGroupBits : uint32_t {
+2 -2
View File
@@ -130,8 +130,8 @@ ClimateTraits AirConditioner::traits() {
void AirConditioner::dump_config() {
ESP_LOGCONFIG(Constants::TAG,
"MideaDongle:\n"
" [x] Period: %" PRIu32 "ms\n"
" [x] Response timeout: %" PRIu32 "ms\n"
" [x] Period: %dms\n"
" [x] Response timeout: %dms\n"
" [x] Request attempts: %d",
this->base_.getPeriod(), this->base_.getTimeout(), this->base_.getNumAttempts());
#ifdef USE_REMOTE_TRANSMITTER
+2 -69
View File
@@ -1,11 +1,8 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import climate, uart
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL
from esphome.core import ID
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType, TemplateArgsType
from esphome.const import CONF_UPDATE_INTERVAL
from esphome.types import ConfigType
DEPENDENCIES = ["uart"]
AUTO_LOAD = ["climate"]
@@ -22,18 +19,6 @@ MitsubishiCN105Climate = mitsubishi_ns.class_(
uart.UARTDevice,
)
SetRemoteTemperatureAction = mitsubishi_ns.class_(
"SetRemoteTemperatureAction",
automation.Action,
cg.Parented.template(MitsubishiCN105Climate),
)
ClearRemoteTemperatureAction = mitsubishi_ns.class_(
"ClearRemoteTemperatureAction",
automation.Action,
cg.Parented.template(MitsubishiCN105Climate),
)
CONFIG_SCHEMA = (
climate.climate_schema(MitsubishiCN105Climate)
.extend(uart.UART_DEVICE_SCHEMA)
@@ -68,55 +53,3 @@ async def to_code(config: ConfigType) -> None:
config[CONF_CURRENT_TEMPERATURE_MIN_INTERVAL]
)
)
@automation.register_action(
"climate.mitsubishi_cn105.set_remote_temperature",
SetRemoteTemperatureAction,
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate),
cv.Required(CONF_TEMPERATURE): cv.templatable(
cv.All(
cv.temperature,
cv.Range(min=8.0, max=39.5),
)
),
}
),
synchronous=True,
)
async def set_remote_temperature_action_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
temperature = await cg.templatable(config[CONF_TEMPERATURE], args, float)
cg.add(var.set_temperature(temperature))
return var
@automation.register_action(
"climate.mitsubishi_cn105.clear_remote_temperature",
ClearRemoteTemperatureAction,
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate),
}
),
synchronous=True,
)
async def clear_remote_temperature_action_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
return var
@@ -1,4 +1,3 @@
#include <algorithm>
#include <array>
#include <cmath>
#include <numeric>
@@ -8,7 +7,7 @@ namespace esphome::mitsubishi_cn105 {
static const char *const TAG = "mitsubishi_cn105.driver";
static constexpr uint32_t RESPONSE_TIMEOUT_MS = 2000;
static constexpr uint32_t WRITE_TIMEOUT_MS = 2000;
static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31;
@@ -30,85 +29,44 @@ static constexpr uint8_t STATUS_MSG_ROOM_TEMP = 0x03;
static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41;
static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61;
template<auto Unknown, size_t N> struct LookupMap {
using value_type = decltype(Unknown);
static constexpr auto UNKNOWN_VALUE = Unknown;
const std::array<value_type, N> table;
constexpr value_type lookup(uint8_t raw) const { return (raw < N) ? this->table[raw] : UNKNOWN_VALUE; }
constexpr bool reverse_lookup(value_type value, uint8_t &out) const {
static_assert(N <= std::numeric_limits<uint8_t>::max());
if (value == UNKNOWN_VALUE) {
return false;
}
for (uint8_t i = 0; i < static_cast<uint8_t>(N); ++i) {
if (this->table[i] == value) {
out = i;
return true;
}
}
return false;
}
constexpr bool is_valid(value_type value) const {
uint8_t raw;
return reverse_lookup(value, raw);
}
};
template<auto Unknown, class T, std::size_t N> static constexpr auto make_map(const T (&values)[N]) {
return LookupMap<Unknown, N>{std::to_array(values)};
}
static constexpr auto PROTOCOL_MODE_MAP = make_map<MitsubishiCN105::Mode::UNKNOWN>({
MitsubishiCN105::Mode::UNKNOWN, // 0x00
static constexpr std::array<std::optional<MitsubishiCN105::Mode>, 9> PROTOCOL_MODE_MAP = {
std::nullopt, // 0x00
MitsubishiCN105::Mode::HEAT, // 0x01
MitsubishiCN105::Mode::DRY, // 0x02
MitsubishiCN105::Mode::COOL, // 0x03
MitsubishiCN105::Mode::UNKNOWN, // 0x04
MitsubishiCN105::Mode::UNKNOWN, // 0x05
MitsubishiCN105::Mode::UNKNOWN, // 0x06
std::nullopt, // 0x04
std::nullopt, // 0x05
std::nullopt, // 0x06
MitsubishiCN105::Mode::FAN_ONLY, // 0x07
MitsubishiCN105::Mode::AUTO // 0x08
});
};
static constexpr auto PROTOCOL_FAN_MODE_MAP = make_map<MitsubishiCN105::FanMode::UNKNOWN>({
static constexpr std::array<std::optional<MitsubishiCN105::FanMode>, 7> PROTOCOL_FAN_MODE_MAP = {
MitsubishiCN105::FanMode::AUTO, // 0x00
MitsubishiCN105::FanMode::QUIET, // 0x01
MitsubishiCN105::FanMode::SPEED_1, // 0x02
MitsubishiCN105::FanMode::SPEED_2, // 0x03
MitsubishiCN105::FanMode::UNKNOWN, // 0x04
std::nullopt, // 0x04
MitsubishiCN105::FanMode::SPEED_3, // 0x05
MitsubishiCN105::FanMode::SPEED_4 // 0x06
});
};
static constexpr auto PROTOCOL_VANE_MODE_MAP = make_map<MitsubishiCN105::VaneMode::UNKNOWN>({
MitsubishiCN105::VaneMode::AUTO, // 0x00
MitsubishiCN105::VaneMode::POSITION_1, // 0x01
MitsubishiCN105::VaneMode::POSITION_2, // 0x02
MitsubishiCN105::VaneMode::POSITION_3, // 0x03
MitsubishiCN105::VaneMode::POSITION_4, // 0x04
MitsubishiCN105::VaneMode::POSITION_5, // 0x05
MitsubishiCN105::VaneMode::UNKNOWN, // 0x06
MitsubishiCN105::VaneMode::SWING // 0x07
});
template<typename T, size_t N>
static constexpr std::optional<T> lookup(const std::array<std::optional<T>, N> &table, uint8_t value) {
return (value < N) ? table[value] : std::nullopt;
}
static constexpr auto PROTOCOL_WIDE_VANE_MODE_MAP = make_map<MitsubishiCN105::WideVaneMode::UNKNOWN>({
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x00
MitsubishiCN105::WideVaneMode::FAR_LEFT, // 0x01
MitsubishiCN105::WideVaneMode::LEFT, // 0x02
MitsubishiCN105::WideVaneMode::CENTER, // 0x03
MitsubishiCN105::WideVaneMode::RIGHT, // 0x04
MitsubishiCN105::WideVaneMode::FAR_RIGHT, // 0x05
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x06
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x07
MitsubishiCN105::WideVaneMode::LEFT_RIGHT, // 0x08
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x09
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0A
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0B
MitsubishiCN105::WideVaneMode::SWING // 0x0C
});
template<typename T, size_t N>
static constexpr bool reverse_lookup(const std::array<std::optional<T>, N> &table, T value, uint8_t &placeholder) {
for (size_t i = 0; i < N; ++i) {
const auto &table_value = table[i];
if (table_value.has_value() && table_value == value) {
placeholder = i;
return true;
}
}
return false;
}
static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) {
return static_cast<uint8_t>(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0}));
@@ -123,7 +81,7 @@ static constexpr auto make_packet(uint8_t type, const std::array<uint8_t, Payloa
return packet;
}
static constexpr float decode_temperature(int temp_a, int temp_b, int delta) {
static float decode_temperature(int temp_a, int temp_b, int delta) {
return temp_b != 0 ? (temp_b - 128) / 2.0f : delta + temp_a;
}
@@ -132,31 +90,23 @@ static constexpr auto CONNECT_PACKET = make_packet(PACKET_TYPE_CONNECT_REQUEST,
void MitsubishiCN105::initialize() { this->set_state_(State::CONNECTING); }
bool MitsubishiCN105::update() {
switch (this->state_) {
case State::WAITING_FOR_SCHEDULED_STATUS_UPDATE:
if (this->pending_updates_.any()) {
this->status_update_wait_credit_ms_ =
std::min(this->update_interval_ms_, get_loop_time_ms() - this->operation_start_ms_);
this->set_state_(State::APPLYING_SETTINGS);
return false;
}
if (this->has_timed_out_(this->update_interval_ms_)) {
this->set_state_(State::UPDATING_STATUS);
return false;
}
break;
if (const auto start = this->status_update_start_ms_) {
if (this->pending_updates_.any()) {
this->cancel_waiting_and_transition_to_(State::APPLYING_SETTINGS);
return false;
}
case State::CONNECTING:
case State::UPDATING_STATUS:
case State::APPLYING_SETTINGS:
if (this->has_timed_out_(RESPONSE_TIMEOUT_MS)) {
this->set_state_(State::READ_TIMEOUT);
return false;
}
break;
if ((get_loop_time_ms() - *start) >= this->update_interval_ms_) {
this->cancel_waiting_and_transition_to_(State::UPDATING_STATUS);
return false;
}
}
default:
break;
if (const auto start = this->write_timeout_start_ms_; start && (get_loop_time_ms() - *start) >= WRITE_TIMEOUT_MS) {
this->write_timeout_start_ms_.reset();
this->frame_parser_.reset();
this->set_state_(State::READ_TIMEOUT);
return false;
}
return this->frame_parser_.read_and_parse(this->device_, [this](uint8_t type, const uint8_t *payload, size_t len) {
@@ -218,6 +168,7 @@ void MitsubishiCN105::did_transition_(State to) {
break;
case State::CONNECTED:
this->write_timeout_start_ms_.reset();
this->current_status_msg_type_ = STATUS_MSG_SETTINGS;
this->set_state_(State::UPDATING_STATUS);
break;
@@ -227,6 +178,7 @@ void MitsubishiCN105::did_transition_(State to) {
break;
case State::STATUS_UPDATED: {
this->write_timeout_start_ms_.reset();
if (this->pending_updates_.any() && this->is_status_initialized()) {
this->set_state_(State::APPLYING_SETTINGS);
} else if (this->current_status_msg_type_ == STATUS_MSG_SETTINGS && this->should_request_room_temperature_()) {
@@ -239,23 +191,22 @@ void MitsubishiCN105::did_transition_(State to) {
}
case State::SCHEDULE_NEXT_STATUS_UPDATE:
this->operation_start_ms_ = get_loop_time_ms() - this->status_update_wait_credit_ms_;
this->status_update_wait_credit_ms_ = 0;
this->status_update_start_ms_ = get_loop_time_ms();
this->current_status_msg_type_ = STATUS_MSG_SETTINGS;
this->set_state_(State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
break;
case State::APPLYING_SETTINGS:
this->apply_settings_();
this->pending_updates_.clear();
break;
case State::SETTINGS_APPLIED:
this->write_timeout_start_ms_.reset();
this->set_state_(State::SCHEDULE_NEXT_STATUS_UPDATE);
break;
case State::READ_TIMEOUT:
this->frame_parser_.reset();
this->status_update_wait_credit_ms_ = 0;
this->set_state_(State::CONNECTING);
break;
@@ -279,7 +230,7 @@ bool MitsubishiCN105::should_request_room_temperature_() const {
void MitsubishiCN105::send_packet_(const uint8_t *packet, size_t len) {
FrameParser::dump_buffer_vv("TX", packet, len);
this->device_.write_array(packet, len);
this->operation_start_ms_ = get_loop_time_ms();
this->write_timeout_start_ms_ = get_loop_time_ms();
}
void MitsubishiCN105::update_status_() {
@@ -287,6 +238,11 @@ void MitsubishiCN105::update_status_() {
this->send_packet_(make_packet(PACKET_TYPE_STATUS_REQUEST, payload));
}
void MitsubishiCN105::cancel_waiting_and_transition_to_(State state) {
this->status_update_start_ms_.reset();
this->set_state_(state);
}
bool MitsubishiCN105::process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len) {
switch (type) {
case PACKET_TYPE_CONNECT_RESPONSE:
@@ -322,10 +278,9 @@ bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len)
this->set_state_(State::STATUS_UPDATED);
}
bool changed =
previous.power_on != this->status_.power_on || previous.mode != this->status_.mode ||
previous.fan_mode != this->status_.fan_mode || previous.target_temperature != this->status_.target_temperature ||
previous.vane_mode != this->status_.vane_mode || previous.wide_vane_mode != this->status_.wide_vane_mode;
bool changed = previous.power_on != this->status_.power_on || previous.mode != this->status_.mode ||
previous.fan_mode != this->status_.fan_mode ||
previous.target_temperature != this->status_.target_temperature;
if (this->is_room_temperature_enabled()) {
changed |= previous.room_temperature != this->status_.room_temperature;
@@ -354,31 +309,22 @@ bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len)
return false;
}
if (!this->pending_updates_.contains(UpdateFlag::POWER)) {
if (!this->pending_updates_.has(UpdateFlag::POWER)) {
this->status_.power_on = payload[2] != 0;
}
this->use_temperature_encoding_b_ = payload[10] != 0;
if (!this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) {
if (!this->pending_updates_.has(UpdateFlag::TEMPERATURE)) {
this->status_.target_temperature = decode_temperature(-payload[4], payload[10], TARGET_TEMPERATURE_ENC_A_OFFSET);
}
if (!this->pending_updates_.contains(UpdateFlag::MODE)) {
if (!this->pending_updates_.has(UpdateFlag::MODE)) {
const bool i_see = payload[3] > 0x08;
this->status_.mode = PROTOCOL_MODE_MAP.lookup(payload[3] - (i_see ? 0x08 : 0));
this->status_.mode = lookup(PROTOCOL_MODE_MAP, payload[3] - (i_see ? 0x08 : 0)).value_or(Mode::UNKNOWN);
}
if (!this->pending_updates_.contains(UpdateFlag::FAN)) {
this->status_.fan_mode = PROTOCOL_FAN_MODE_MAP.lookup(payload[5]);
}
if (!this->pending_updates_.contains(UpdateFlag::VANE)) {
this->status_.vane_mode = PROTOCOL_VANE_MODE_MAP.lookup(payload[6]);
}
this->set_wide_vane_high_bit_ = (payload[9] & 0xF0) == 0x80;
if (!this->pending_updates_.contains(UpdateFlag::WIDE_VANE)) {
this->status_.wide_vane_mode = PROTOCOL_WIDE_VANE_MODE_MAP.lookup(payload[9] & 0x0F);
if (!this->pending_updates_.has(UpdateFlag::FAN)) {
this->status_.fan_mode = lookup(PROTOCOL_FAN_MODE_MAP, payload[5]).value_or(FanMode::UNKNOWN);
}
return true;
@@ -396,27 +342,6 @@ bool MitsubishiCN105::parse_status_room_temperature_(const uint8_t *payload, siz
return true;
}
void MitsubishiCN105::set_remote_temperature(float temperature) {
if (std::isnan(temperature)) {
ESP_LOGD(TAG, "Ignoring NaN remote temperature");
return;
}
if (temperature < 8.0f || temperature > 39.5f) {
ESP_LOGD(TAG, "Ignoring out-of-range remote temperature: %.1f", temperature);
return;
}
this->set_remote_temperature_half_deg_(static_cast<uint8_t>(std::round(temperature * 2.0f)));
}
void MitsubishiCN105::clear_remote_temperature() {
this->set_remote_temperature_half_deg_(REMOTE_TEMPERATURE_DISABLED);
}
void MitsubishiCN105::set_remote_temperature_half_deg_(uint8_t temperature_half_deg) {
this->remote_temperature_half_deg_ = temperature_half_deg;
this->pending_updates_.set(UpdateFlag::REMOTE_TEMPERATURE);
}
void MitsubishiCN105::set_power(bool power_on) {
this->status_.power_on = power_on;
this->pending_updates_.set(UpdateFlag::POWER);
@@ -432,7 +357,8 @@ void MitsubishiCN105::set_target_temperature(float target_temperature) {
}
void MitsubishiCN105::set_mode(Mode mode) {
if (!PROTOCOL_MODE_MAP.is_valid(mode)) {
uint8_t placeholder;
if (!reverse_lookup(PROTOCOL_MODE_MAP, mode, placeholder)) {
ESP_LOGD(TAG, "Setting invalid mode: %u", static_cast<uint8_t>(mode));
return;
}
@@ -441,7 +367,8 @@ void MitsubishiCN105::set_mode(Mode mode) {
}
void MitsubishiCN105::set_fan_mode(FanMode fan_mode) {
if (!PROTOCOL_FAN_MODE_MAP.is_valid(fan_mode)) {
uint8_t placeholder;
if (!reverse_lookup(PROTOCOL_FAN_MODE_MAP, fan_mode, placeholder)) {
ESP_LOGD(TAG, "Setting invalid fan mode: %u", static_cast<uint8_t>(fan_mode));
return;
}
@@ -449,80 +376,31 @@ void MitsubishiCN105::set_fan_mode(FanMode fan_mode) {
this->pending_updates_.set(UpdateFlag::FAN);
}
void MitsubishiCN105::set_vane_mode(VaneMode vane_mode) {
if (!PROTOCOL_VANE_MODE_MAP.is_valid(vane_mode)) {
ESP_LOGD(TAG, "Setting invalid vane mode: %u", static_cast<uint8_t>(vane_mode));
return;
}
this->status_.vane_mode = vane_mode;
this->pending_updates_.set(UpdateFlag::VANE);
}
void MitsubishiCN105::set_wide_vane_mode(WideVaneMode wide_vane_mode) {
if (!PROTOCOL_WIDE_VANE_MODE_MAP.is_valid(wide_vane_mode)) {
ESP_LOGD(TAG, "Setting invalid wide vane mode: %u", static_cast<uint8_t>(wide_vane_mode));
return;
}
this->status_.wide_vane_mode = wide_vane_mode;
this->pending_updates_.set(UpdateFlag::WIDE_VANE);
}
void MitsubishiCN105::apply_settings_() {
std::array<uint8_t, REQUEST_PAYLOAD_LEN> payload{};
std::array<uint8_t, REQUEST_PAYLOAD_LEN> payload = {0x01};
// Apply all other pending settings first; handle REMOTE_TEMPERATURE last
if (this->pending_updates_.contains_only(UpdateFlag::REMOTE_TEMPERATURE)) {
payload[0] = 0x07;
if (this->remote_temperature_half_deg_ == REMOTE_TEMPERATURE_DISABLED) {
payload[3] = 0x80;
if (this->pending_updates_.has(UpdateFlag::POWER)) {
payload[1] |= 0x01;
payload[3] = this->status_.power_on ? 0x01 : 0x00;
}
if (this->pending_updates_.has(UpdateFlag::TEMPERATURE)) {
payload[1] |= 0x04;
if (this->use_temperature_encoding_b_) {
payload[14] = static_cast<uint8_t>(std::round(this->status_.target_temperature * 2.0f) + 128);
} else {
payload[1] = 0x01;
payload[2] = static_cast<uint8_t>(this->remote_temperature_half_deg_ - 16);
payload[3] = static_cast<uint8_t>(this->remote_temperature_half_deg_ + 128);
}
this->pending_updates_.clear(UpdateFlag::REMOTE_TEMPERATURE);
} else {
payload[0] = 0x01;
if (this->pending_updates_.contains(UpdateFlag::POWER)) {
payload[1] |= 0x01;
payload[3] = this->status_.power_on ? 0x01 : 0x00;
payload[5] = static_cast<uint8_t>(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(this->status_.target_temperature));
}
}
if (this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) {
payload[1] |= 0x04;
if (this->use_temperature_encoding_b_) {
payload[14] = static_cast<uint8_t>(std::round(this->status_.target_temperature * 2.0f) + 128);
} else {
payload[5] =
static_cast<uint8_t>(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(this->status_.target_temperature));
}
}
if (this->pending_updates_.has(UpdateFlag::MODE) &&
reverse_lookup(PROTOCOL_MODE_MAP, this->status_.mode, payload[4])) {
payload[1] |= 0x02;
}
if (this->pending_updates_.contains(UpdateFlag::MODE) &&
PROTOCOL_MODE_MAP.reverse_lookup(this->status_.mode, payload[4])) {
payload[1] |= 0x02;
}
if (this->pending_updates_.contains(UpdateFlag::FAN) &&
PROTOCOL_FAN_MODE_MAP.reverse_lookup(this->status_.fan_mode, payload[6])) {
payload[1] |= 0x08;
}
if (this->pending_updates_.contains(UpdateFlag::VANE) &&
PROTOCOL_VANE_MODE_MAP.reverse_lookup(this->status_.vane_mode, payload[7])) {
payload[1] |= 0x10;
}
if (this->pending_updates_.contains(UpdateFlag::WIDE_VANE) &&
PROTOCOL_WIDE_VANE_MODE_MAP.reverse_lookup(this->status_.wide_vane_mode, payload[13])) {
payload[2] |= 0x01;
if (this->set_wide_vane_high_bit_) {
payload[13] |= 0x80;
}
}
this->pending_updates_.clear(UpdateFlag::POWER, UpdateFlag::TEMPERATURE, UpdateFlag::MODE, UpdateFlag::FAN,
UpdateFlag::VANE, UpdateFlag::WIDE_VANE);
if (this->pending_updates_.has(UpdateFlag::FAN) &&
reverse_lookup(PROTOCOL_FAN_MODE_MAP, this->status_.fan_mode, payload[6])) {
payload[1] |= 0x08;
}
this->send_packet_(make_packet(PACKET_TYPE_WRITE_SETTINGS_REQUEST, payload));
@@ -2,7 +2,6 @@
#include <optional>
#include "esphome/components/uart/uart.h"
#include "esphome/core/finite_set_mask.h"
namespace esphome::mitsubishi_cn105 {
@@ -29,36 +28,12 @@ class MitsubishiCN105 {
UNKNOWN,
};
enum class VaneMode : uint8_t {
AUTO,
POSITION_1,
POSITION_2,
POSITION_3,
POSITION_4,
POSITION_5,
SWING,
UNKNOWN,
};
enum class WideVaneMode : uint8_t {
FAR_LEFT,
LEFT,
CENTER,
RIGHT,
FAR_RIGHT,
LEFT_RIGHT,
SWING,
UNKNOWN,
};
struct Status {
float target_temperature{NAN};
float room_temperature{NAN};
bool power_on{false};
float target_temperature{NAN};
Mode mode{Mode::UNKNOWN};
FanMode fan_mode{FanMode::UNKNOWN};
VaneMode vane_mode{VaneMode::UNKNOWN};
WideVaneMode wide_vane_mode{WideVaneMode::UNKNOWN};
float room_temperature{NAN};
};
explicit MitsubishiCN105(uart::UARTDevice &device) : device_(device) {}
@@ -85,10 +60,6 @@ class MitsubishiCN105 {
void set_target_temperature(float target_temperature);
void set_mode(Mode mode);
void set_fan_mode(FanMode fan_mode);
void set_vane_mode(VaneMode vane_mode);
void set_wide_vane_mode(WideVaneMode mode);
void set_remote_temperature(float temperature);
void clear_remote_temperature();
protected:
enum class State : uint8_t {
@@ -120,27 +91,20 @@ class MitsubishiCN105 {
};
enum class UpdateFlag : uint8_t {
TEMPERATURE = 0,
POWER = 1,
MODE = 2,
FAN = 3,
VANE = 4,
WIDE_VANE = 5,
REMOTE_TEMPERATURE = 6,
TEMPERATURE = 1 << 0,
POWER = 1 << 1,
MODE = 1 << 2,
FAN = 1 << 3,
};
struct UpdateFlags {
template<typename... Flags> void set(Flags... flags) { (this->mask_.insert(flags), ...); }
template<typename... Flags> void clear(Flags... flags) { (this->mask_.erase(flags), ...); }
bool any() const { return !this->mask_.empty(); }
bool contains(UpdateFlag flag) const { return this->mask_.count(flag); }
bool contains_only(UpdateFlag flag) const { return this->mask_.get_mask() == Mask{flag}.get_mask(); }
void set(UpdateFlag f) { flags_ |= static_cast<uint8_t>(f); }
void clear() { flags_ = 0; }
bool any() const { return flags_ != 0; }
bool has(UpdateFlag f) const { return (flags_ & static_cast<uint8_t>(f)) != 0; }
protected:
using Mask =
FiniteSetMask<UpdateFlag, DefaultBitPolicy<UpdateFlag, static_cast<int>(UpdateFlag::REMOTE_TEMPERATURE) + 1>>;
Mask mask_;
uint8_t flags_{0};
};
void set_state_(State new_state);
@@ -152,30 +116,25 @@ class MitsubishiCN105 {
bool parse_status_room_temperature_(const uint8_t *payload, size_t len);
void send_packet_(const uint8_t *packet, size_t len);
void update_status_();
void cancel_waiting_and_transition_to_(State state);
bool should_request_room_temperature_() const;
void apply_settings_();
bool has_timed_out_(uint32_t timeout) const { return ((get_loop_time_ms() - this->operation_start_ms_) >= timeout); }
void set_remote_temperature_half_deg_(uint8_t temperature_half_deg);
template<typename T> void send_packet_(const T &packet) { this->send_packet_(packet.data(), packet.size()); }
static bool should_transition(State from, State to);
static const LogString *state_to_string(State state);
uart::UARTDevice &device_;
uint32_t update_interval_ms_{1000};
uint32_t status_update_wait_credit_ms_{0};
uint32_t operation_start_ms_{0};
uint32_t room_temperature_min_interval_ms_{60000};
std::optional<uint32_t> write_timeout_start_ms_;
std::optional<uint32_t> status_update_start_ms_;
std::optional<uint32_t> last_room_temperature_update_ms_;
Status status_{};
State state_{State::NOT_CONNECTED};
UpdateFlags pending_updates_;
bool use_temperature_encoding_b_{false};
bool set_wide_vane_high_bit_{false};
FrameParser frame_parser_;
uint8_t current_status_msg_type_{0};
static constexpr uint8_t REMOTE_TEMPERATURE_DISABLED = 0;
uint8_t remote_temperature_half_deg_{REMOTE_TEMPERATURE_DISABLED};
FrameParser frame_parser_;
};
} // namespace esphome::mitsubishi_cn105
@@ -56,7 +56,7 @@ void MitsubishiCN105Climate::dump_config() {
ESP_LOGCONFIG(TAG, " Current temperature min interval: %" PRIu32 " ms",
this->hp_.get_room_temperature_min_interval());
} else {
ESP_LOGCONFIG(TAG, " Current temperature: DISABLED");
ESP_LOGCONFIG(TAG, " Current temperature: disabled");
}
ESP_LOGCONFIG(TAG,
" Update interval: %" PRIu32 " ms\n"
@@ -1,6 +1,5 @@
#pragma once
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/components/climate/climate.h"
#include "esphome/components/uart/uart.h"
@@ -19,11 +18,8 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public
climate::ClimateTraits traits() override;
void control(const climate::ClimateCall &call) override;
void set_update_interval(uint32_t ms) { this->hp_.set_update_interval(ms); }
void set_current_temperature_min_interval(uint32_t ms) { this->hp_.set_room_temperature_min_interval(ms); }
void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); }
void clear_remote_temperature() { this->hp_.clear_remote_temperature(); }
void set_update_interval(uint32_t ms) { hp_.set_update_interval(ms); }
void set_current_temperature_min_interval(uint32_t ms) { hp_.set_room_temperature_min_interval(ms); }
protected:
void apply_values_();
@@ -31,18 +27,4 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public
MitsubishiCN105 hp_;
};
template<typename... Ts>
class SetRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
public:
TEMPLATABLE_VALUE(float, temperature)
void play(const Ts &...x) override { this->parent_->set_remote_temperature(this->temperature_.value(x...)); }
};
template<typename... Ts>
class ClearRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
public:
void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); }
};
} // namespace esphome::mitsubishi_cn105
@@ -182,7 +182,7 @@ void SourceSpeaker::loop() {
break;
}
case speaker::STATE_RUNNING:
if (!this->audio_source_->has_buffered_data() &&
if (!this->transfer_buffer_->has_buffered_data() &&
(this->pending_playback_frames_.load(std::memory_order_acquire) == 0)) {
// No audio data in buffer waiting to get mixed and no frames are pending playback
if ((this->timeout_ms_.has_value() && ((millis() - this->last_seen_data_ms_) > this->timeout_ms_.value())) ||
@@ -254,12 +254,15 @@ void SourceSpeaker::send_command_(uint32_t command_bit, bool wake_loop) {
void SourceSpeaker::start() { this->send_command_(SOURCE_SPEAKER_COMMAND_START, true); }
esp_err_t SourceSpeaker::start_() {
const size_t bytes_per_frame = this->audio_stream_info_.frames_to_bytes(1);
// Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and
// avoids unnecessary single-frame splices.
const size_t ring_buffer_size =
(this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame;
if (this->audio_source_.use_count() == 0) {
const size_t ring_buffer_size = this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_);
if (this->transfer_buffer_.use_count() == 0) {
this->transfer_buffer_ =
audio::AudioSourceTransferBuffer::create(this->audio_stream_info_.ms_to_bytes(TRANSFER_BUFFER_DURATION_MS));
if (this->transfer_buffer_ == nullptr) {
return ESP_ERR_NO_MEM;
}
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (!temp_ring_buffer) {
temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
@@ -268,15 +271,9 @@ esp_err_t SourceSpeaker::start_() {
if (!temp_ring_buffer) {
return ESP_ERR_NO_MEM;
} else {
this->transfer_buffer_->set_source(temp_ring_buffer);
}
std::unique_ptr<audio::RingBufferAudioSource> source = audio::RingBufferAudioSource::create(
temp_ring_buffer, this->audio_stream_info_.ms_to_bytes(TRANSFER_BUFFER_DURATION_MS),
static_cast<uint8_t>(bytes_per_frame));
if (source == nullptr) {
return ESP_ERR_NO_MEM;
}
this->audio_source_ = std::move(source);
}
return this->parent_->start(this->audio_stream_info_);
@@ -287,7 +284,7 @@ void SourceSpeaker::stop() { this->send_command_(SOURCE_SPEAKER_COMMAND_STOP); }
void SourceSpeaker::finish() { this->send_command_(SOURCE_SPEAKER_COMMAND_FINISH); }
bool SourceSpeaker::has_buffered_data() const {
return ((this->audio_source_.use_count() > 0) && this->audio_source_->has_buffered_data());
return ((this->transfer_buffer_.use_count() > 0) && this->transfer_buffer_->has_buffered_data());
}
void SourceSpeaker::set_mute_state(bool mute_state) {
@@ -304,18 +301,16 @@ void SourceSpeaker::set_volume(float volume) {
float SourceSpeaker::get_volume() { return this->parent_->get_output_speaker()->get_volume(); }
size_t SourceSpeaker::process_data_from_source(std::shared_ptr<audio::RingBufferAudioSource> &audio_source,
size_t SourceSpeaker::process_data_from_source(std::shared_ptr<audio::AudioSourceTransferBuffer> &transfer_buffer,
TickType_t ticks_to_wait) {
if (audio_source->available() > 0) {
// Existing exposure was ducked when fill() promoted it; do not re-duck on partial-consume re-entry.
return 0;
}
// Store current offset, as these samples are already ducked
const size_t current_length = transfer_buffer->available();
size_t bytes_read = audio_source->fill(ticks_to_wait, false);
size_t bytes_read = transfer_buffer->transfer_data_from_source(ticks_to_wait);
uint32_t samples_to_duck = this->audio_stream_info_.bytes_to_samples(bytes_read);
if (samples_to_duck > 0) {
int16_t *current_buffer = reinterpret_cast<int16_t *>(audio_source->mutable_data());
int16_t *current_buffer = reinterpret_cast<int16_t *>(transfer_buffer->get_buffer_start() + current_length);
duck_samples(current_buffer, samples_to_duck, &this->current_ducking_db_reduction_,
&this->ducking_transition_samples_remaining_, this->samples_per_ducking_step_,
@@ -411,7 +406,7 @@ void SourceSpeaker::duck_samples(int16_t *input_buffer, uint32_t input_samples_t
void SourceSpeaker::enter_stopping_state_() {
this->state_ = speaker::STATE_STOPPING;
this->stopping_start_ms_ = millis();
this->audio_source_.reset();
this->transfer_buffer_.reset();
}
void MixerSpeaker::dump_config() {
@@ -617,9 +612,9 @@ void MixerSpeaker::audio_mixer_task(void *params) {
// Pre-allocate vectors to avoid heap allocation in the loop (max 8 source speakers per schema)
FixedVector<SourceSpeaker *> speakers_with_data;
FixedVector<std::shared_ptr<audio::RingBufferAudioSource>> audio_sources_with_data;
FixedVector<std::shared_ptr<audio::AudioSourceTransferBuffer>> transfer_buffers_with_data;
speakers_with_data.init(this_mixer->source_speakers_.size());
audio_sources_with_data.init(this_mixer->source_speakers_.size());
transfer_buffers_with_data.init(this_mixer->source_speakers_.size());
while (true) {
uint32_t event_group_bits = xEventGroupGetBits(this_mixer->event_group_);
@@ -634,27 +629,27 @@ void MixerSpeaker::audio_mixer_task(void *params) {
this_mixer->audio_stream_info_.value().bytes_to_frames(output_transfer_buffer->free());
speakers_with_data.clear();
audio_sources_with_data.clear();
transfer_buffers_with_data.clear();
for (auto &speaker : this_mixer->source_speakers_) {
if (speaker->is_running() && !speaker->get_pause_state()) {
// Speaker is running and not paused, so it possibly can provide audio data
std::shared_ptr<audio::RingBufferAudioSource> audio_source = speaker->get_audio_source().lock();
if (audio_source.use_count() == 0) {
// No audio source allocated, so skip processing this speaker
std::shared_ptr<audio::AudioSourceTransferBuffer> transfer_buffer = speaker->get_transfer_buffer().lock();
if (transfer_buffer.use_count() == 0) {
// No transfer buffer allocated, so skip processing this speaker
continue;
}
speaker->process_data_from_source(audio_source, 0); // Exposes and ducks audio from source ring buffers
speaker->process_data_from_source(transfer_buffer, 0); // Transfers and ducks audio from source ring buffers
if (audio_source->available() > 0) {
// Retain shared ownership across the mixing pass so the source isn't released mid-mix
audio_sources_with_data.push_back(audio_source);
if (transfer_buffer->available() > 0) {
// Store the locked transfer buffers in their own vector to avoid releasing ownership until after the loop
transfer_buffers_with_data.push_back(transfer_buffer);
speakers_with_data.push_back(speaker);
}
}
}
if (audio_sources_with_data.empty()) {
if (transfer_buffers_with_data.empty()) {
// No audio available for transferring, block task temporarily
delay(TASK_DELAY_MS);
continue;
@@ -662,7 +657,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
uint32_t frames_to_mix = output_frames_free;
if ((audio_sources_with_data.size() == 1) || this_mixer->queue_mode_) {
if ((transfer_buffers_with_data.size() == 1) || this_mixer->queue_mode_) {
// Only one speaker has audio data, just copy samples over
audio::AudioStreamInfo active_stream_info = speakers_with_data[0]->get_audio_stream_info();
@@ -672,10 +667,10 @@ void MixerSpeaker::audio_mixer_task(void *params) {
// Speaker's sample rate matches the output speaker's, copy directly
const uint32_t frames_available_in_buffer =
active_stream_info.bytes_to_frames(audio_sources_with_data[0]->available());
active_stream_info.bytes_to_frames(transfer_buffers_with_data[0]->available());
frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer);
copy_frames(reinterpret_cast<const int16_t *>(audio_sources_with_data[0]->data()), active_stream_info,
reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end()),
copy_frames(reinterpret_cast<int16_t *>(transfer_buffers_with_data[0]->get_buffer_start()),
active_stream_info, reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end()),
this_mixer->audio_stream_info_.value(), frames_to_mix);
// Set playback delay for newly contributing source
@@ -687,7 +682,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
// Update source speaker pending frames
speakers_with_data[0]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release);
audio_sources_with_data[0]->consume(active_stream_info.frames_to_bytes(frames_to_mix));
transfer_buffers_with_data[0]->decrease_buffer_length(active_stream_info.frames_to_bytes(frames_to_mix));
// Update output transfer buffer length and pipeline frame count
output_transfer_buffer->increase_buffer_length(
@@ -714,25 +709,25 @@ void MixerSpeaker::audio_mixer_task(void *params) {
}
} else {
// Determine how many frames to mix
for (size_t i = 0; i < audio_sources_with_data.size(); ++i) {
const uint32_t frames_available_in_buffer =
speakers_with_data[i]->get_audio_stream_info().bytes_to_frames(audio_sources_with_data[i]->available());
for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) {
const uint32_t frames_available_in_buffer = speakers_with_data[i]->get_audio_stream_info().bytes_to_frames(
transfer_buffers_with_data[i]->available());
frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer);
}
const int16_t *primary_buffer = reinterpret_cast<const int16_t *>(audio_sources_with_data[0]->data());
int16_t *primary_buffer = reinterpret_cast<int16_t *>(transfer_buffers_with_data[0]->get_buffer_start());
audio::AudioStreamInfo primary_stream_info = speakers_with_data[0]->get_audio_stream_info();
// Mix two streams together
for (size_t i = 1; i < audio_sources_with_data.size(); ++i) {
for (size_t i = 1; i < transfer_buffers_with_data.size(); ++i) {
mix_audio_samples(primary_buffer, primary_stream_info,
reinterpret_cast<const int16_t *>(audio_sources_with_data[i]->data()),
reinterpret_cast<int16_t *>(transfer_buffers_with_data[i]->get_buffer_start()),
speakers_with_data[i]->get_audio_stream_info(),
reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end()),
this_mixer->audio_stream_info_.value(), frames_to_mix);
if (i != audio_sources_with_data.size() - 1) {
if (i != transfer_buffers_with_data.size() - 1) {
// Need to mix more streams together, point primary buffer and stream info to the already mixed output
primary_buffer = reinterpret_cast<const int16_t *>(output_transfer_buffer->get_buffer_end());
primary_buffer = reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end());
primary_stream_info = this_mixer->audio_stream_info_.value();
}
}
@@ -740,8 +735,8 @@ void MixerSpeaker::audio_mixer_task(void *params) {
// Get current pipeline depth for delay calculation (before incrementing)
uint32_t current_pipeline_frames = this_mixer->frames_in_pipeline_.load(std::memory_order_acquire);
// Update source audio source consumption and add new audio durations to the source speaker pending playbacks
for (size_t i = 0; i < audio_sources_with_data.size(); ++i) {
// Update source transfer buffer lengths and add new audio durations to the source speaker pending playbacks
for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) {
// Set playback delay for newly contributing sources
if (!speakers_with_data[i]->has_contributed_.load(std::memory_order_acquire)) {
speakers_with_data[i]->playback_delay_frames_.store(current_pipeline_frames, std::memory_order_release);
@@ -749,7 +744,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
}
speakers_with_data[i]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release);
audio_sources_with_data[i]->consume(
transfer_buffers_with_data[i]->decrease_buffer_length(
speakers_with_data[i]->get_audio_stream_info().frames_to_bytes(frames_to_mix));
}
@@ -67,13 +67,11 @@ class SourceSpeaker : public speaker::Speaker, public Component {
void set_pause_state(bool pause_state) override { this->pause_state_ = pause_state; }
bool get_pause_state() const override { return this->pause_state_; }
/// @brief Exposes the next ring buffer chunk (zero-copy) and ducks the freshly exposed bytes in place.
/// If the source still has bytes from a prior partial consume, this is a no-op (those bytes were already
/// ducked on the fill that exposed them).
/// @param audio_source Locked shared_ptr to the audio source (must be valid, not null)
/// @brief Transfers audio from the ring buffer into the transfer buffer. Ducks audio while transferring.
/// @param transfer_buffer Locked shared_ptr to the transfer buffer (must be valid, not null)
/// @param ticks_to_wait FreeRTOS ticks to wait while waiting to read from the ring buffer.
/// @return Number of bytes newly exposed from the ring buffer.
size_t process_data_from_source(std::shared_ptr<audio::RingBufferAudioSource> &audio_source,
/// @return Number of bytes transferred from the ring buffer.
size_t process_data_from_source(std::shared_ptr<audio::AudioSourceTransferBuffer> &transfer_buffer,
TickType_t ticks_to_wait);
/// @brief Sets the ducking level for the source speaker.
@@ -85,7 +83,7 @@ class SourceSpeaker : public speaker::Speaker, public Component {
void set_parent(MixerSpeaker *parent) { this->parent_ = parent; }
void set_timeout(uint32_t ms) { this->timeout_ms_ = ms; }
std::weak_ptr<audio::RingBufferAudioSource> get_audio_source() { return this->audio_source_; }
std::weak_ptr<audio::AudioSourceTransferBuffer> get_transfer_buffer() { return this->transfer_buffer_; }
protected:
friend class MixerSpeaker;
@@ -108,7 +106,7 @@ class SourceSpeaker : public speaker::Speaker, public Component {
MixerSpeaker *parent_;
std::shared_ptr<audio::RingBufferAudioSource> audio_source_;
std::shared_ptr<audio::AudioSourceTransferBuffer> transfer_buffer_;
std::weak_ptr<ring_buffer::RingBuffer> ring_buffer_;
uint32_t buffer_duration_ms_;
+11 -3
View File
@@ -500,9 +500,17 @@ def upload_program(config: ConfigType, args, host: str) -> bool:
mcumgr_device = host
if mcumgr_device:
firmware = Path(
CORE.relative_pioenvs_path(CORE.name, "zephyr", "app_update.bin")
).resolve()
# `esphome upload --prebuilt-dir <path>` ships the MCUboot-signed
# update image at the root of the prebuilt directory; prefer that so
# the dashboard's transparent BLE install on a Bluetooth proxy works
# without a local Zephyr build tree.
prebuilt = CORE.prebuilt_artifact_path("app_update.bin")
if prebuilt is not None:
firmware = prebuilt.resolve()
else:
firmware = Path(
CORE.relative_pioenvs_path(CORE.name, "zephyr", "app_update.bin")
).resolve()
asyncio.run(smpmgr_upload(mcumgr_device, firmware))
return True # Handled: mcumgr OTA upload
@@ -68,9 +68,6 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type)
return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE;
} else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) {
return OTA_RESPONSE_ERROR_WRITING_FLASH;
} else if (err == ESP_ERR_OTA_PARTITION_CONFLICT) {
// This error appears with 1 factory and 1 ota partition
return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION;
}
return OTA_RESPONSE_ERROR_UNKNOWN;
}
@@ -210,7 +210,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() {
ESP_LOGE(TAG, "Cannot resolve running app partition at address 0x%" PRIX32, running_app_offset);
return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE;
}
ESP_LOGD(TAG, "Copying running app from 0x%" PRIX32 " to 0x%" PRIX32 " (size: 0x%zX)", running_app_part->address,
ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address,
plan.copy_dest_part->address, running_app_size);
err = esp_partition_copy(plan.copy_dest_part, 0, running_app_part, 0, running_app_size);
if (err != ESP_OK) {
@@ -261,7 +261,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() {
ESP_LOGE(TAG, "Selected app partition not found after partition table update");
return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE;
}
ESP_LOGD(TAG, "Setting next boot partition to 0x%" PRIX32, new_boot_partition->address);
ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address);
err = esp_ota_set_boot_partition(new_boot_partition);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X)", err);
+7 -35
View File
@@ -215,7 +215,7 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
If loading fails after cloning, attempts a revert and retry in case
a prior cached checkout is stale.
"""
repo_root, revert = git.clone_or_update(
repo_dir, revert = git.clone_or_update(
url=config[CONF_URL],
ref=config.get(CONF_REF),
refresh=config[CONF_REFRESH],
@@ -225,10 +225,6 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
)
files: list[dict[str, Any]] = []
# ``repo_root`` is the directory containing ``.git`` and must be passed
# to git for symlink-stub resolution. ``repo_dir`` may be narrowed to a
# subdirectory via the user's CONF_PATH and is used for file lookups.
repo_dir = repo_root
if base_path := config.get(CONF_PATH):
repo_dir = repo_dir / base_path
@@ -240,37 +236,13 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
def _load_package_yaml(yaml_file: Path, filename: str) -> dict:
"""Load a YAML file from a remote package, validating min_version."""
def _load(path: Path) -> dict | str | None:
try:
return yaml_util.load_yaml(path)
except EsphomeError as e:
raise cv.Invalid(
f"{filename} is not a valid YAML file."
f" Please check the file contents.\n{e}"
) from e
new_yaml = _load(yaml_file)
if not isinstance(new_yaml, dict):
# On Windows, git defaults to core.symlinks=false unless the user
# has Developer Mode enabled or is running elevated. Files stored
# in the repo as symlinks (tree mode 120000) are then checked out
# as plain text files containing the symlink target path, so
# parsing them as YAML yields a bare scalar instead of a mapping.
# Best-effort: follow the symlink target ourselves and re-load.
target = git.resolve_symlink_stub(repo_root, yaml_file)
if target is not None:
new_yaml = _load(target)
if not isinstance(new_yaml, dict):
try:
new_yaml = yaml_util.load_yaml(yaml_file)
except EsphomeError as e:
raise cv.Invalid(
f"{filename} does not contain a YAML mapping at the top level "
f"(got {type(new_yaml).__name__}). "
f"If this file is a git symlink in the source repository, it "
f"may not have been materialized correctly on your platform "
f"(this is a known issue with git on Windows without Developer "
f"Mode enabled). Try pointing your package at the real file "
f"path instead."
)
f"{filename} is not a valid YAML file."
f" Please check the file contents.\n{e}"
) from e
esphome_config = new_yaml.get(CONF_ESPHOME) or {}
min_version = esphome_config.get(CONF_MIN_VERSION)
if min_version is not None and cv.Version.parse(min_version) > cv.Version.parse(
@@ -150,7 +150,7 @@ void IRAM_ATTR PulseMeterSensor::edge_intr(PulseMeterSensor *sensor) {
edge_state.last_sent_edge_us_ = now;
state.last_detected_edge_us_ = now;
state.last_rising_edge_us_ = now;
state.count_ += 1;
state.count_++; // NOLINT(clang-diagnostic-deprecated-volatile)
}
// This ISR is bound to rising edges, so the pin is high
@@ -173,7 +173,7 @@ void IRAM_ATTR PulseMeterSensor::pulse_intr(PulseMeterSensor *sensor) {
} else if (length && !pulse_state.latched_ && sensor->last_pin_val_) { // Long enough high edge
pulse_state.latched_ = true;
state.last_detected_edge_us_ = pulse_state.last_intr_;
state.count_ += 1;
state.count_++; // NOLINT(clang-diagnostic-deprecated-volatile)
}
// Due to order of operations this includes
@@ -8,10 +8,9 @@ breaking changes policy. Use at your own risk.
Once the API is considered stable, this warning will be removed.
"""
from esphome import automation
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_ON_CONTROL
from esphome.const import CONF_ID
from esphome.core import CORE, coroutine_with_priority
from esphome.core.entity_helpers import queue_entity_register, setup_entity
from esphome.coroutine import CoroPriority
@@ -43,7 +42,6 @@ def radio_frequency_schema(class_: type[cg.MockObjClass]) -> cv.Schema:
return entity_schema.extend(
{
cv.GenerateID(): cv.declare_id(class_),
cv.Optional(CONF_ON_CONTROL): automation.validate_automation({}),
}
)
@@ -61,11 +59,6 @@ async def register_radio_frequency(var: cg.Pvariable, config: ConfigType) -> Non
await setup_radio_frequency_core_(var, config)
CORE.register_platform_component("radio_frequency", var)
for conf in config.get(CONF_ON_CONTROL, []):
await automation.build_callback_automation(
var, "add_on_control_callback", [(RadioFrequencyCall, "x")], conf
)
async def new_radio_frequency(config: ConfigType, *args) -> cg.Pvariable:
"""Create a new RadioFrequency instance.
@@ -54,10 +54,6 @@ RadioFrequencyCall &RadioFrequencyCall::set_repeat_count(uint32_t count) {
void RadioFrequencyCall::perform() {
if (this->parent_ != nullptr) {
// Fire any on_control hooks (user-wired automations) before handing off to
// the platform-specific control() — gives users a chance to react to call
// parameters (e.g. retune an external RF front-end based on call.get_frequency()).
this->parent_->control_callback_.call(*this);
this->parent_->control(*this);
}
}
@@ -170,15 +170,6 @@ class RadioFrequency : public Component, public EntityBase, public remote_base::
this->receive_callback_.add(std::forward<F>(callback));
}
/// Add a callback to invoke when a transmit call is made on this entity.
/// Fires before the platform-specific control() runs, with the call object
/// (containing frequency, modulation, repeat count, etc.). Used by the
/// `on_control` YAML trigger so users can wire any RF front-end driver
/// (CC1101, RFM69, custom) to react to per-call parameters.
template<typename F> void add_on_control_callback(F &&callback) {
this->control_callback_.add(std::forward<F>(callback));
}
protected:
friend class RadioFrequencyCall;
@@ -191,8 +182,6 @@ class RadioFrequency : public Component, public EntityBase, public remote_base::
// Callback manager for receive events (lazy: saves memory when no callbacks registered)
LazyCallbackManager<void(remote_base::RemoteReceiveData)> receive_callback_;
// Callback manager for on_control trigger (lazy: same memory savings)
LazyCallbackManager<void(const RadioFrequencyCall &)> control_callback_;
};
} // namespace esphome::radio_frequency
@@ -78,10 +78,10 @@ void RemoteReceiverComponent::setup() {
void RemoteReceiverComponent::dump_config() {
ESP_LOGCONFIG(TAG,
"Remote Receiver:\n"
" Buffer Size: %" PRIu32 "\n"
" Tolerance: %" PRIu32 "%s\n"
" Filter out pulses shorter than: %" PRIu32 " us\n"
" Signal is done after %" PRIu32 " us of no changes",
" Buffer Size: %u\n"
" Tolerance: %u%s\n"
" Filter out pulses shorter than: %u us\n"
" Signal is done after %u us of no changes",
this->buffer_size_, this->tolerance_,
(this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? " us" : "%", this->filter_us_,
this->idle_us_);
+3 -3
View File
@@ -139,7 +139,7 @@ def _parse_platform_version(value):
# The default/recommended arduino framework version
# - https://github.com/earlephilhower/arduino-pico/releases
# - https://api.registry.platformio.org/v3/packages/earlephilhower/tool/framework-arduinopico
RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 6, 0)
RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 5, 1)
# The raspberrypi platform version to use for arduino frameworks
# - https://github.com/maxgerhardt/platform-raspberrypi/tags
@@ -149,8 +149,8 @@ RECOMMENDED_ARDUINO_PLATFORM_VERSION = "v1.4.0-gcc14-arduinopico460"
def _arduino_check_versions(value):
value = value.copy()
lookups = {
"dev": (cv.Version(5, 6, 0), "https://github.com/earlephilhower/arduino-pico"),
"latest": (cv.Version(5, 6, 0), None),
"dev": (cv.Version(5, 5, 1), "https://github.com/earlephilhower/arduino-pico"),
"latest": (cv.Version(5, 5, 1), None),
"recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None),
}
-18
View File
@@ -457,19 +457,6 @@ RP2040_BOARD_PINS = {
"SS": 17,
"TX": 12,
},
"challenger_2350_nbiot": {
"LED": 15,
"MISO": 16,
"MOSI": 19,
"RX": 13,
"SCK": 18,
"SCL": 21,
"SCL1": 31,
"SDA": 20,
"SDA1": 31,
"SS": 17,
"TX": 12,
},
"challenger_2350_wifi6_ble5": {
"LED": 7,
"MISO": 16,
@@ -1724,11 +1711,6 @@ BOARDS = {
"mcu": "rp2350",
"max_pin": 47,
},
"challenger_2350_nbiot": {
"name": "iLabs Challenger 2350 NB-IoT",
"mcu": "rp2350",
"max_pin": 47,
},
"challenger_2350_wifi6_ble5": {
"name": "iLabs Challenger 2350 WiFi/BLE",
"mcu": "rp2350",
+2 -74
View File
@@ -15,7 +15,6 @@
#elif defined(USE_ESP32)
#include <esp_ota_ops.h>
#include <esp_system.h>
#include <esp_image_format.h>
#endif
#endif
@@ -23,37 +22,6 @@ namespace esphome::safe_mode {
static const char *const TAG = "safe_mode";
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK) && !defined(USE_OTA_PARTITIONS)
// Find a non-running app partition. If verify is true, only returns a partition
// whose image passes verification (expensive: reads flash). Returns nullptr if none found.
static const esp_partition_t *find_alternate_app_partition(bool verify) {
const esp_partition_t *running = esp_ota_get_running_partition();
const esp_partition_t *result = nullptr;
esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr);
while (it != nullptr) {
const esp_partition_t *p = esp_partition_get(it);
if (p->address != running->address) {
if (!verify) {
result = p;
break;
}
esp_image_metadata_t data = {};
const esp_partition_pos_t part_pos = {
.offset = p->address,
.size = p->size,
};
if (esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &part_pos, &data) == ESP_OK) {
result = p;
break;
}
}
it = esp_partition_next(it);
}
esp_partition_iterator_release(it);
return result;
}
#endif
void SafeModeComponent::dump_config() {
ESP_LOGCONFIG(TAG,
"Safe Mode:\n"
@@ -66,11 +34,7 @@ void SafeModeComponent::dump_config() {
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
const char *state_str;
if (this->ota_state_ == ESP_OTA_IMG_NEW) {
#ifdef USE_OTA_PARTITIONS
state_str = "support unknown";
#else
state_str = "not supported";
#endif
} else if (this->ota_state_ == ESP_OTA_IMG_PENDING_VERIFY) {
state_str = "supported";
} else {
@@ -100,18 +64,6 @@ void SafeModeComponent::dump_config() {
" See https://esphome.io/guides/faq.html#brownout-detector-was-triggered");
}
}
if (!this->app_ota_possible_) {
ESP_LOGW(TAG, "OTA updates are impossible.");
#ifdef USE_OTA_PARTITIONS
ESP_LOGW(TAG, " OTA partition table update or serial flashing is required.");
#else
if (find_alternate_app_partition(false) != nullptr) {
ESP_LOGW(TAG, " Activate safe mode to reboot to the recovery partition.");
} else {
ESP_LOGE(TAG, " No recovery partition available; serial flashing is required.");
}
#endif
}
#endif
}
@@ -172,10 +124,8 @@ bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t en
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
// Check partition state to detect if bootloader supports rollback
const esp_partition_t *running_part = esp_ota_get_running_partition();
esp_ota_get_state_partition(running_part, &this->ota_state_);
const esp_partition_t *next_part = esp_ota_get_next_update_partition(nullptr);
this->app_ota_possible_ = (next_part != nullptr && next_part != running_part);
const esp_partition_t *running = esp_ota_get_running_partition();
esp_ota_get_state_partition(running, &this->ota_state_);
#endif
uint32_t rtc_val = this->read_rtc_();
@@ -201,28 +151,6 @@ bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t en
ESP_LOGE(TAG, "Boot loop detected");
}
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK) && !defined(USE_OTA_PARTITIONS)
// Allow recovery of soft-bricked devices
// Instead of starting safe_mode, reboot to the other app partition if all conditions are met:
// - app OTA is impossible (for example because the other app partition has type 'factory')
// - the other app partition contains a valid app (for example Tasmota safeboot image or ESPHome)
// - allow_partition_access is not configured making recovery via partition table update impossible
// Image verification is deferred until here so the cost is only paid when entering safe mode,
// not on every boot.
if (!this->app_ota_possible_) {
const esp_partition_t *rollback_part = find_alternate_app_partition(true);
if (rollback_part != nullptr) {
esp_err_t err = esp_ota_set_boot_partition(rollback_part);
if (err == ESP_OK) {
ESP_LOGW(TAG, "OTA updates are impossible. Rebooting to recovery app.");
App.reboot();
} else {
ESP_LOGE(TAG, "Failed to set recovery boot partition: %s", esp_err_to_name(err));
}
}
}
#endif
this->status_set_error();
this->set_timeout(enable_time, []() {
ESP_LOGW(TAG, "Timeout, restarting");
+1 -4
View File
@@ -48,14 +48,11 @@ class SafeModeComponent final : public Component {
uint32_t safe_mode_enable_time_{60000}; ///< The time safe mode should remain active for
uint32_t safe_mode_rtc_value_{0};
uint32_t safe_mode_start_time_{0}; ///< stores when safe mode was enabled
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
esp_ota_img_states_t ota_state_{ESP_OTA_IMG_UNDEFINED}; // 4-byte enum
#endif
// Group 1-byte members together to minimize padding
bool boot_successful_{false}; ///< set to true after boot is considered successful
uint8_t safe_mode_num_attempts_{0};
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
bool app_ota_possible_{true};
esp_ota_img_states_t ota_state_{ESP_OTA_IMG_UNDEFINED};
#endif
// Larger objects at the end
ESPPreferenceObject rtc_;
-2
View File
@@ -169,8 +169,6 @@ async def script_execute_action_to_code(config, action_id, template_arg, args):
return value
if type == "bool":
return cg.RawExpression(str(value).lower())
if isinstance(value, (list, tuple)):
return cg.ArrayInitializer(*value)
return cg.RawExpression(str(value))
return converter
+2
View File
@@ -25,6 +25,7 @@ from esphome.const import (
CONF_TEMPERATURE_COMPENSATION,
CONF_TIME_CONSTANT,
CONF_VOC,
DEVICE_CLASS_AQI,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PM1,
DEVICE_CLASS_PM10,
@@ -76,6 +77,7 @@ def _gas_sensor(
return sensor.sensor_schema(
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
).extend(
{
+3
View File
@@ -14,6 +14,7 @@ from esphome.const import (
CONF_TEMPERATURE,
CONF_TYPE,
CONF_VOC,
DEVICE_CLASS_AQI,
DEVICE_CLASS_CARBON_DIOXIDE,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PM1,
@@ -92,11 +93,13 @@ CONFIG_SCHEMA = (
cv.Optional(CONF_VOC): sensor.sensor_schema(
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_NOX): sensor.sensor_schema(
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_CO2): sensor.sensor_schema(
+2 -5
View File
@@ -206,15 +206,12 @@ async def to_code(config: ConfigType) -> None:
)
# sendspin-cpp library
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1")
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.4.0")
cg.add_define("USE_SENDSPIN", True) # for MDNS
data = _get_data()
# The color role is not yet wired up in ESPHome; disable it in the library for now.
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_COLOR", False)
# Configure Sendspin roles based on requested features (ESPHome internally via USE_SENDSPIN_*)
# and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*).
if data.artwork_support:
@@ -267,7 +264,7 @@ async def to_code(config: ConfigType) -> None:
# 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),
# decode buffer location PREFER_EXTERNAL.
# interpolation/decode buffer locations PREFER_EXTERNAL.
player_struct_fields = [
("audio_formats", audio_format_structs),
("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]),
@@ -188,6 +188,14 @@ void SendspinMediaSource::on_stream_end() {
}
}
// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback)
void SendspinMediaSource::on_stream_clear() {
if (this->get_state() != media_source::MediaSourceState::IDLE) {
// Only set to IDLE if we were previously in a non-IDLE state, to avoid duplicate state changes
this->set_state_(media_source::MediaSourceState::IDLE);
}
}
// THREAD CONTEXT: Main loop (PlayerRoleListener callback)
void SendspinMediaSource::on_volume_changed(uint8_t volume) { this->request_volume_(volume / 100.0f); }

Some files were not shown because too many files have changed in this diff Show More