Compare commits

..
Author SHA1 Message Date
J. Nick Koston 3966dad8fb [api] Keep pending IR/RF transmit replies in a fixed per-connection array 2026-09-10 19:57:35 -05:00
J. Nick Koston a9bf807f4a [api] Reply when an infrared/RF raw timings transmit has completed 2026-09-10 19:26:39 -05:00
esphome[bot] 3e3822e554 Bump bundled esphome-device-builder to 1.14.6 (#19072) 2026-09-10 16:22:30 +00:00
Kevin AhrendtandJ. Nick Koston 7564f5ff1a [sendspin] Add manufacturer, model, and firmware version options (#18792)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-09-10 11:06:58 -05:00
matt123p a807a8f945 [es7210] Fix 4 channel microphone support (#19034) 2026-09-10 11:11:43 -04:00
Keith Burzinskiandpuddly 280fac11e6 [serial_proxy] Add tap interface and port mode (#18955)
Co-authored-by: puddly <32534428+puddly@users.noreply.github.com>
2026-09-10 10:03:33 -05:00
esphome[bot] 9924148302 Bump aioesphomeapi from 46.3.0 to 46.4.0 (#19071) 2026-09-10 14:59:23 +00:00
luar123 66f829c760 [zigbee] wake loop on defer/set_timeout (#19050) 2026-09-10 08:08:07 -05:00
luar123 a88ec7d90b [logger] Flush uart before sleep in idf 6 (#18975) 2026-09-10 13:07:09 +00:00
luar123 54706e869c [deep_sleep] disable loop (#18962) 2026-09-10 07:18:15 -05:00
05f7d5e4f1 [mlx90614] pec validation (#6689)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-09-10 16:14:43 +12:00
Robin ThoniandJesse Hills ddbd89dd2a [network] Improve network::is_connected() to better handle multiple interfaces (#18999)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-09-10 04:06:44 +00:00
Jesse Hills 5ed59af920 [template] Surface value metadata on template entity forms (#17545) 2026-09-10 14:11:13 +12:00
Jesse Hills 4868b498cf [ci] Ask stale PR authors to merge dev instead of rebasing (#19064) 2026-09-10 14:09:58 +12:00
Jesse Hills c66fa81208 [core] Consolidate setup scripts into a cross-platform setup.py (#18856) 2026-09-10 14:09:49 +12:00
esphome[bot] 58ca345684 [ci] Refresh integration test durations (#19049) 2026-09-09 13:42:09 -04:00
Remco van Essen fd598057ef [sendspin] Fix codec enum codegen when codecs is not set (#19055) 2026-09-09 08:22:23 -04:00
Jesse Hills ef7279341f Merge branch 'beta' into dev 2026-09-09 12:18:55 +12:00
Jesse Hills 69f2a7d556 Merge pull request #19051 from esphome/bump-2026.9.0b3
2026.9.0b3
2026-09-09 12:18:36 +12:00
Jesse Hills 008677298a Bump version to 2026.9.0b3 2026-09-09 09:54:44 +12:00
Kevin AhrendtandJesse Hills d2bc056f0a [sendspin] Add codec preference list to the media source (#19047)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-09-09 09:54:42 +12:00
J. Nick Koston 42fffd16fe [esphome][core] Give a lost OTA chunk ack time to be retransmitted (#19041) 2026-09-09 09:54:42 +12:00
J. Nick Koston 9c16aba6f7 [noise] Bump noise-c to 0.1.26 and libsodium to 1.10021.8 (#19030) 2026-09-09 09:54:42 +12:00
Kevin Ahrendt ac79173f4a [i2s_audio] Fix spurious driver failure (#19045) 2026-09-09 09:54:42 +12:00
Kevin Ahrendt 0a1e2acbcb [audio][i2s_audio][micro_wake_word][microphone][mixer][resampler][speaker] Replace use_count() checks with lock and null test (#19046) 2026-09-09 09:54:42 +12:00
Kevin AhrendtandCopilot Autofix powered by AI 9b6facb20d [core] Fix use-after-free when deleting a running StaticTask (#19048)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-09-09 09:54:42 +12:00
esphome[bot] f89b9e704c Bump bundled esphome-device-builder to 1.14.5 (#19040) 2026-09-09 09:54:42 +12:00
Johnandpre-commit-ci-lite[bot] f8b2e53609 [atm90e32] Verify offset calibration writes (#18701)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-09-09 09:54:42 +12:00
raykholo 7660dd7fa7 [anova] Re-assert temperature unit on every poll cycle (#17141) 2026-09-09 09:54:42 +12:00
c3ce07755f [rf_bridge] Fix bucket sniffing with Portisch firmware (#17683)
Co-authored-by: Bryan Li <bryanli@Bryans-MacBook-Pro.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-09 09:54:42 +12:00
Gytis f8a4cfa945 [lvgl] Add missing label dependency to qrcode, keyboard and tabview (#18387) 2026-09-09 09:54:42 +12:00
J. Nick Koston 866ddb6e57 [core] Skip PlatformIO's private-package authorization probe (#18823) 2026-09-09 09:54:42 +12:00
Jonathan Swoboda ca864c22b4 [tuya] Build without a network component (#18948) 2026-09-09 09:54:42 +12:00
Jesse Hills c9729244af [udp] Use cv.invalid for relocated packet_transport options (#19032) 2026-09-09 09:54:42 +12:00
Davide D M 442e4a1ec2 [debug] Check reboot source pref on ESP_RST_WDT and guard against empty source (#17537) 2026-09-09 09:54:41 +12:00
Pieter ViljoenandJesse Hills 199acdf522 [ble_client] Report Established from nodes that never read services (#17920)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-09-09 09:54:41 +12:00
Samuel SiebandSamuel Sieb 9340863652 [dallas_temp] filter 85 temp from sensor reset (#17877)
Co-authored-by: Samuel Sieb <samuel@sieb.net>
2026-09-09 09:54:41 +12:00
AndreKR d5cff6e9df [logger] Fix garbled stack traces (#17939) 2026-09-09 09:54:41 +12:00
Ryan Ronnander e7f45a0d31 [mqtt] Restore brightness flag in light discovery (#18950) 2026-09-09 09:54:41 +12:00
mipa87andClaude 628ebe23ec [audio] Do not treat MP3_STREAM_INFO_CHANGED as a fatal decoder error (#19028)
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-09 09:54:41 +12:00
823d79c948 [i2s_audio] Keep a start request that arrives while the speaker task stops (#19027)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-09-09 09:54:41 +12:00
Kevin AhrendtandJesse Hills b947094f45 [sendspin] Add codec preference list to the media source (#19047)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-09-09 09:31:18 +12:00
J. Nick Koston 6c5ab89d5f [esphome][core] Give a lost OTA chunk ack time to be retransmitted (#19041) 2026-09-09 09:08:16 +12:00
J. Nick Koston 8f511a365a [noise] Bump noise-c to 0.1.26 and libsodium to 1.10021.8 (#19030) 2026-09-09 09:07:06 +12:00
Kevin Ahrendt 006f31af93 [i2s_audio] Fix spurious driver failure (#19045) 2026-09-09 09:05:02 +12:00
Kevin Ahrendt 5bb112f407 [audio][i2s_audio][micro_wake_word][microphone][mixer][resampler][speaker] Replace use_count() checks with lock and null test (#19046) 2026-09-09 09:04:34 +12:00
dependabot[bot] 4ab9298ab3 Bump esptool from 5.3.1 to 5.4.0 (#19023) 2026-09-08 22:11:36 +02:00
Kevin AhrendtandCopilot Autofix powered by AI 3926612281 [core] Fix use-after-free when deleting a running StaticTask (#19048)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-09-09 07:46:17 +12:00
J. Nick Koston 1ce0bed3f6 [core] Share compiled binaries across modbus integration tests (#18945) 2026-09-08 18:10:45 +02:00
144 changed files with 3497 additions and 8480 deletions
@@ -1,39 +0,0 @@
name: Cache Arduino ESP8266
description: >
Resolve the pinned Arduino core and xtensa toolchain versions and cache the
native ESP8266 install (~110 MB framework + toolchain; no ccache store, the
seed job saves before any compile runs). Exports
ESPHOME_ARDUINO8266_PREFIX to the job so every later step installs into
the cached path; the Python venv must already be restored. Mirrors
cache-esp-idf: only dev-branch pushes write the shared cache, everything
else restores.
runs:
using: composite
steps:
- name: Resolve the native toolchain cache key
# Versions are pinned in code, not a hashable file; resolve them so a
# bump changes the cache key. Assignment form so errexit catches a
# resolver failure.
id: version
shell: bash
run: |
# One owner for the install prefix: exported here and referenced by
# the cache steps below via env, so the caller's install and the
# cached path cannot diverge.
echo "ESPHOME_ARDUINO8266_PREFIX=$HOME/.esphome-arduino8266" >> "$GITHUB_ENV"
. venv/bin/activate
key=$(python -c 'from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION as f; from esphome.arduino8266.framework import TOOLCHAIN_VERSION as t; print(f"{f}-{t}")')
[ -n "$key" ] || exit 1
echo "key=$key" >> "$GITHUB_OUTPUT"
- name: Cache the native toolchain (write on dev)
if: github.ref == 'refs/heads/dev'
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.ESPHOME_ARDUINO8266_PREFIX }}
key: ${{ runner.os }}-esp8266-native-${{ steps.version.outputs.key }}
- name: Restore the native toolchain (off dev)
if: github.ref != 'refs/heads/dev'
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.ESPHOME_ARDUINO8266_PREFIX }}
key: ${{ runner.os }}-esp8266-native-${{ steps.version.outputs.key }}
+1 -67
View File
@@ -102,8 +102,6 @@ jobs:
device-builder: ${{ steps.determine.outputs.device-builder }}
esp32-platformio: ${{ steps.determine.outputs.esp32-platformio }}
esp32-platformio-components: ${{ steps.determine.outputs.esp32-platformio-components }}
esp8266-native: ${{ steps.determine.outputs.esp8266-native }}
esp8266-native-components: ${{ steps.determine.outputs.esp8266-native-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 }}
@@ -167,8 +165,6 @@ jobs:
echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT
echo "esp32-platformio=$(echo "$output" | jq -r '.esp32_platformio')" >> $GITHUB_OUTPUT
echo "esp32-platformio-components=$(echo "$output" | jq -r '.esp32_platformio_components')" >> $GITHUB_OUTPUT
echo "esp8266-native=$(echo "$output" | jq -r '.esp8266_native')" >> $GITHUB_OUTPUT
echo "esp8266-native-components=$(echo "$output" | jq -r '.esp8266_native_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
@@ -187,32 +183,6 @@ jobs:
path: .temp/components_graph.json
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
seed-esp8266-native-cache:
name: Seed the esp8266 native toolchain cache
runs-on: ubuntu-24.04
needs:
- common
# PR-branch cache saves are invisible to other PRs, so dev pushes seed
# the shared entry test-esp8266-native restores. Only dev: the composite
# action saves nowhere else, so a beta/release push would download the
# toolchain and discard it.
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
timeout-minutes: 15
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache the native toolchain
uses: ./.github/actions/cache-arduino8266
- name: Install the native toolchain
run: |
. venv/bin/activate
python -c "from esphome.arduino8266.framework import check_and_install; from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION; check_and_install(RECOMMENDED_ARDUINO_FRAMEWORK_VERSION)"
ci-custom:
name: Run script/ci-custom
runs-on: ubuntu-24.04
@@ -1267,7 +1237,7 @@ jobs:
# compile validates config first, so a separate config pass is
# redundant for this smoke test. ESP-IDF framework via PlatformIO:
python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio --fail-on-no-tests
python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio
echo ""
echo "ESP-IDF-via-PlatformIO build passed! Starting Arduino smoke test..."
@@ -1276,40 +1246,6 @@ jobs:
# Arduino framework via PlatformIO (only components with an esp32-ard test are built):
python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio
test-esp8266-native:
name: Test esp8266 components with the native toolchain
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp8266-native == 'true'
env:
# Computed by script/determine-jobs.py (ESP8266_NATIVE_TEST_COMPONENTS)
TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp8266-native-components }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache the native toolchain
uses: ./.github/actions/cache-arduino8266
- name: Run native toolchain compile test
run: |
. venv/bin/activate
echo "Testing components: $TEST_COMPONENTS"
echo ""
# ESP8266 Arduino built directly (no PlatformIO); compile validates
# config first, so a separate config pass is redundant.
python3 script/test_build_components.py -e compile -t esp8266-ard -c "$TEST_COMPONENTS" -f --toolchain arduino --fail-on-no-tests
device-builder:
name: Test downstream esphome/device-builder
runs-on: ubuntu-24.04
@@ -1672,7 +1608,6 @@ jobs:
needs:
- common
- seed-apt-cache
- seed-esp8266-native-cache
- determine-jobs
- ci-custom
- pylint
@@ -1688,7 +1623,6 @@ jobs:
- clang-tidy-esp32-variants
- test-build-components-split
- test-esp32-platformio
- test-esp8266-native
- device-builder
- memory-impact-target-branch
- memory-impact-pr-branch
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
and will be closed if no further activity occurs within 7 days.
If you are the author of this PR, please leave a comment if you want
to keep it open. Also, please rebase your PR onto the latest dev
to keep it open. Also, please merge the latest dev branch into your
branch to ensure that it's up to date with the latest changes.
Thank you for your contribution!
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.14.5
RUN uv pip install --no-cache-dir esphome-device-builder==1.14.6
RUN \
platformio settings set enable_telemetry No \
+31 -83
View File
@@ -817,9 +817,7 @@ def write_cpp_file() -> int:
from esphome.build_gen import espidf
espidf.write_project()
elif not CORE.using_native_toolchain:
# Other native builds generate their project at compile time;
# never write a platformio.ini for them
else:
from esphome.build_gen import platformio
platformio.write_project()
@@ -861,14 +859,20 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
toolchain.create_factory_bin()
toolchain.create_ota_bin()
toolchain.create_elf_copy()
from esphome.build_helpers.idedata import warn_if_idedata_missing
from esphome.build_helpers.idedata import IDEDATA_BEST_EFFORT_ERRORS
warn_if_idedata_missing(toolchain.get_idedata)
elif CORE.using_native_toolchain:
raise EsphomeError(
f"Toolchain '{CORE.toolchain.value}' resolved but no platform "
"backend claimed the build"
)
try:
if toolchain.get_idedata() is None:
_LOGGER.warning("No idedata was generated for this build")
except IDEDATA_BEST_EFFORT_ERRORS as err:
# The firmware already built; an idedata failure must not fail
# a successful build.
_LOGGER.warning(
"Could not generate idedata: %s (IDE, clang-tidy, and "
"memory-analysis data will be unavailable for this build)",
err,
)
_LOGGER.debug("Idedata failure detail", exc_info=True)
else:
from esphome.platformio import toolchain
@@ -971,15 +975,12 @@ def upload_using_esptool(
if file is not None:
flash_images = [FlashImage(path=file, offset="0x0")]
elif (native := _native_toolchain_module()) is not None:
# Every native backend supplies its own 0x0 flash image (bootloader
# and partitions included where the target needs them)
image = native.get_factory_firmware_path()
if not image.is_file():
raise EsphomeError(
f"{image} does not exist; compile the configuration first"
)
flash_images = [FlashImage(path=image, offset="0x0")]
elif CORE.using_toolchain_esp_idf:
from esphome.espidf import toolchain
flash_images = [
FlashImage(path=toolchain.get_factory_firmware_path(), offset="0x0")
]
else:
from esphome.platformio import toolchain
@@ -1961,39 +1962,15 @@ def command_update_all(args: ArgsProtocol) -> int | None:
return run_multiple_configs(files, build_command)
# Native build backend per (target platform, toolchain). Keyed here rather
# than through a platform hook so the serial upload/logs fast path never
# imports the platform component package (see the esp32 variant comment in
# upload_using_esptool); the platform half comes from CORE.data the same way.
_NATIVE_TOOLCHAIN_MODULES = {
("esp32", Toolchain.ESP_IDF): "esphome.espidf.toolchain",
("esp8266", Toolchain.ARDUINO): "esphome.arduino8266.toolchain",
}
def _native_toolchain_module():
"""The native build backend module for the resolved toolchain."""
if not CORE.using_native_toolchain:
return None
key = (CORE.target_platform, CORE.toolchain)
if (module_path := _NATIVE_TOOLCHAIN_MODULES.get(key)) is None:
# Degrading to the PlatformIO path would build with the wrong backend
raise EsphomeError(
f"Toolchain '{CORE.toolchain.value}' has no native build backend "
f"module for platform {CORE.target_platform}"
)
return importlib.import_module(module_path)
def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
import json
native_toolchain = _native_toolchain_module()
if CORE.using_toolchain_esp_idf:
# Native ESP-IDF derives idedata from the build's compile_commands.json,
# so the configuration must already be compiled.
from esphome.espidf import toolchain as espidf_toolchain
if native_toolchain is not None:
# Native toolchains derive idedata from the build's
# compile_commands.json, so the configuration must already be compiled.
idedata = native_toolchain.get_idedata()
idedata = espidf_toolchain.get_idedata()
if idedata is None:
_LOGGER.error(
"No idedata available; compile the configuration first",
@@ -2032,17 +2009,6 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
from esphome.analyze_memory.cli import MemoryAnalyzerCLI
from esphome.analyze_memory.ram_strings import RamStringsAnalyzer
# Refuse an unsupported toolchain before paying for a full compile
native_toolchain = _native_toolchain_module()
if native_toolchain is None and not CORE.using_toolchain_platformio:
_LOGGER.error(
"analyze-memory is not supported with the '%s' toolchain on %s; "
"re-run with --toolchain platformio",
CORE.toolchain.value if CORE.toolchain else "unresolved",
CORE.target_platform,
)
return 1
# Always compile to ensure fresh data (fast if no changes - just relinks)
exit_code = write_cpp(config)
if exit_code != 0:
@@ -2054,31 +2020,13 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
# Get idedata for analysis
idedata = None
if native_toolchain is not None:
objdump = native_toolchain.get_objdump_path()
readelf = native_toolchain.get_readelf_path()
for tool in (objdump, readelf):
if not tool.is_file():
# The analyzer would silently fall back to host binutils,
# which cannot read the target ELF. clean-all is heavy for
# ESP-IDF, so suggest a recompile first.
_LOGGER.error(
"%s is missing; the toolchain install may be incomplete "
"(recompile, or run 'esphome clean-all' if it persists)",
tool,
)
return 1
objdump_path = str(objdump)
readelf_path = str(readelf)
if CORE.using_toolchain_esp_idf:
from esphome.espidf import toolchain
firmware_elf = native_toolchain.get_elf_path()
if not firmware_elf.is_file():
# The analyzer swallows tool failures, so a missing ELF would
# produce an exit-0 zeroed report
_LOGGER.error(
"%s is missing; compile the configuration first", firmware_elf
)
return 1
objdump_path = str(toolchain.get_objdump_path())
readelf_path = str(toolchain.get_readelf_path())
firmware_elf = toolchain.get_elf_path()
else:
from esphome.platformio import toolchain
+1 -4
View File
@@ -19,7 +19,6 @@ from typing import NamedTuple
from esphome.build_helpers.ccache import ccache_defaults_env
from esphome.build_helpers.ninja import find_ninja
from esphome.build_helpers.pch import ccache_pch_env
from esphome.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path
from esphome.core import EsphomeError, Version
from esphome.framework_helpers import str_to_lst_of_str
@@ -157,6 +156,4 @@ def ccache_env(ccache: str | None) -> dict[str, str]:
"""
if ccache is None:
return {}
env = ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
env.update(ccache_pch_env())
return env
return ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
-329
View File
@@ -1,329 +0,0 @@
"""Native Arduino ESP8266 build driver (the PlatformIO ``run`` equivalent)."""
from __future__ import annotations
import json
import logging
from pathlib import Path
import subprocess
from typing import Any
from esphome.arduino8266 import framework
from esphome.build_helpers.ccache import resolve_ccache_path
from esphome.const import (
CONF_COMPILE_PROCESS_LIMIT,
CONF_ESPHOME,
KEY_CORE,
KEY_FRAMEWORK_VERSION,
)
from esphome.core import CORE, EsphomeError
from esphome.helpers import write_file_if_changed
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
# ESP8266 user RAM (matches upload.maximum_ram_size in every board manifest)
_MAX_RAM_SIZE = 81920
def _warn_ignored_platformio_options() -> None:
"""Warn for component-added platformio options the native build drops.
The consumed set is exported by core/config.py next to the routing that
stores these options, so the two cannot drift; YAML upload_speed never
reaches CORE.platformio_options here.
"""
from esphome.core.config import NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS
consumed = NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS
for key in sorted(CORE.platformio_options or {}):
if key not in consumed:
_LOGGER.warning(
"platformio_options->%s is ignored when building with the "
"native 'arduino' toolchain",
key,
)
_RAM_SECTIONS = (".data", ".rodata", ".bss")
_FLASH_SECTIONS = (".irom0.text", ".text", ".text1", ".data", ".rodata")
def get_build_dir() -> Path:
return CORE.relative_pioenvs_path(CORE.name)
def get_elf_path() -> Path:
return get_build_dir() / "firmware.elf"
def _toolchain_tool(name: str) -> Path:
return framework.toolchain_tool(framework.get_toolchain_path(), name)
def get_factory_firmware_path() -> Path:
"""The image to serial-flash at 0x0 (same bytes as firmware.bin: the
8266 factory copy exists for artifact-contract parity, not content)."""
return get_build_dir() / "firmware.factory.bin"
def get_addr2line_path() -> Path:
return _toolchain_tool("addr2line")
def get_objdump_path() -> Path:
return _toolchain_tool("objdump")
def get_readelf_path() -> Path:
return _toolchain_tool("readelf")
def run_compile(config: ConfigType, verbose: bool) -> int:
from esphome.build_gen import arduino8266 as build_gen
_warn_ignored_platformio_options()
paths = framework.check_and_install(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION])
# Resolved once per build: the resolution probes PATH and spawns the
# runnability check, and three consumers need the same answer
ccache = resolve_ccache_path()
ninja_changed = build_gen.write_project(paths, ccache)
build_dir = get_build_dir()
env = framework.get_build_env(paths.toolchain, ccache)
# Regenerate the compile DB before the build (a pure function of
# build.ninja); skip only when it is at least as fresh as build.ninja
# (an interrupted previous run may have rewritten the manifest without
# regenerating the DB).
compdb = build_dir / "compile_commands.json"
compdb_stamp = build_dir / ".compile_commands.stamp"
ninja_file = build_dir / "build.ninja"
# Freshness rides a stamp: the DB itself is written through
# write_file_if_changed (its mtime feeds get_idedata's cache), so a
# regeneration with identical content would stay "stale" forever
if (
ninja_changed
or not compdb.is_file()
or not compdb_stamp.is_file()
or compdb_stamp.stat().st_mtime < ninja_file.stat().st_mtime
):
_write_compile_commands(paths.ninja, build_dir, env)
compdb_stamp.touch()
cmd = [str(paths.ninja)]
if verbose:
cmd.append("-v")
if jobs := config[CONF_ESPHOME].get(CONF_COMPILE_PROCESS_LIMIT):
cmd += ["-j", str(jobs)]
# Explicit targets, not the default statement: a generator defect that
# drops them fails loudly with "unknown target" instead of a green
# no-op run that leaves stale artifacts in place
targets = ["firmware.factory.bin", "firmware.ota.bin"]
cmd += targets
# A dry-run probe keeps a no-op rebuild quiet: ninja would only print
# "no work to do". A freshly rewritten manifest all but guarantees work,
# so skip the probe (and its full stat pass) on that path. cwd instead
# of -C also drops the "Entering directory" banner on real builds.
skip_build = False
if not ninja_changed:
probe = subprocess.run(
[str(paths.ninja), "-n", *targets],
cwd=build_dir,
env=env,
capture_output=True,
text=True,
check=False,
close_fds=False,
)
if probe.stderr.strip():
# A load-time diagnostic (e.g. "multiple rules generate X")
# flags a generator bug; the skip branch would otherwise
# swallow it forever
_LOGGER.warning("ninja: %s", probe.stderr.strip())
if probe.returncode != 0:
# An unknown target here is the defective-manifest case; fall
# through to the real build so the error prints attributably
_LOGGER.debug("ninja probe failed; running the full build")
skip_build = probe.returncode == 0 and "no work to do" in probe.stdout
if skip_build:
_LOGGER.debug("ninja: nothing to rebuild")
else:
_LOGGER.debug("Running: %s", " ".join(cmd))
rc = subprocess.run(
cmd, cwd=build_dir, env=env, check=False, close_fds=False
).returncode
if rc != 0:
return rc
# ninja already refused a manifest missing the explicit targets above;
# existence covers the remaining hole (a rule that ran but wrote
# elsewhere). The factory/ota copies are what upload and OTA consume.
build_dir_artifacts = (
get_elf_path(),
build_dir / "firmware.bin",
get_factory_firmware_path(),
build_dir / "firmware.ota.bin",
)
for artifact in build_dir_artifacts:
if not artifact.is_file():
_LOGGER.error("Build produced no %s", artifact)
return 1
if not _print_size_summary(build_dir, paths):
# The cause was already warned; name the consequence so a build
# contributing no RAM/Flash metric is visible to CI harnesses
_LOGGER.warning("Firmware size summary unavailable for this build")
from esphome.build_helpers.idedata import warn_if_idedata_missing
warn_if_idedata_missing(lambda: get_idedata(ccache))
return 0
def _write_compile_commands(
ninja_path: Path, build_dir: Path, env: dict[str, str]
) -> None:
compdb = build_dir / "compile_commands.json"
result = subprocess.run(
[str(ninja_path), "-C", str(build_dir), "-t", "compdb", "c", "cxx", "asm"],
env=env,
capture_output=True,
text=True,
check=False,
close_fds=False,
)
if result.returncode != 0:
# Drop any stale database so consumers (IDE integration, clang-tidy,
# the memory analyzer) can't silently read outdated data.
compdb.unlink(missing_ok=True)
raise EsphomeError(f"Could not generate compile_commands.json: {result.stderr}")
try:
entries = json.loads(result.stdout)
except ValueError as err:
compdb.unlink(missing_ok=True)
raise EsphomeError(
f"ninja produced an unparsable compile database: {err} "
f"(output starts {result.stdout[:120]!r})"
) from err
if not entries:
# compdb exits 0 with [] for unknown rule names; a renamed compile
# rule must fail the build, not silently strand every consumer
compdb.unlink(missing_ok=True)
raise EsphomeError(
"ninja produced an empty compile database; the generator's rule "
"names no longer match"
)
# write_file_if_changed keeps the mtime stable on no-op builds so the
# idedata cache in get_idedata() stays valid.
write_file_if_changed(compdb, result.stdout)
def _parse_app_size(build_dir: Path, paths: framework.InstalledPaths) -> int | None:
"""Read the app flash budget (irom0_0_seg length) from the linker script."""
from esphome.build_gen.arduino8266 import get_flash_ld_path
from esphome.components.esp8266.build_surgery import segment_length
# Warnings, not debug: without the app size the Flash summary line is
# dropped and CI's memory-impact extraction loses its flash metric.
ld_path = get_flash_ld_path(build_dir, paths)
try:
ld_text = ld_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as err:
# UnicodeDecodeError: a truncated/corrupt script must degrade to
# the same warning, never abort an already-linked build
_LOGGER.warning("Cannot read linker script for the Flash summary: %s", err)
return None
app_size = segment_length(ld_text, "irom0_0_seg")
if app_size is None:
_LOGGER.warning("irom0_0_seg not found in %s; skipping Flash summary", ld_path)
return None
if app_size == 0:
_LOGGER.warning(
"irom0_0_seg has zero length in %s; skipping Flash summary", ld_path
)
return None
return app_size
def _print_size_summary(build_dir: Path, paths: framework.InstalledPaths) -> bool:
"""Print the PlatformIO-shaped RAM/Flash lines; False when skipped.
The exact shape (including the bar) is parsed by
``script/ci_memory_impact_extract.py``; ``print_size_line`` matches it.
"""
from esphome.build_helpers.size_summary import print_size_line
size_tool = _toolchain_tool("size")
try:
result = subprocess.run(
[str(size_tool), "-A", "-d", str(get_elf_path())],
capture_output=True,
text=True,
check=False,
close_fds=False,
)
except OSError as err:
# The summary is a bonus artifact like idedata; a truncated
# toolchain extraction must not discard an already-linked build
_LOGGER.warning("Could not summarize firmware size: %s", err)
return False
if result.returncode != 0:
_LOGGER.warning("Could not summarize firmware size: %s", result.stderr)
return False
sections: dict[str, int] = {}
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) >= 2 and parts[0].startswith("."):
try:
sections[parts[0]] = int(parts[1])
except ValueError:
# An unparsed RAM/Flash section trips the missing-sections
# guard below, so no total is built on a dropped value
_LOGGER.warning("Unparsable size output for section %s", parts[0])
if missing := set(_RAM_SECTIONS + _FLASH_SECTIONS) - set(sections):
# A defaulted 0 would print a confidently wrong total for CI's metric
_LOGGER.warning(
"Size output is missing section(s) %s; skipping the size summary",
", ".join(sorted(missing)),
)
return False
# Resolve the flash budget before printing anything: a RAM line without
# its Flash line would let CI's memory-impact extraction sum the two
# metrics over different build counts (_parse_app_size already warned).
app_size = _parse_app_size(build_dir, paths)
if not app_size:
return False
ram = sum(sections[s] for s in _RAM_SECTIONS)
flash = sum(sections[s] for s in _FLASH_SECTIONS)
print_size_line("RAM", ram, _MAX_RAM_SIZE)
print_size_line("Flash", flash, app_size)
return True
# Sentinel: "resolve for me"; None is a real value meaning disabled.
_CCACHE_UNRESOLVED: Any = object()
def get_idedata(ccache: str | None = _CCACHE_UNRESOLVED) -> dict | None:
"""Derive idedata from the build's compile_commands.json.
Same contract as ``espidf.toolchain.get_idedata``: the fields IDE
integrations, clang-tidy, and the memory analyzer expect.
"""
from esphome.build_helpers.idedata import load_or_build_idedata
if ccache is _CCACHE_UNRESOLVED:
# Deliberately uncached: env/PATH can change between builds in a
# long-lived host process
ccache = resolve_ccache_path()
return load_or_build_idedata(
get_build_dir() / "compile_commands.json",
get_elf_path(),
# Suffixed so a platformio->arduino->platformio round trip on one
# config never serves the other toolchain's cache shape
CORE.relative_internal_path("idedata", f"{CORE.name}.arduino.json"),
# The compile DB's commands carry the same ccache prefix the ninja
# rules were generated with
launcher=str(ccache) if ccache else None,
)
File diff suppressed because it is too large Load Diff
+1 -14
View File
@@ -87,19 +87,6 @@ def ccache_defaults_env(cache_dir: Path) -> dict[str, str]:
"CCACHE_DIR": str(cache_dir),
"CCACHE_NOHASHDIR": "true",
"CCACHE_DEPEND": "1",
# A user value wins via the filter below
"CCACHE_BASEDIR": effective_ccache_basedir(),
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
}
return {k: v for k, v in defaults.items() if k not in os.environ}
def effective_ccache_basedir() -> str:
"""The prefix ccache rewrites out of hashed paths: a user CCACHE_BASEDIR
wins, else the resolved build path (matching ccache_defaults_env)."""
from esphome.core import CORE
raw = os.environ.get("CCACHE_BASEDIR")
if raw is not None and Path(raw).is_absolute() and len(Path(raw).parts) > 1:
return raw
# Unset or degenerate ("", "/", relative): fall back to the build path
return str(Path(CORE.build_path).resolve())
+6 -61
View File
@@ -11,7 +11,6 @@ consumers (IDE integration, clang-tidy) expect:
from __future__ import annotations
from collections.abc import Callable
import json
import logging
import os
@@ -22,8 +21,6 @@ import subprocess
from esphome.core import EsphomeError
from esphome.helpers import write_file
_LOGGER = logging.getLogger(__name__)
# Everything idedata generation may raise after a successful link; idedata
# is a bonus artifact, so consumers warn instead of failing the build
IDEDATA_BEST_EFFORT_ERRORS = (
@@ -34,36 +31,13 @@ IDEDATA_BEST_EFFORT_ERRORS = (
ValueError,
)
_LOGGER = logging.getLogger(__name__)
def warn_if_idedata_missing(get_idedata: Callable[[], dict | None]) -> None:
"""Run an idedata generator, downgrading any failure to a warning.
Shared by the native backends: the firmware already built, so a missing
or broken idedata must not fail a successful build.
"""
try:
if get_idedata() is None:
_LOGGER.warning("No idedata was generated for this build")
except IDEDATA_BEST_EFFORT_ERRORS as err:
_LOGGER.warning(
"Could not generate idedata: %s (IDE, clang-tidy, and "
"memory-analysis data will be unavailable for this build)",
err,
)
if isinstance(err, (EsphomeError, OSError)):
# Routine environmental failures keep the detail at debug
_LOGGER.debug("Idedata failure detail", exc_info=True)
else:
# LookupError/ValueError/RuntimeError smell like a parsing bug;
# a permanently masked traceback would hide it on every build
_LOGGER.warning("Idedata failure detail", exc_info=True)
# C++ translation-unit suffixes.
CXX_SOURCE_SUFFIXES = (".cpp", ".cc", ".cxx")
# C++ translation-unit suffixes used to identify ESPHome source files.
_CXX_SUFFIXES = (".cpp", ".cc")
# Suffixes of input/output files that appear bare on the command line (and so
# must not be mistaken for compiler flags).
_INPUT_FILE_SUFFIXES = (*CXX_SOURCE_SUFFIXES, ".c", ".o", ".S", ".s")
_INPUT_FILE_SUFFIXES = (*_CXX_SUFFIXES, ".c", ".o", ".S", ".s")
# Path marker identifying an ESPHome source translation unit.
_ESPHOME_SRC_MARKER = "/src/esphome/"
@@ -72,7 +46,7 @@ def _is_esphome_src(file: str) -> bool:
"""Whether ``file`` is an ESPHome C++ translation unit; normalized to
``/`` first since Windows compile DBs use backslashes."""
return _ESPHOME_SRC_MARKER in file.replace("\\", "/") and file.endswith(
CXX_SOURCE_SUFFIXES
_CXX_SUFFIXES
)
@@ -147,7 +121,7 @@ def _pick_entry(entries: list[dict]) -> dict:
if _is_esphome_src(entry["file"]):
return entry
for entry in entries:
if entry["file"].endswith(CXX_SOURCE_SUFFIXES):
if entry["file"].endswith(_CXX_SUFFIXES):
return entry
raise ValueError("no C++ translation unit found in compile_commands.json")
@@ -161,15 +135,6 @@ def _is_launcher(token: str) -> bool:
return Path(token).stem.lower() in _LAUNCHER_STEMS
def is_joined_include(tok: str) -> bool:
"""The joined ``-includefoo.h`` spelling; excludes clang's -include-pch."""
return (
tok.startswith("-include")
and tok != "-include"
and not tok.startswith("-include-")
)
def parse_entry(
entry: dict, launcher: str | None = None
) -> tuple[str, list[str], list[str], list[str]]:
@@ -204,23 +169,11 @@ def parse_entry(
defines: list[str] = []
includes: list[str] = []
cxx_flags: list[str] = []
unresolved_force_includes: list[str] = []
it = iter(tokens[1:])
for tok in it:
if tok in ("-c", "-o"):
next(it, None) # drop the flag and its argument (input/output)
elif tok == "-include" or is_joined_include(tok):
# Re-anchor only names next to the compile (the pch); a name
# meant for the -I chain must stay untouched
raw = next(it, "") if tok == "-include" else tok[len("-include") :]
if not raw:
_LOGGER.warning("Dropping -include with no argument")
elif Path(resolved := _include(raw)).is_file():
cxx_flags.extend(("-include", resolved))
else:
unresolved_force_includes.append(raw)
cxx_flags.extend(("-include", raw))
elif tok.startswith("-D"):
# ``.strip()`` handles tokens like ``-D CONFIGURED=1`` (a single
# quoted arg with a space after -D) that some flags arrive as.
@@ -239,14 +192,6 @@ def parse_entry(
pass # input/output files
else:
cxx_flags.append(tok)
for raw in unresolved_force_includes:
# A deleted build artifact would otherwise surface only downstream
if not any((Path(inc) / raw).is_file() for inc in includes):
_LOGGER.warning(
"-include %s found neither next to the compile nor on the "
"include path; cached idedata may not resolve it",
raw,
)
return cxx_path, defines, includes, cxx_flags
-178
View File
@@ -1,178 +0,0 @@
"""Shared precompiled-header policy for the build backends.
The prefix either mirrors the TUs' own force-includes (ESP8266) or is a
curated core-header set (ESP-IDF). ``esphome: includes:`` sources receive
it too; Arduino.h visibility there is intended (esphome#8693).
"""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
import hashlib
import logging
import os
from pathlib import Path
import posixpath
import re
import stat
from esphome.build_helpers.ccache import parse_enable_env
_DOMAIN = "pch"
@dataclass
class _PCHData:
emitted: bool = False
def _pch_data() -> _PCHData:
from esphome.core import CORE
if _DOMAIN not in CORE.data:
CORE.data[_DOMAIN] = _PCHData()
return CORE.data[_DOMAIN]
def mark_pch_emitted() -> None:
"""Record that this build's consumers reference the pch."""
_pch_data().emitted = True
_LOGGER = logging.getLogger(__name__)
# The header and its .gch/.sum sidecars live in the build directory.
PCH_HEADER_NAME = "esphome_pch.h"
# Every artifact the pch machinery can leave behind, for cleanup.
PCH_ARTIFACT_NAMES = (
PCH_HEADER_NAME,
f"{PCH_HEADER_NAME}.gch",
f"{PCH_HEADER_NAME}.gch.sum",
f"{PCH_HEADER_NAME}.gch.failed",
)
# The core defines header every backend anchors its prefix on.
PCH_CORE_HEADER = "esphome/core/defines.h"
# ccache cannot hash through a .gch; CCACHE_PCH_EXTSUM makes it hash the
# .sum sidecar instead of the .gch bytes, which are not reproducible.
# Keep in sync with the literals in platformio/pch.py.script.
_CCACHE_PCH_ENV = {
"CCACHE_SLOPPINESS": "pch_defines,time_macros",
"CCACHE_PCH_EXTSUM": "true",
}
# Both include forms: an angle include resolving under src/ must enter the
# digest too; ones that do not resolve simply end the walk
_INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE)
def pch_enabled() -> bool:
"""Precompiled-header knob: default on, ``ESPHOME_PCH_ENABLE=0`` opts out."""
return parse_enable_env("ESPHOME_PCH_ENABLE") is not False
def ccache_pch_env() -> dict[str, str]:
"""Settings ccache needs to cache compiles that consume the .gch;
empty unless this build actually emitted one. User-set values win.
Native backends export these process-wide; only time_macros affects
non-pch TUs."""
if not (pch_enabled() and _pch_data().emitted):
return {}
extsum = os.environ.get("CCACHE_PCH_EXTSUM")
if extsum is not None and extsum.strip().lower() not in ("1", "true", "yes", "on"):
# ccache then hashes the non-reproducible .gch bytes: permanent misses
_LOGGER.warning("CCACHE_PCH_EXTSUM=%s disables pch caching", extsum)
env = {k: v for k, v in _CCACHE_PCH_ENV.items() if k not in os.environ}
user_sloppiness = os.environ.get("CCACHE_SLOPPINESS")
if user_sloppiness is not None and (
missing := [
t
for t in ("pch_defines", "time_macros")
if t not in {tok.strip() for tok in user_sloppiness.split(",")}
]
):
# Without these ccache declines every pch-consuming compile
env["CCACHE_SLOPPINESS"] = ",".join((user_sloppiness, *missing))
_LOGGER.warning(
"Adding %s to CCACHE_SLOPPINESS so ccache can cache compiles "
"that use the precompiled header",
",".join(missing),
)
return env
def pch_extra_scripts() -> list[str]:
"""The extra_scripts entries a PlatformIO platform registers for the
pch; empty when disabled (the script itself has no enable check)."""
return ["post:pch.py"] if pch_enabled() else []
def pch_header_text(include_headers: Iterable[str]) -> str:
"""The prefix-header source: exactly these includes, in order."""
return "".join(f'#include "{name}"\n' for name in include_headers)
def _resolves(path: Path) -> bool:
"""False when missing; other stat failures propagate (identity unknown,
unlike is_file(), which would silently drop the header)."""
try:
return stat.S_ISREG(path.stat().st_mode)
except (FileNotFoundError, NotADirectoryError):
return False
def _include_closure(src_dir: Path, roots: Iterable[str]) -> dict[str, bytes]:
"""Include closure of ``roots``: src-relative name -> contents.
Resolution mirrors the compiler (includer's dir, then src root); names
outside ``src_dir`` end the walk and are versioned by the caller. No
#ifdef evaluation: over-approximating is the safe direction.
"""
seen: dict[str, bytes] = {}
stack: list[tuple[str, str]] = [(name, "") for name in roots]
while stack:
name, from_dir = stack.pop()
for candidate in (f"{from_dir}/{name}" if from_dir else name, name):
rel = posixpath.normpath(candidate)
if not rel.startswith("..") and _resolves(src_dir / rel):
break
else:
continue
if rel in seen:
continue
try:
data = (src_dir / rel).read_bytes()
except OSError as err:
# A marker would truncate the transitive walk; fail closed
_LOGGER.warning("Could not read %s for the pch checksum: %s", rel, err)
raise
seen[rel] = data
parent = posixpath.dirname(rel)
stack.extend(
# surrogateescape: a non-UTF-8 name just fails to resolve
(inc.decode(errors="surrogateescape"), parent)
for inc in _INCLUDE_RE.findall(data)
)
return seen
def pch_checksum(
src_dir: Path, include_headers: Iterable[str], extra: Iterable[str]
) -> str:
"""Digest standing in for the .gch in ccache's hash: the include closure
of the prefix header plus caller-supplied identity strings (versioned
install paths, flags). Raises OSError when a header's identity cannot
be established at all; callers must then compile without a pch."""
digest = hashlib.sha256()
closure = _include_closure(src_dir, include_headers)
for name in sorted(closure):
digest.update(name.encode(errors="surrogateescape"))
digest.update(closure[name])
digest.update(b"\0")
for item in extra:
digest.update(item.encode(errors="surrogateescape"))
digest.update(b"\0")
return digest.hexdigest()
+48 -4
View File
@@ -77,6 +77,7 @@ service APIConnection {
rpc serial_proxy_set_modem_pins(SerialProxySetModemPinsRequest) returns (void) {}
rpc serial_proxy_get_modem_pins(SerialProxyGetModemPinsRequest) returns (void) {}
rpc serial_proxy_request(SerialProxyRequest) returns (void) {}
rpc serial_proxy_set_mode(SerialProxySetModeRequest) returns (void) {}
}
@@ -2696,6 +2697,21 @@ message InfraredRFReceiveEvent {
repeated sint32 timings = 3 [packed = true, (container_pointer_no_template) = "std::vector<int32_t>"]; // Raw timings in microseconds (zigzag-encoded): alternating mark/space periods
}
// Sent only to the client that issued an InfraredRFTransmitRawTimingsRequest, once the
// transmitter reports that the transmission (all repeats) has finished, or immediately with
// success=false if it could not be started (unknown key, no transmitter, no or invalid timings).
// Lets clients pace requests instead of estimating durations (since API 1.18)
message InfraredRFTransmitCompleteResponse {
option (id) = 153;
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY";
option (no_delay) = true;
uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"];
fixed32 key = 2 [(force) = true]; // Key of the transmitter entity from the request
bool success = 3; // false if the transmit never started
}
// ==================== RADIO FREQUENCY ====================
// Lists available radio frequency entity instances
@@ -2726,7 +2742,8 @@ enum SerialProxyParity {
SERIAL_PROXY_PARITY_ODD = 2;
}
// Configure UART parameters for a serial proxy instance
// Configure UART parameters for a serial proxy instance. Only the subscribed client may
// configure the port; others are refused with PORT_IN_USE (since API 1.17).
message SerialProxyConfigureRequest {
option (id) = 138;
option (source) = SOURCE_CLIENT;
@@ -2752,7 +2769,8 @@ message SerialProxyDataReceived {
bytes data = 2; // Raw data received from the serial device
}
// Write data to a serial device
// Write data to a serial device. Only the subscribed client may write; writes from
// others are ignored (since API 1.17).
message SerialProxyWriteRequest {
option (id) = 140;
option (source) = SOURCE_CLIENT;
@@ -2763,7 +2781,8 @@ message SerialProxyWriteRequest {
bytes data = 2; // Raw data to write to the serial device
}
// Set modem control pin states (RTS and DTR)
// Set modem control pin states (RTS and DTR). Only the subscribed client may set them;
// others are refused with PORT_IN_USE (since API 1.17).
message SerialProxySetModemPinsRequest {
option (id) = 141;
option (source) = SOURCE_CLIENT;
@@ -2802,6 +2821,7 @@ enum SerialProxyRequestType {
// error the device answers with INVALID_ARGUMENT.
SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3; // Acknowledges a SerialProxyConfigureRequest
SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4; // Acknowledges a SerialProxySetModemPinsRequest
SERIAL_PROXY_REQUEST_TYPE_SET_MODE = 5; // Acknowledges a SerialProxySetModeRequest (since API 1.17)
}
enum SerialProxyStatus {
@@ -2814,7 +2834,8 @@ enum SerialProxyStatus {
SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6; // Invalid instance index or parameter value
}
// Generic request message for simple serial proxy operations
// Generic request message for simple serial proxy operations. FLUSH requires an active
// subscription; it is refused with PORT_IN_USE otherwise (since API 1.17).
message SerialProxyRequest {
option (id) = 144;
option (source) = SOURCE_CLIENT;
@@ -2838,6 +2859,29 @@ message SerialProxyRequestResponse {
string error_message = 4; // Additional detail on failure (optional)
}
// How a port treats the bytes passing through it. RAW is a plain byte pipe; PROTOCOL
// activates the port's protocol-aware tap (if one is configured), letting it observe
// traffic and inject protocol bytes such as acknowledgements. Which protocol the tap
// speaks is a property of the device configuration, discoverable from the tap
// component's own API surface. A client that is about to flash firmware selects RAW
// first, which definitively disables that injection.
enum SerialProxyMode {
SERIAL_PROXY_MODE_RAW = 0;
SERIAL_PROXY_MODE_PROTOCOL = 1;
}
// Only the subscribed client may change the mode; any other caller -- including one that
// never subscribed -- is refused with PORT_IN_USE. PROTOCOL is refused with NOT_SUPPORTED
// when the port has no protocol-aware tap configured.
message SerialProxySetModeRequest {
option (id) = 152;
option (source) = SOURCE_CLIENT;
option (ifdef) = "USE_SERIAL_PROXY";
uint32 instance = 1;
SerialProxyMode mode = 2;
}
// ==================== BLUETOOTH CONNECTION PARAMS ====================
message BluetoothSetConnectionParamsRequest {
option (id) = 145;
+39 -4
View File
@@ -1516,6 +1516,18 @@ uint16_t APIConnection::try_send_event_info(EntityBase *entity, APIConnection *c
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg) {
#ifdef USE_DEVICES
const uint32_t device_id = msg.device_id;
#else
const uint32_t device_id = 0;
#endif
// Register before perform(): blocking transmitters report completion from inside it, and the
// non-blocking RMT path flushes the previous frame's completion there
const bool want_reply = this->client_supports_api_version(1, 18);
if (want_reply) {
this->parent_->register_pending_ir_rf_transmit(device_id, msg.key, this);
}
bool started = false;
// Dispatch by key: infrared entities are checked first, then radio frequency entities.
// The key is unique across all entity instances on a device, so at most one lookup will succeed.
#ifdef USE_INFRARED
@@ -1525,8 +1537,7 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF
call.set_carrier_frequency(msg.carrier_frequency);
call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_);
call.set_repeat_count(msg.repeat_count);
call.perform();
return;
started = call.perform();
}
#endif
#ifdef USE_RADIO_FREQUENCY
@@ -1537,9 +1548,12 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF
call.set_modulation(static_cast<radio_frequency::RadioFrequencyModulation>(msg.modulation));
call.set_repeat_count(msg.repeat_count);
call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_);
call.perform();
started = call.perform();
}
#endif
if (want_reply && !started) {
this->parent_->fail_pending_ir_rf_transmit(device_id, msg.key, this);
}
}
#endif
@@ -1551,6 +1565,13 @@ void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent
ESP_LOGV(TAG, "IR/RF event dropped, TCP buffer full");
}
}
void APIConnection::send_infrared_rf_transmit_complete_response(const InfraredRFTransmitCompleteResponse &msg) {
if (!this->send_message(msg)) {
// a lost reply stalls the client's pacing until the server side expiry
API_LOG_MSG_DROPPED(TAG, "IR/RF transmit complete");
}
}
#endif
#ifdef USE_SERIAL_PROXY
@@ -1661,6 +1682,7 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
break;
case enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE:
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS:
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE:
// Response-only discriminators; never valid in a request
ESP_LOGW(TAG, "Response-only serial proxy request type: %" PRIu32, static_cast<uint32_t>(msg.type));
status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
@@ -1673,6 +1695,19 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
send_serial_proxy_ack(this, msg.instance, msg.type, status);
}
void APIConnection::on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg) {
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE,
enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT);
return;
}
serial_proxy::SerialProxyResult result = proxies[msg.instance]->set_mode_from_client(this, msg.mode);
send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE,
serial_proxy_result_to_status(result));
}
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) {
if (!this->send_message(msg)) {
ESP_LOGV(TAG, "Serial proxy data dropped, TCP buffer full");
@@ -1799,7 +1834,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
HelloResponse resp;
resp.api_version_major = 1;
resp.api_version_minor = 16;
resp.api_version_minor = 18;
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise
resp.server_info = ESPHOME_VERSION_REF;
resp.name = StringRef(App.get_name());
+2
View File
@@ -236,6 +236,7 @@ class APIConnection final : public APIServerConnectionBase {
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg);
void send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg);
void send_infrared_rf_transmit_complete_response(const InfraredRFTransmitCompleteResponse &msg);
#endif
#ifdef USE_SERIAL_PROXY
@@ -244,6 +245,7 @@ class APIConnection final : public APIServerConnectionBase {
void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg);
void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg);
void on_serial_proxy_request(const SerialProxyRequest &msg);
void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &msg);
void send_serial_proxy_data(const SerialProxyDataReceived &msg);
#endif
+31
View File
@@ -4082,6 +4082,24 @@ InfraredRFReceiveEvent::calculate_size() const {
}
return size;
}
uint8_t *InfraredRFTransmitCompleteResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
#ifdef USE_DEVICES
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->device_id);
#endif
ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key);
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 3, this->success);
return pos;
}
uint32_t InfraredRFTransmitCompleteResponse::calculate_size() const {
uint32_t size = 0;
#ifdef USE_DEVICES
size += ProtoSize::calc_uint32(1, this->device_id);
#endif
size += 5;
size += ProtoSize::calc_bool(1, this->success);
return size;
}
#endif
#ifdef USE_RADIO_FREQUENCY
uint8_t *ListEntitiesRadioFrequencyResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
@@ -4253,6 +4271,19 @@ uint32_t SerialProxyRequestResponse::calculate_size() const {
size += ProtoSize::calc_length(1, this->error_message.size());
return size;
}
bool SerialProxySetModeRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) {
case 1:
this->instance = value;
break;
case 2:
this->mode = static_cast<enums::SerialProxyMode>(value);
break;
default:
return false;
}
return true;
}
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
+41
View File
@@ -356,6 +356,7 @@ enum SerialProxyRequestType : uint32_t {
SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2,
SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3,
SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4,
SERIAL_PROXY_REQUEST_TYPE_SET_MODE = 5,
};
enum SerialProxyStatus : uint32_t {
SERIAL_PROXY_STATUS_OK = 0,
@@ -366,6 +367,10 @@ enum SerialProxyStatus : uint32_t {
SERIAL_PROXY_STATUS_PORT_IN_USE = 5,
SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6,
};
enum SerialProxyMode : uint32_t {
SERIAL_PROXY_MODE_RAW = 0,
SERIAL_PROXY_MODE_PROTOCOL = 1,
};
#endif
} // namespace enums
@@ -3236,6 +3241,26 @@ class InfraredRFReceiveEvent final : public ProtoMessage {
protected:
};
class InfraredRFTransmitCompleteResponse final : public ProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 153;
static constexpr uint8_t ESTIMATED_SIZE = 11;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("infrared_rf_transmit_complete_response"); }
#endif
#ifdef USE_DEVICES
uint32_t device_id{0};
#endif
uint32_t key{0};
bool success{false};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
};
#endif
#ifdef USE_RADIO_FREQUENCY
class ListEntitiesRadioFrequencyResponse final : public InfoResponseProtoMessage {
@@ -3403,6 +3428,22 @@ class SerialProxyRequestResponse final : public ProtoMessage {
protected:
};
class SerialProxySetModeRequest final : public ProtoDecodableMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 152;
static constexpr uint8_t ESTIMATED_SIZE = 6;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("serial_proxy_set_mode_request"); }
#endif
uint32_t instance{0};
enums::SerialProxyMode mode{};
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
};
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage {
+27
View File
@@ -854,6 +854,8 @@ template<> const char *proto_enum_to_string<enums::SerialProxyRequestType>(enums
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_CONFIGURE");
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS:
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS");
case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODE:
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODE");
default:
return ESPHOME_PSTR("UNKNOWN");
}
@@ -878,6 +880,16 @@ template<> const char *proto_enum_to_string<enums::SerialProxyStatus>(enums::Ser
return ESPHOME_PSTR("UNKNOWN");
}
}
template<> const char *proto_enum_to_string<enums::SerialProxyMode>(enums::SerialProxyMode value) {
switch (value) {
case enums::SERIAL_PROXY_MODE_RAW:
return ESPHOME_PSTR("SERIAL_PROXY_MODE_RAW");
case enums::SERIAL_PROXY_MODE_PROTOCOL:
return ESPHOME_PSTR("SERIAL_PROXY_MODE_PROTOCOL");
default:
return ESPHOME_PSTR("UNKNOWN");
}
}
#endif
const char *HelloRequest::dump_to(DumpBuffer &out) const {
@@ -2728,6 +2740,15 @@ const char *InfraredRFReceiveEvent::dump_to(DumpBuffer &out) const {
}
return out.c_str();
}
const char *InfraredRFTransmitCompleteResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("InfraredRFTransmitCompleteResponse"));
#ifdef USE_DEVICES
dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
#endif
dump_field(out, ESPHOME_PSTR("key"), this->key);
dump_field(out, ESPHOME_PSTR("success"), this->success);
return out.c_str();
}
#endif
#ifdef USE_RADIO_FREQUENCY
const char *ListEntitiesRadioFrequencyResponse::dump_to(DumpBuffer &out) const {
@@ -2805,6 +2826,12 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("error_message"), this->error_message);
return out.c_str();
}
const char *SerialProxySetModeRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxySetModeRequest"));
dump_field(out, ESPHOME_PSTR("instance"), this->instance);
dump_field(out, ESPHOME_PSTR("mode"), static_cast<enums::SerialProxyMode>(this->mode));
return out.c_str();
}
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const {
@@ -712,6 +712,17 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
this->on_device_capabilities_request();
break;
}
#ifdef USE_SERIAL_PROXY
case SerialProxySetModeRequest::MESSAGE_TYPE: {
SerialProxySetModeRequest msg;
msg.decode(msg_data, msg_size);
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_serial_proxy_set_mode_request"), msg);
#endif
this->on_serial_proxy_set_mode_request(msg);
break;
}
#endif
default:
break;
}
+3
View File
@@ -235,6 +235,9 @@ class APIServerConnectionBase {
void on_serial_proxy_request(const SerialProxyRequest &value){};
#endif
#ifdef USE_SERIAL_PROXY
void on_serial_proxy_set_mode_request(const SerialProxySetModeRequest &value){};
#endif
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){};
#endif
+90
View File
@@ -193,6 +193,9 @@ void APIServer::remove_client_(uint8_t client_index) {
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
this->unregister_active_action_calls_for_connection(client.get());
#endif
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
this->unregister_pending_ir_rf_transmits_for_connection_(client.get());
#endif
ESP_LOGV(TAG, "Remove connection %s", client->get_name());
@@ -417,6 +420,93 @@ void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_
for (auto &c : this->active_clients())
c->send_infrared_rf_receive_event(resp);
}
// Safety net for transmitters that never report completion (for example a failed component)
static constexpr uint32_t IR_RF_TRANSMIT_TIMEOUT_MS = 30000;
static constexpr char IR_RF_TRANSMIT_TIMEOUT[] = "ir_rf_tx";
void APIServer::register_pending_ir_rf_transmit(uint32_t device_id, uint32_t key, APIConnection *conn) {
if (this->pending_ir_rf_count_ == this->pending_ir_rf_transmits_.size()) {
// only an unpaced client gets here; its oldest request is answered as not started
this->complete_pending_ir_rf_transmit_(0, false);
}
this->pending_ir_rf_transmits_[this->pending_ir_rf_count_++] = {device_id, key, App.get_loop_component_start_time(),
conn};
if (this->pending_ir_rf_count_ == 1) {
this->set_timeout(IR_RF_TRANSMIT_TIMEOUT, IR_RF_TRANSMIT_TIMEOUT_MS,
[this]() { this->expire_pending_ir_rf_transmits_(); });
}
}
void APIServer::fail_pending_ir_rf_transmit(uint32_t device_id, uint32_t key, APIConnection *conn) {
auto &pending = this->pending_ir_rf_transmits_;
for (size_t i = this->pending_ir_rf_count_; i-- > 0;) {
if (pending[i].connection == conn && pending[i].key == key && pending[i].device_id == device_id) {
this->complete_pending_ir_rf_transmit_(i, false);
return;
}
}
}
void APIServer::send_infrared_rf_transmit_complete(const EntityBase &entity) {
uint32_t device_id = 0;
#ifdef USE_DEVICES
device_id = entity.get_device_id();
#endif
const uint32_t key = entity.get_object_id_hash();
auto &pending = this->pending_ir_rf_transmits_;
for (size_t i = 0; i < this->pending_ir_rf_count_; i++) {
if (pending[i].key == key && pending[i].device_id == device_id) {
this->complete_pending_ir_rf_transmit_(i, true);
return;
}
}
}
void APIServer::complete_pending_ir_rf_transmit_(size_t index, bool success) {
const auto &entry = this->pending_ir_rf_transmits_[index];
InfraredRFTransmitCompleteResponse resp{};
#ifdef USE_DEVICES
resp.device_id = entry.device_id;
#endif
resp.key = entry.key;
resp.success = success;
entry.connection->send_infrared_rf_transmit_complete_response(resp);
this->erase_pending_ir_rf_transmit_(index);
}
void APIServer::erase_pending_ir_rf_transmit_(size_t index) {
auto &pending = this->pending_ir_rf_transmits_;
for (size_t i = index + 1; i < this->pending_ir_rf_count_; i++) {
pending[i - 1] = pending[i];
}
this->pending_ir_rf_count_--;
}
void APIServer::unregister_pending_ir_rf_transmits_for_connection_(APIConnection *conn) {
auto &pending = this->pending_ir_rf_transmits_;
uint8_t kept = 0;
for (size_t i = 0; i < this->pending_ir_rf_count_; i++) {
if (pending[i].connection != conn) {
pending[kept++] = pending[i];
}
}
this->pending_ir_rf_count_ = kept;
}
// A timer firing on an empty list is harmless, so nothing cancels it
void APIServer::expire_pending_ir_rf_transmits_() {
const uint32_t now = App.get_loop_component_start_time();
auto &pending = this->pending_ir_rf_transmits_;
while (this->pending_ir_rf_count_ != 0 && now - pending[0].registered_ms >= IR_RF_TRANSMIT_TIMEOUT_MS) {
ESP_LOGW(TAG, "IR/RF transmit %" PRIu32 " never reported completion", pending[0].key);
this->complete_pending_ir_rf_transmit_(0, false);
}
if (this->pending_ir_rf_count_ != 0) {
this->set_timeout(IR_RF_TRANSMIT_TIMEOUT, IR_RF_TRANSMIT_TIMEOUT_MS - (now - pending[0].registered_ms),
[this]() { this->expire_pending_ir_rf_transmits_(); });
}
}
#endif
#ifdef USE_ALARM_CONTROL_PANEL
+22
View File
@@ -196,6 +196,10 @@ class APIServer final : public Component,
#endif
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector<int32_t> *timings);
// Completion replies for InfraredRFTransmitRawTimingsRequest (API 1.18+); register before perform()
void register_pending_ir_rf_transmit(uint32_t device_id, uint32_t key, APIConnection *conn);
void fail_pending_ir_rf_transmit(uint32_t device_id, uint32_t key, APIConnection *conn);
void send_infrared_rf_transmit_complete(const EntityBase &entity);
#endif
bool is_connected() const { return this->api_connection_count_ != 0; }
@@ -342,6 +346,24 @@ class APIServer final : public Component,
uint32_t next_action_call_id_{1}; // Counter for generating unique action_call_ids
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES
#endif
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
struct PendingIrRfTransmit {
uint32_t device_id;
uint32_t key;
uint32_t registered_ms;
APIConnection *connection;
};
// FIFO per entity: entities forward only completions of frames they submitted, and each
// completion pops the oldest match, so entries are never reordered. One slot per connection:
// a pacing client has at most one transmit outstanding. Unlike action calls, transmits share
// one named expiry timer, since a scheduler item per entry would churn the heap.
std::array<PendingIrRfTransmit, MAX_API_CONNECTIONS> pending_ir_rf_transmits_{};
uint8_t pending_ir_rf_count_{0};
void complete_pending_ir_rf_transmit_(size_t index, bool success);
void erase_pending_ir_rf_transmit_(size_t index);
void unregister_pending_ir_rf_transmits_for_connection_(APIConnection *conn);
void expire_pending_ir_rf_transmits_();
#endif
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
struct PendingActionResponse {
uint32_t call_id;
@@ -58,6 +58,9 @@ esp_err_t AudioReader::add_sink(const std::weak_ptr<ring_buffer::RingBuffer> &ou
if (current_audio_file_ != nullptr) {
// A transfer buffer isn't ncessary for a local file
this->file_ring_buffer_ = output_ring_buffer.lock();
if (this->file_ring_buffer_ == nullptr) {
return ESP_ERR_INVALID_STATE;
}
return ESP_OK;
}
@@ -51,14 +51,14 @@ void AudioTransferBuffer::increase_buffer_length(size_t bytes) { this->buffer_le
void AudioTransferBuffer::clear_buffered_data() {
this->buffer_length_ = 0;
if (this->ring_buffer_.use_count() > 0) {
if (this->ring_buffer_ != nullptr) {
this->ring_buffer_->reset();
}
}
void AudioSinkTransferBuffer::clear_buffered_data() {
this->buffer_length_ = 0;
if (this->ring_buffer_.use_count() > 0) {
if (this->ring_buffer_ != nullptr) {
this->ring_buffer_->reset();
}
#ifdef USE_SPEAKER
@@ -69,7 +69,7 @@ void AudioSinkTransferBuffer::clear_buffered_data() {
}
bool AudioTransferBuffer::has_buffered_data() const {
if (this->ring_buffer_.use_count() > 0) {
if (this->ring_buffer_ != nullptr) {
return ((this->ring_buffer_->available() > 0) || (this->available() > 0));
}
return (this->available() > 0);
@@ -144,7 +144,7 @@ size_t AudioSourceTransferBuffer::transfer_data_from_source(TickType_t ticks_to_
size_t bytes_to_read = AudioTransferBuffer::free();
size_t bytes_read = 0;
if (bytes_to_read > 0) {
if (this->ring_buffer_.use_count() > 0) {
if (this->ring_buffer_ != nullptr) {
bytes_read = this->ring_buffer_->read((void *) this->get_buffer_end(), bytes_to_read, ticks_to_wait);
}
@@ -161,7 +161,7 @@ size_t AudioSinkTransferBuffer::transfer_data_to_sink(TickType_t ticks_to_wait,
bytes_written = this->speaker_->play(this->data_start_, this->available(), ticks_to_wait);
} else
#endif
if (this->ring_buffer_.use_count() > 0) {
if (this->ring_buffer_ != nullptr) {
bytes_written =
this->ring_buffer_->write_without_replacement((void *) this->data_start_, this->available(), ticks_to_wait);
} else if (this->sink_callback_ != nullptr) {
@@ -186,7 +186,7 @@ bool AudioSinkTransferBuffer::has_buffered_data() const {
return (this->speaker_->has_buffered_data() || (this->available() > 0));
}
#endif
if (this->ring_buffer_.use_count() > 0) {
if (this->ring_buffer_ != nullptr) {
return ((this->ring_buffer_->available() > 0) || (this->available() > 0));
}
return (this->available() > 0);
@@ -44,7 +44,7 @@ bool DeepSleepComponent::prepare_to_sleep_() {
this->status_set_warning();
ESP_LOGV(TAG, "Waiting for pin to switch state to enter deep sleep...");
}
this->next_enter_deep_sleep_ = true;
this->defer_sleep_();
return false;
}
}
@@ -17,6 +17,7 @@ void DeepSleepComponent::setup() {
void DeepSleepComponent::schedule_sleep_() {
this->next_enter_deep_sleep_ = false;
this->disable_loop();
const optional<uint32_t> run_duration = get_run_duration_();
if (run_duration.has_value()) {
ESP_LOGI(TAG, "Scheduling in %" PRIu32 " ms", *run_duration);
@@ -45,7 +46,7 @@ void DeepSleepComponent::loop() {
void DeepSleepComponent::begin_sleep(bool manual) {
if (this->prevent_ && !manual) {
this->next_enter_deep_sleep_ = true;
this->defer_sleep_();
return;
}
@@ -190,6 +190,11 @@ class DeepSleepComponent final : public Component {
void schedule_sleep_();
bool should_teardown_();
void defer_sleep_() {
this->next_enter_deep_sleep_ = true;
this->enable_loop();
}
#ifdef USE_BK72XX
bool pin_prevents_sleep_(WakeUpPinItem &pin_item) const;
bool get_real_pin_state_(InternalGPIOPin &pin) const { return (pin.digital_read() ^ pin.is_inverted()); }
@@ -100,7 +100,7 @@ bool DeepSleepComponent::prepare_to_sleep_() {
this->status_set_warning();
ESP_LOGW(TAG, "Waiting for wakeup pin state change");
}
this->next_enter_deep_sleep_ = true;
this->defer_sleep_();
return false;
}
return true;
+3 -2
View File
@@ -153,13 +153,14 @@ bool ES7210::configure_mic_gain_() {
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC2_GAIN_REG44, 0x0f, regv));
// Configure mic 3
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00));
// MIC3 uses the ADC3/4 and MIC3/4 clock domains (bits 2 and 4), not the MIC1/2 domains.
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x15, 0x00));
ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC34_POWER_REG4C, 0x00));
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x10, 0x10));
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC3_GAIN_REG45, 0x0f, regv));
// Configure mic 4
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x0b, 0x00));
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_CLOCK_OFF_REG01, 0x15, 0x00));
ES7210_ERROR_CHECK(this->write_byte(ES7210_MIC34_POWER_REG4C, 0x00));
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x10, 0x10));
ES7210_ERROR_CHECK(this->es7210_update_reg_bit_(ES7210_MIC4_GAIN_REG46, 0x0f, regv));
+41 -191
View File
@@ -3,10 +3,8 @@ from pathlib import Path
import platform
import re
import subprocess
import time
from typing import Any
from esphome.build_helpers.pch import pch_extra_scripts
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import (
@@ -16,7 +14,6 @@ from esphome.const import (
CONF_FRAMEWORK,
CONF_PLATFORM_VERSION,
CONF_SOURCE,
CONF_TOOLCHAIN,
CONF_VERSION,
KEY_CORE,
KEY_FRAMEWORK_VERSION,
@@ -24,7 +21,6 @@ from esphome.const import (
KEY_TARGET_PLATFORM,
PLATFORM_ESP8266,
ThreadModel,
Toolchain,
)
from esphome.core import (
CORE,
@@ -35,13 +31,12 @@ from esphome.core import (
)
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.helpers import IS_MACOS, copy_file_if_changed
from esphome.platformio.toolchain import copy_ccache_script, copy_pch_script
from esphome.platformio.toolchain import copy_ccache_script
from esphome.storage_json import StorageJSON
from esphome.types import ConfigType
from .boards import BOARDS, ESP8266_BOARD_BUILD, board_ld_script
from .boards import BOARDS, board_ld_script
from .const import (
BUILD_FLASH_MODES,
CONF_EARLY_PIN_INIT,
CONF_ENABLE_SERIAL,
CONF_ENABLE_SERIAL1,
@@ -49,7 +44,6 @@ from .const import (
KEY_BOARD,
KEY_ESP8266,
KEY_PIN_INITIAL_STATES,
KEY_SCANF_FLOAT,
KEY_SERIAL1_REQUIRED,
KEY_SERIAL_REQUIRED,
KEY_WAVEFORM_REQUIRED,
@@ -109,53 +103,6 @@ def set_core_data(config: ConfigType) -> ConfigType:
return config
_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.ARDUINO)
_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS)
_resolve_toolchain = cv.resolve_toolchain("ESP8266", _TOOLCHAINS, Toolchain.PLATFORMIO)
def _validate_native_toolchain(config: ConfigType) -> ConfigType:
"""Constraints of the native (non-PlatformIO) Arduino toolchain."""
if not CORE.using_toolchain_arduino:
return config
from esphome.arduino8266.framework import MIN_FRAMEWORK_VERSION
conf = config[CONF_FRAMEWORK]
version = cv.Version.parse(conf[CONF_VERSION])
if version < MIN_FRAMEWORK_VERSION:
raise cv.Invalid(
"'toolchain: arduino' requires framework version "
f"{MIN_FRAMEWORK_VERSION} or newer"
)
# platform_version is a PlatformIO concept; drop it (as esp32's native
# toolchain does), warning when a custom pin is discarded. The floor
# above guarantees the schema-derived default is the ARDUINO_4 spec.
if (
conf.pop(CONF_PLATFORM_VERSION, _ARDUINO_4_PLATFORM_SPEC)
!= _ARDUINO_4_PLATFORM_SPEC
):
_LOGGER.warning(
"'platform_version' is ignored by 'toolchain: arduino'; the native "
"toolchain downloads the framework and compiler directly"
)
if conf[CONF_SOURCE] != _format_framework_arduino_version(version):
raise cv.Invalid(
"'toolchain: arduino' does not support a custom framework source; "
"use 'toolchain: platformio'"
)
# BOARDS is a subset of ESP8266_BOARD_BUILD today; the second clause is
# a drift guard for the independently regenerated tables
if (
config[CONF_BOARD] not in BOARDS
or config[CONF_BOARD] not in ESP8266_BOARD_BUILD
):
raise cv.Invalid(
f"Board '{config[CONF_BOARD]}' is not supported by "
"'toolchain: arduino'; use 'toolchain: platformio'"
)
return config
def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]:
"""Binary-download entries for a built ESP8266 firmware.
@@ -185,7 +132,7 @@ def _format_framework_arduino_version(ver: cv.Version) -> str:
# a PIO platformio/framework-arduinoespressif8266 value
# List of package versions: https://api.registry.platformio.org/v3/packages/platformio/tool/framework-arduinoespressif8266
# Same encoding the native toolchain uses for its package download, so a
# custom-source check against this value cannot drift from what it fetches.
# version bump cannot drift between the two paths.
from esphome.arduino8266.framework import framework_package_version
try:
@@ -247,7 +194,7 @@ def _arduino_check_versions(value: ConfigType) -> ConfigType:
platform_version = value.get(CONF_PLATFORM_VERSION)
if platform_version is None:
if version >= cv.Version(3, 1, 0):
platform_version = _ARDUINO_4_PLATFORM_SPEC
platform_version = _parse_platform_version(str(ARDUINO_4_PLATFORM_VERSION))
else:
platform_version = _parse_platform_version(str(ARDUINO_3_PLATFORM_VERSION))
value[CONF_PLATFORM_VERSION] = platform_version
@@ -270,10 +217,6 @@ def _parse_platform_version(value: Any) -> str:
return value
# The platform_version derived for every core >= 3.1.0 config
_ARDUINO_4_PLATFORM_SPEC = _parse_platform_version(str(ARDUINO_4_PLATFORM_VERSION))
ARDUINO_FRAMEWORK_SCHEMA = cv.All(
cv.Schema(
{
@@ -290,6 +233,7 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All(
)
BUILD_FLASH_MODES = ["qio", "qout", "dio", "dout"]
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
@@ -306,30 +250,15 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_ENABLE_SERIAL1): cv.boolean,
cv.Optional(CONF_ENABLE_FULL_PRINTF, default=False): cv.boolean,
cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean,
cv.Optional(
CONF_TOOLCHAIN, visibility=cv.Visibility.ADVANCED
): _validate_toolchain,
}
),
_resolve_toolchain,
_validate_native_toolchain,
# Until the native toolchain lands, PlatformIO is the only backend;
# reject a --toolchain this platform cannot serve yet.
cv.require_platformio_toolchain("ESP8266"),
set_core_data,
)
def native_toolchain_module():
"""The native build backend for the resolved toolchain, if any.
``__main__`` dispatches from its own toolchain-keyed table; this helper
serves the component's internal callers.
"""
if not CORE.using_toolchain_arduino:
return None
from esphome.arduino8266 import toolchain
return toolchain
def check_rosetta() -> None:
"""Fail fast when the x86_64 ESP8266 toolchain cannot run on this Mac.
@@ -365,13 +294,12 @@ def _choose_ld_script(board: str) -> str:
@coroutine_with_priority(CoroPriority.PLATFORM)
async def to_code(config: ConfigType) -> None:
use_platformio = CORE.using_toolchain_platformio
cg.add(esp8266_ns.setup_preferences())
if use_platformio:
cg.add_platformio_option("lib_ldf_mode", "off")
cg.add_platformio_option("lib_compat_mode", "strict")
cg.add_platformio_option("board", config[CONF_BOARD])
cg.add_platformio_option("lib_ldf_mode", "off")
cg.add_platformio_option("lib_compat_mode", "strict")
cg.add_platformio_option("board", config[CONF_BOARD])
cg.add_build_flag("-DUSE_ESP8266")
cg.set_cpp_standard("gnu++20")
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
@@ -387,33 +315,28 @@ async def to_code(config: ConfigType) -> None:
"enabling scanf float support (~8KB flash)"
)
# The native generator reads the same decision (KEY_SCANF_FLOAT)
CORE.data[KEY_ESP8266][KEY_SCANF_FLOAT] = bool(enable_scanf_float)
if use_platformio:
extra_scripts = [
"pre:ccache.py",
"pre:testing_mode.py",
"pre:exclude_updater.py",
"pre:exclude_waveform.py",
"pre:relocate_ratetable.py",
]
if not enable_scanf_float:
extra_scripts.append("pre:remove_float_scanf.py")
extra_scripts.extend(pch_extra_scripts())
extra_scripts.append("post:post_build.py")
cg.add_platformio_option("extra_scripts", extra_scripts)
extra_scripts = [
"pre:ccache.py",
"pre:testing_mode.py",
"pre:exclude_updater.py",
"pre:exclude_waveform.py",
"pre:relocate_ratetable.py",
]
if not enable_scanf_float:
extra_scripts.append("pre:remove_float_scanf.py")
extra_scripts.append("post:post_build.py")
cg.add_platformio_option("extra_scripts", extra_scripts)
conf = config[CONF_FRAMEWORK]
cg.add_platformio_option("framework", "arduino")
cg.add_build_flag("-DUSE_ARDUINO")
cg.add_build_flag("-DUSE_ESP8266_FRAMEWORK_ARDUINO")
cg.add_build_flag("-Wno-nonnull-compare")
if use_platformio:
cg.add_platformio_option("framework", "arduino")
cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION])
cg.add_platformio_option(
"platform_packages",
[f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"],
)
cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION])
cg.add_platformio_option(
"platform_packages",
[f"platformio/framework-arduinoespressif8266@{conf[CONF_SOURCE]}"],
)
# Default for platformio is LWIP2_LOW_MEMORY with:
# - MSS=536
@@ -451,8 +374,7 @@ async def to_code(config: ConfigType) -> None:
# Force-include inline std::__throw_* overrides so GCC dead-strips the unused
# libstdc++ error message strings (e.g. "basic_string::_M_create") from DRAM.
# See throw_stubs.h for details. Must be prepended before <string>, so this
# uses build_src_flags with -include. Unconditional: the native build
# generator reads the same option, keeping one source of truth.
# uses build_src_flags with -include.
cg.add_platformio_option(
"build_src_flags", "-include esphome/components/esp8266/throw_stubs.h"
)
@@ -482,8 +404,6 @@ async def to_code(config: ConfigType) -> None:
# implementation in the Arduino ESP8266 core.
cg.add_build_flag("-Wl,--wrap=millis")
# Unconditional: the native build generator reads the same option,
# keeping one source of truth
cg.add_platformio_option("board_build.flash_mode", config[CONF_BOARD_FLASH_MODE])
ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
@@ -492,7 +412,7 @@ async def to_code(config: ConfigType) -> None:
cg.RawExpression(f"VERSION_CODE({ver.major}, {ver.minor}, {ver.patch})"),
)
if use_platformio and config[CONF_BOARD] in BOARDS:
if config[CONF_BOARD] in BOARDS:
cg.add_platformio_option(
"board_build.ldscript", _choose_ld_script(config[CONF_BOARD])
)
@@ -533,24 +453,8 @@ async def finalize_serial_config() -> None:
cg.add_build_flag("-DNO_GLOBAL_SERIAL1")
# Called by __main__.compile_program; returning False falls through to the
# PlatformIO toolchain.
def run_compile(args, config: ConfigType) -> bool:
# Positive check: the native backend only runs when explicitly resolved
toolchain = native_toolchain_module()
if toolchain is None:
return False
if toolchain.run_compile(config, CORE.verbose) != 0:
raise EsphomeError("ESP8266 native build failed")
return True
# Called by writer.py
def copy_files() -> None:
# Native builds skip the PlatformIO extra scripts; the build generator
# carries their logic
if CORE.using_toolchain_arduino:
return
dir = Path(__file__).parent
for script in (
"post_build",
@@ -565,7 +469,6 @@ def copy_files() -> None:
CORE.relative_build_path(f"{script}.py"),
)
copy_ccache_script()
copy_pch_script()
# ESP logs stack trace decoder, based on https://github.com/me-no-dev/EspExceptionDecoder
@@ -608,75 +511,22 @@ ESP8266_EXCEPTION_CODES = {
}
_DECODE_WARNED_AT: dict[str, float] = {}
def _decode_pc(config: ConfigType, addr: str) -> None:
from esphome.platformio import toolchain
def _warn_decode_problem(key: str, message: str, *args) -> bool:
"""Warn, deduplicated briefly so a burst of stack-dump addresses warns
once but a later dump warns again; returns whether it warned so the
caller can mark suppressed addresses individually."""
now = time.monotonic()
last = _DECODE_WARNED_AT.get(key)
if last is not None and now - last < 30:
return False
_DECODE_WARNED_AT[key] = now
_LOGGER.warning(message, *args)
return True
def _decode_pc(config: ConfigType, addr: str, *, bulk: bool = False) -> None:
"""Decode one crash address. ``bulk``: the caller is scanning every
8-hex stack word, most of which are not code addresses -- unmappable
ones log at debug so real frames are not buried."""
if (native_toolchain := native_toolchain_module()) is not None:
addr2line = native_toolchain.get_addr2line_path()
elf = native_toolchain.get_elf_path()
for path in (addr2line, elf):
if not path.is_file():
_warn_decode_problem(
str(path), "Cannot decode crash addresses: %s missing", path
)
# The detailed warning names no address; mark named
# registers, but bulk stack words at debug (~150 per dump)
log = _LOGGER.debug if bulk else _LOGGER.warning
log("Not decoded %s (toolchain file missing)", addr)
return
addr2line, elf = str(addr2line), str(elf)
else:
from esphome.platformio import toolchain
idedata = toolchain.get_idedata(config)
if not idedata.addr2line_path or not idedata.firmware_elf_path:
_warn_decode_problem(
"no-addr2line",
"Cannot decode crash addresses: no addr2line or ELF in idedata",
)
log = _LOGGER.debug if bulk else _LOGGER.warning
log("Not decoded %s (no addr2line or ELF)", addr)
return
addr2line, elf = idedata.addr2line_path, idedata.firmware_elf_path
command = [addr2line, "-pfiaC", "-e", elf, addr]
idedata = toolchain.get_idedata(config)
if not idedata.addr2line_path or not idedata.firmware_elf_path:
_LOGGER.debug("decode_pc no addr2line")
return
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
try:
translation = subprocess.check_output(command, close_fds=False).decode().strip()
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
# Warn, not debug: a failing addr2line must be visible. The warning
# is rate-limited across a dump, so mark every undecoded address
# inline or the rest read as merely unmappable
if not _warn_decode_problem(
"addr2line-failed", "Could not decode crash address %s (%s)", addr, err
):
# The detailed warning already named this address; mark only
# the rate-limited ones, and bulk stack words at debug
log = _LOGGER.debug if bulk else _LOGGER.warning
log("Not decoded %s (addr2line failed)", addr)
except Exception: # noqa: BLE001 # pylint: disable=broad-except
_LOGGER.debug("Caught exception for command %s", command, exc_info=1)
return
if "?? ??:0" in translation:
# A named register that fails to decode is confusing silence; a
# bulk stack word failing is the expected common case
log = _LOGGER.debug if bulk else _LOGGER.warning
log("Not decoded %s (address not in %s)", addr, elf)
# Nothing useful
return
translation = translation.replace(" at ??:?", "").replace(":?", "")
_LOGGER.warning("Decoded %s", translation)
@@ -746,6 +596,6 @@ def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) ->
if backtrace_state:
for addr in re.finditer(STACKTRACE_ESP8266_BACKTRACE_PC_RE, line):
_decode_pc(config, addr.group(), bulk=True)
_decode_pc(config, addr.group())
return backtrace_state
+1 -3
View File
@@ -16,6 +16,7 @@ KEY_WAVEFORM_REQUIRED = "waveform_required"
KEY_SERIAL_REQUIRED = "serial_required"
KEY_SERIAL1_REQUIRED = "serial1_required"
# Set for the native (non-PlatformIO) toolchain's build generator
KEY_FLASH_MODE = "flash_mode"
KEY_SCANF_FLOAT = "scanf_float"
# Per-board flash-layout override consumed by board_ld_script()
KEY_LDSCRIPT = "ldscript"
@@ -72,6 +73,3 @@ def enable_serial1() -> None:
enable_serial1()
"""
CORE.data.setdefault(KEY_ESP8266, {})[KEY_SERIAL1_REQUIRED] = True
BUILD_FLASH_MODES = ("qio", "qout", "dio", "dout")
@@ -41,7 +41,10 @@ const noise::NoiseContext &ESPHomeOTAComponent::noise_context_() const {
#endif
static constexpr uint16_t OTA_BLOCK_SIZE = 8192;
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer
// Milliseconds for data transfer. Covers the lwIP retransmit run seen in
// practice for a lost chunk ack (1.5 + 3 + 6 + 12 + 24 + 48 s); the CLI waits
// longer (espota2.DATA_PHASE_TIMEOUT) so the device is free before it retries
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 105000;
// Single-instance pointer — multi-port configs are rejected in final_validate.
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
@@ -118,21 +118,24 @@ void I2SAudioSpeakerBase::loop() {
break;
}
// Still starting up or winding down from a previous run
if ((this->tx_handle_ != nullptr) || (this->speaker_task_handle_ != nullptr)) {
break;
}
if (this->start_i2s_driver(this->audio_stream_info_) != ESP_OK) {
ESP_LOGE(TAG, "Driver failed to start; retrying in 1 second");
this->status_momentary_error("driver-failure", 1000);
break;
}
if (this->speaker_task_handle_ == nullptr) {
xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
&this->speaker_task_handle_);
xTaskCreate(I2SAudioSpeakerBase::speaker_task, "speaker_task", TASK_STACK_SIZE, (void *) this, TASK_PRIORITY,
&this->speaker_task_handle_);
if (this->speaker_task_handle_ == nullptr) {
ESP_LOGE(TAG, "Task failed to start, retrying in 1 second");
this->status_momentary_error("task-failure", 1000);
this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt
}
if (this->speaker_task_handle_ == nullptr) {
ESP_LOGE(TAG, "Task failed to start, retrying in 1 second");
this->status_momentary_error("task-failure", 1000);
this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt
}
break;
case speaker::STATE_RUNNING: // Intentional fallthrough
@@ -218,8 +221,8 @@ size_t I2SAudioSpeakerBase::play(const uint8_t *data, size_t length, TickType_t
}
bool I2SAudioSpeakerBase::has_buffered_data() const {
if (this->audio_ring_buffer_.use_count() > 0) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->audio_ring_buffer_.lock();
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->audio_ring_buffer_.lock();
if (temp_ring_buffer != nullptr) {
return temp_ring_buffer->available() > 0;
}
return false;
+24 -10
View File
@@ -47,11 +47,7 @@ InfraredCall &InfraredCall::set_repeat_count(uint32_t count) {
return *this;
}
void InfraredCall::perform() {
if (this->parent_ != nullptr) {
this->parent_->control(*this);
}
}
bool InfraredCall::perform() { return this->parent_ != nullptr && this->parent_->control(*this); }
// ========== Infrared ==========
@@ -64,6 +60,15 @@ void Infrared::setup() {
if (this->receiver_ != nullptr) {
this->receiver_->register_listener(this);
}
#if defined(USE_API) && defined(USE_IR_RF)
if (this->transmitter_ != nullptr) {
// only frames this entity submitted; YAML automations share the transmitter
this->transmitter_->set_on_complete_callback([this](uint32_t seq) {
if (seq == this->inflight_seq_)
this->notify_transmit_complete_();
});
}
#endif
}
void Infrared::dump_config() {
@@ -75,15 +80,15 @@ void Infrared::dump_config() {
YESNO(this->traits_.get_supports_receiver()));
}
void Infrared::control(const InfraredCall &call) {
bool Infrared::control(const InfraredCall &call) {
if (this->transmitter_ == nullptr) {
ESP_LOGW(TAG, "No transmitter configured");
return;
return false;
}
if (!call.has_raw_timings()) {
ESP_LOGE(TAG, "No raw timings provided");
return;
return false;
}
// Create transmit data object
@@ -107,7 +112,7 @@ void Infrared::control(const InfraredCall &call) {
// Decode base64url (URL-safe) into transmit buffer
if (!transmit_data->set_data_from_base64url(call.get_base64url_data())) {
ESP_LOGE(TAG, "Invalid base64url data");
return;
return false;
}
// Sanity check: validate timing values are within reasonable bounds
constexpr int32_t max_timing_us = 500000; // 500ms absolute max
@@ -115,7 +120,7 @@ void Infrared::control(const InfraredCall &call) {
int32_t abs_timing = timing < 0 ? -timing : timing;
if (abs_timing > max_timing_us) {
ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us);
return;
return false;
}
}
ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(),
@@ -133,7 +138,16 @@ void Infrared::control(const InfraredCall &call) {
}
// Perform transmission
this->inflight_seq_ = transmit_call.get_seq();
transmit_call.perform();
return true;
}
void Infrared::notify_transmit_complete_() {
#if defined(USE_API) && defined(USE_IR_RF)
if (api::global_api_server != nullptr)
api::global_api_server->send_infrared_rf_transmit_complete(*this);
#endif
}
uint32_t Infrared::get_capability_flags() const {
+7 -4
View File
@@ -54,8 +54,8 @@ class InfraredCall {
/// Set the number of times to repeat transmission (1 = transmit once, 2 = transmit twice, etc.)
InfraredCall &set_repeat_count(uint32_t count);
/// Perform the transmission
void perform();
/// Perform the transmission; returns true if a frame was handed to the transmitter
bool perform();
/// Get the carrier frequency
const optional<uint32_t> &get_carrier_frequency() const { return this->carrier_frequency_; }
@@ -145,8 +145,11 @@ class Infrared : public Component, public EntityBase, public remote_base::Remote
protected:
friend class InfraredCall;
/// Perform the actual transmission (called by InfraredCall)
virtual void control(const InfraredCall &call);
/// Perform the actual transmission (called by InfraredCall); false if nothing was transmitted
virtual bool control(const InfraredCall &call);
/// Forwards the transmitter's completion to the API server
void notify_transmit_complete_();
uint32_t inflight_seq_{0}; // seq of the frame this entity submitted last
// Underlying hardware components
remote_base::RemoteReceiverBase *receiver_{nullptr};
+21 -10
View File
@@ -12,16 +12,16 @@ static const char *const TAG = "ir_rf_proxy";
// Static template: all instantiations occur in this translation unit.
template<typename CallT>
static void transmit_raw_timings(remote_base::RemoteTransmitterBase *transmitter, uint32_t carrier_frequency,
const CallT &call) {
static bool transmit_raw_timings(remote_base::RemoteTransmitterBase *transmitter, uint32_t carrier_frequency,
const CallT &call, uint32_t &inflight_seq) {
if (transmitter == nullptr) {
ESP_LOGW(TAG, "No transmitter configured");
return;
return false;
}
if (!call.has_raw_timings()) {
ESP_LOGE(TAG, "No raw timings provided");
return;
return false;
}
auto transmit_call = transmitter->transmit();
@@ -36,14 +36,14 @@ static void transmit_raw_timings(remote_base::RemoteTransmitterBase *transmitter
} else if (call.is_base64url()) {
if (!transmit_data->set_data_from_base64url(call.get_base64url_data())) {
ESP_LOGE(TAG, "Invalid base64url data");
return;
return false;
}
constexpr int32_t max_timing_us = 500000;
for (int32_t timing : transmit_data->get_data()) {
int32_t abs_timing = timing < 0 ? -timing : timing;
if (abs_timing > max_timing_us) {
ESP_LOGE(TAG, "Invalid timing value: %" PRId32 " µs (max %" PRId32 ")", timing, max_timing_us);
return;
return false;
}
}
ESP_LOGD(TAG, "Transmitting base64url raw timings: count=%zu, repeat=%" PRIu32, transmit_data->get_data().size(),
@@ -58,7 +58,9 @@ static void transmit_raw_timings(remote_base::RemoteTransmitterBase *transmitter
transmit_call.set_send_times(call.get_repeat_count());
}
inflight_seq = transmit_call.get_seq();
transmit_call.perform();
return true;
}
// ========== IrRfProxy (Infrared platform) ==========
@@ -80,9 +82,9 @@ void IrRfProxy::dump_config() {
}
}
void IrRfProxy::control(const infrared::InfraredCall &call) {
bool IrRfProxy::control(const infrared::InfraredCall &call) {
uint32_t carrier = call.get_carrier_frequency().value_or(0);
transmit_raw_timings(this->transmitter_, carrier, call);
return transmit_raw_timings(this->transmitter_, carrier, call, this->inflight_seq_);
}
#endif // USE_IR_RF
@@ -101,6 +103,15 @@ void RfProxy::setup() {
if (this->receiver_ != nullptr) {
this->receiver_->register_listener(this);
}
#if defined(USE_API) && defined(USE_RADIO_FREQUENCY)
if (this->transmitter_ != nullptr) {
// only frames this entity submitted; YAML automations share the transmitter
this->transmitter_->set_on_complete_callback([this](uint32_t seq) {
if (seq == this->inflight_seq_)
this->notify_transmit_complete_();
});
}
#endif
}
void RfProxy::dump_config() {
@@ -122,11 +133,11 @@ void RfProxy::dump_config() {
}
}
void RfProxy::control(const radio_frequency::RadioFrequencyCall &call) {
bool 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.
transmit_raw_timings(this->transmitter_, 0, call);
return transmit_raw_timings(this->transmitter_, 0, call, this->inflight_seq_);
}
#endif // USE_RADIO_FREQUENCY
+2 -2
View File
@@ -35,7 +35,7 @@ class IrRfProxy final : public infrared::Infrared {
void set_receiver_frequency(uint32_t frequency_hz) { this->get_traits().set_receiver_frequency_hz(frequency_hz); }
protected:
void control(const infrared::InfraredCall &call) override;
bool control(const infrared::InfraredCall &call) override;
// RF frequency in kHz (Hz / 1000); 0 = infrared, non-zero = RF
uint32_t frequency_khz_{0};
@@ -63,7 +63,7 @@ class RfProxy final : public radio_frequency::RadioFrequency {
void set_frequency_hz(uint32_t freq_hz) { this->traits_.set_fixed_frequency_hz(freq_hz); }
protected:
void control(const radio_frequency::RadioFrequencyCall &call) override;
bool control(const radio_frequency::RadioFrequencyCall &call) override;
remote_base::RemoteTransmitterBase *transmitter_{nullptr};
remote_base::RemoteReceiverBase *receiver_{nullptr};
+11 -2
View File
@@ -3,6 +3,7 @@
#include "esphome/components/esp32/crash_handler.h"
#include <esp_log.h>
#include <esp_idf_version.h>
#include <driver/uart.h>
#include <soc/soc_caps.h>
@@ -16,8 +17,10 @@
#include <driver/usb_serial_jtag_vfs.h>
#endif
#endif
#include "esp_idf_version.h"
#if defined(CONFIG_PM_ENABLE) && defined(CONFIG_FREERTOS_USE_TICKLESS_IDLE) && \
(ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0))
#include "esp_sleep.h"
#endif
#include "freertos/FreeRTOS.h"
#include <fcntl.h>
@@ -87,6 +90,12 @@ void init_uart(uart_port_t uart_num, uint32_t baud_rate, int tx_buffer_size) {
// ESP-IDF requires rx_buffer_size > UART_HW_FIFO_LEN (128 bytes).
const int min_rx_buffer_size = UART_HW_FIFO_LEN(uart_num) + 1;
uart_driver_install(uart_num, min_rx_buffer_size, tx_buffer_size, 0, nullptr, 0);
#if defined(CONFIG_PM_ENABLE) && defined(CONFIG_FREERTOS_USE_TICKLESS_IDLE) && \
(ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0))
// Always flush before going to light sleep. Could be disabled for devices
// without TOP_PD or if source_clk = UART_SCLK_RTC
esp_sleep_set_console_uart_handling_mode(ESP_SLEEP_ALWAYS_FLUSH_UART);
#endif
}
void Logger::pre_setup() {
@@ -129,7 +129,7 @@ void MicroWakeWord::setup() {
return;
}
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (this->ring_buffer_.use_count() > 1) {
if (temp_ring_buffer != nullptr) {
// Producer-only write: never touches consumer state. If the buffer is full, ask the inference task
// to drain it - reset() is a consumer operation and must run on the inference task's thread.
// Disable partial writes so audio chunks are either fully accepted or rejected and handled below.
@@ -446,9 +446,9 @@ void MicroWakeWord::loop() {
xEventGroupClearBits(this->event_group_, EventGroupBits::TASK_STOPPING);
}
if ((event_group_bits & EventGroupBits::TASK_STOPPED)) {
// Retries on a subsequent loop if the task is still running on the other core
if ((event_group_bits & EventGroupBits::TASK_STOPPED) && this->inference_task_.deallocate()) {
ESP_LOGD(TAG, "Inference task is finished, freeing task resources");
this->inference_task_.deallocate();
xEventGroupClearBits(this->event_group_, ALL_BITS);
xQueueReset(this->detection_queue_);
this->set_state_(State::STOPPED);
@@ -48,7 +48,7 @@ class MicrophoneSource final {
template<typename F> void add_data_callback(F &&data_callback) {
this->mic_->add_data_callback([this, data_callback](const std::vector<uint8_t> &data) {
if (this->enabled_ || this->passive_) {
if (this->processed_samples_.use_count() == 0) {
if (this->processed_samples_ == nullptr) {
// Create vector if its unused
this->processed_samples_ = std::make_shared<std::vector<uint8_t>>();
}
@@ -218,7 +218,7 @@ size_t SourceSpeaker::play(const uint8_t *data, size_t length, TickType_t ticks_
}
size_t bytes_written = 0;
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (temp_ring_buffer.use_count() > 0) {
if (temp_ring_buffer != nullptr) {
// Only write to the ring buffer if the reference is valid
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
if (bytes_written > 0) {
@@ -250,14 +250,14 @@ esp_err_t SourceSpeaker::start_() {
// 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) {
if (this->audio_source_ == nullptr) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (!temp_ring_buffer) {
if (temp_ring_buffer == nullptr) {
temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
this->ring_buffer_ = temp_ring_buffer;
}
if (!temp_ring_buffer) {
if (temp_ring_buffer == nullptr) {
return ESP_ERR_NO_MEM;
}
@@ -278,7 +278,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->audio_source_ != nullptr) && this->audio_source_->has_buffered_data());
}
void SourceSpeaker::set_mute_state(bool mute_state) {
@@ -382,8 +382,8 @@ void MixerSpeaker::loop() {
ESP_LOGV(TAG, "Stopping");
xEventGroupClearBits(this->event_group_, MIXER_TASK_STATE_STOPPING);
}
if (event_group_bits & MIXER_TASK_STATE_STOPPED) {
this->task_.deallocate();
// Retries on a subsequent loop if the task is still running on the other core
if ((event_group_bits & MIXER_TASK_STATE_STOPPED) && this->task_.deallocate()) {
ESP_LOGD(TAG, "Stopped");
xEventGroupClearBits(this->event_group_, MIXER_TASK_ALL_BITS);
this->all_stopped_since_ms_ = 0;
@@ -496,7 +496,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
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) {
if (audio_source == nullptr) {
// No audio source allocated, so skip processing this speaker
continue;
}
+139 -46
View File
@@ -26,44 +26,129 @@ static const uint8_t MLX90614_ID4 = 0x3F;
static const char *const TAG = "mlx90614";
// The EEPROM cell has a limited number of write cycles, so stop retrying after a few failures
static constexpr uint8_t EMISSIVITY_WRITE_ATTEMPTS = 3;
// SMBus packet error code: CRC-8 with polynomial 0x07, MSB first
static uint8_t crc8_pec(const uint8_t *data, uint8_t len) { return crc8(data, len, 0x00, 0x07, true); }
void MLX90614Component::setup() {
if (!this->write_emissivity_()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
this->mark_failed();
if (std::isnan(this->emissivity_)) {
return;
}
this->emissivity_write_attempts_ = EMISSIVITY_WRITE_ATTEMPTS;
this->try_write_emissivity_();
if (this->emissivity_write_attempts_ != 0) {
this->status_set_warning(LOG_STR("Failed to write emissivity, will retry"));
}
}
void MLX90614Component::try_write_emissivity_() {
if (this->emissivity_write_attempts_ == 0) {
return;
}
if (this->write_emissivity_()) {
this->emissivity_write_attempts_ = 0;
return;
}
if (--this->emissivity_write_attempts_ == 0) {
ESP_LOGE(TAG, "Giving up on writing emissivity after %u attempts", EMISSIVITY_WRITE_ATTEMPTS);
this->emissivity_write_failed_ = true;
}
}
bool MLX90614Component::write_emissivity_() {
if (std::isnan(this->emissivity_))
// Skip the write when the EEPROM already holds the desired value to save write cycles
uint16_t current_emissivity;
if (this->read_register_(MLX90614_EMISSIVITY, current_emissivity) != i2c::ERROR_OK) {
return false;
}
const auto desired_emissivity = static_cast<uint16_t>(this->emissivity_ * 0xFFFF);
if (current_emissivity == desired_emissivity) {
return true;
uint16_t value = (uint16_t) (this->emissivity_ * 65535);
if (!this->write_bytes_(MLX90614_EMISSIVITY, 0)) {
return false;
}
delay(10);
if (!this->write_bytes_(MLX90614_EMISSIVITY, value)) {
return false;
}
delay(10);
return true;
return this->write_register_(MLX90614_EMISSIVITY, desired_emissivity);
}
bool MLX90614Component::write_bytes_(uint8_t reg, uint16_t data) {
bool MLX90614Component::write_register_(uint8_t reg, uint16_t data) {
// The PEC covers the whole write transaction: SLA+W, command, data low, data high
uint8_t buf[5];
buf[0] = this->address_ << 1;
buf[1] = reg;
buf[2] = data & 0xFF;
buf[3] = data >> 8;
buf[4] = crc8(buf, 4, 0x00, 0x07, true);
return this->write_bytes(reg, buf + 2, 3);
// See datasheet 8.3.3.1 EEPROM write sequence
// 1. Write 0x0000 into the cell of interest (erases the cell)
buf[2] = buf[3] = 0;
buf[4] = crc8_pec(buf, 4);
auto ec = this->write_register(reg, buf + 2, 3);
if (ec != i2c::ERROR_OK) {
ESP_LOGW(TAG, "Can't erase register 0x%02X, error %d", reg, ec);
return false;
}
// 2. Wait at least 5ms
delay(10);
// 3. Write the new value
if (data != 0) {
buf[2] = data & 0xFF;
buf[3] = data >> 8;
buf[4] = crc8_pec(buf, 4);
ec = this->write_register(reg, buf + 2, 3);
if (ec != i2c::ERROR_OK) {
ESP_LOGW(TAG, "Can't write register 0x%02X, error %d", reg, ec);
return false;
}
// 4. Wait at least 5ms
delay(10);
}
// 5. Read back to confirm the value was stored
uint16_t read_back;
ec = this->read_register_(reg, read_back);
if (ec != i2c::ERROR_OK) {
ESP_LOGW(TAG, "Can't check register 0x%02X value, error %d", reg, ec);
return false;
}
if (read_back != data) {
ESP_LOGW(TAG, "Read back mismatch on register 0x%02X. Expected 0x%04X, got 0x%04X", reg, data, read_back);
return false;
}
return true;
}
i2c::ErrorCode MLX90614Component::read_register_(uint8_t reg, uint16_t &data) {
// The PEC covers the whole read transaction: SLA+W, command, SLA+R, data low, data high
uint8_t buf[6];
buf[0] = this->address_ << 1;
buf[1] = reg;
buf[2] = (this->address_ << 1) | 0x01;
const auto ec = this->read_register(reg, buf + 3, 3);
if (ec != i2c::ERROR_OK) {
ESP_LOGW(TAG, "i2c read error %d", ec);
return ec;
}
const auto expected_pec = crc8_pec(buf, 5);
if (buf[5] != expected_pec) {
ESP_LOGW(TAG, "i2c CRC error. Expected 0x%02X, got 0x%02X", expected_pec, buf[5]);
return i2c::ERROR_CRC;
}
data = encode_uint16(buf[4], buf[3]);
return i2c::ERROR_OK;
}
void MLX90614Component::dump_config() {
ESP_LOGCONFIG(TAG, "MLX90614:");
LOG_I2C_DEVICE(this);
if (this->is_failed()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
if (this->emissivity_write_attempts_ != 0) {
ESP_LOGW(TAG, " Emissivity not written yet, will retry");
}
LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "Ambient", this->ambient_sensor_);
@@ -71,33 +156,41 @@ void MLX90614Component::dump_config() {
}
void MLX90614Component::update() {
uint8_t emissivity[3];
if (this->read_register(MLX90614_EMISSIVITY, emissivity, 3) != i2c::ERROR_OK) {
this->status_set_warning();
return;
// Temperature reads run regardless of the emissivity state so a failure still shows up as NAN
this->try_write_emissivity_();
// Publishes NAN on a bus or CRC failure so a stuck reading is visible instead of silently stale
auto publish_sensor = [this](sensor::Sensor *sensor, uint8_t reg) {
if (sensor == nullptr) {
return i2c::ERROR_OK;
}
uint16_t raw;
const auto ec = this->read_register_(reg, raw);
if (ec != i2c::ERROR_OK) {
sensor->publish_state(NAN);
return ec;
}
// Bit 15 set means the device flagged the reading as invalid
const float temperature = (raw & 0x8000) ? NAN : raw * 0.02f - 273.15f;
ESP_LOGD(TAG, "'%s': Got temperature=%.1f°C", sensor->get_name().c_str(), temperature);
sensor->publish_state(temperature);
return ec;
};
const auto object_ec = publish_sensor(this->object_sensor_, MLX90614_TEMPERATURE_OBJECT_1);
const auto ambient_ec = publish_sensor(this->ambient_sensor_, MLX90614_TEMPERATURE_AMBIENT);
if (object_ec != i2c::ERROR_OK || ambient_ec != i2c::ERROR_OK) {
this->status_set_warning(LOG_STR("Failed to read some sensors"));
} else if (this->emissivity_write_failed_) {
this->status_set_warning(LOG_STR("Failed to write emissivity"));
} else if (this->emissivity_write_attempts_ != 0) {
this->status_set_warning(LOG_STR("Failed to write emissivity, will retry"));
} else {
this->status_clear_warning();
}
uint8_t raw_object[3];
if (this->read_register(MLX90614_TEMPERATURE_OBJECT_1, raw_object, 3) != i2c::ERROR_OK) {
this->status_set_warning();
return;
}
uint8_t raw_ambient[3];
if (this->read_register(MLX90614_TEMPERATURE_AMBIENT, raw_ambient, 3) != i2c::ERROR_OK) {
this->status_set_warning();
return;
}
float ambient = raw_ambient[1] & 0x80 ? NAN : encode_uint16(raw_ambient[1], raw_ambient[0]) * 0.02f - 273.15f;
float object = raw_object[1] & 0x80 ? NAN : encode_uint16(raw_object[1], raw_object[0]) * 0.02f - 273.15f;
ESP_LOGD(TAG, "Got Temperature=%.1f°C Ambient=%.1f°C", object, ambient);
if (this->ambient_sensor_ != nullptr && !std::isnan(ambient))
this->ambient_sensor_->publish_state(ambient);
if (this->object_sensor_ != nullptr && !std::isnan(object))
this->object_sensor_->publish_state(object);
this->status_clear_warning();
}
} // namespace esphome::mlx90614
+6 -1
View File
@@ -18,13 +18,18 @@ class MLX90614Component final : public PollingComponent, public i2c::I2CDevice {
void set_emissivity(float emissivity) { emissivity_ = emissivity; }
protected:
void try_write_emissivity_();
bool write_emissivity_();
bool write_bytes_(uint8_t reg, uint16_t data);
bool write_register_(uint8_t reg, uint16_t data);
i2c::ErrorCode read_register_(uint8_t reg, uint16_t &data);
sensor::Sensor *ambient_sensor_{nullptr};
sensor::Sensor *object_sensor_{nullptr};
float emissivity_{NAN};
// Remaining attempts to program the emissivity EEPROM cell, bounded to limit cell wear
uint8_t emissivity_write_attempts_{0};
bool emissivity_write_failed_{false};
};
} // namespace esphome::mlx90614
+10 -6
View File
@@ -26,30 +26,34 @@ namespace esphome::network {
/// Return whether the node is connected to the network (through wifi, eth, ...)
ESPHOME_ALWAYS_INLINE inline bool is_connected() {
// With a single interface enabled the checks below collapse to `if (x) return true; return false;`, which
// clang-tidy wants folded into one return. Keep the per-interface form so every enabled interface is checked.
// NOLINTBEGIN(readability-simplify-boolean-expr)
#ifdef USE_ETHERNET
if (ethernet::global_eth_component != nullptr && ethernet::global_eth_component->is_connected())
return true;
#endif
#ifdef USE_MODEM
if (modem::global_modem_component != nullptr)
return modem::global_modem_component->is_connected();
if (modem::global_modem_component != nullptr && modem::global_modem_component->is_connected())
return true;
#endif
#ifdef USE_WIFI
if (wifi::global_wifi_component != nullptr)
return wifi::global_wifi_component->is_connected();
if (wifi::global_wifi_component != nullptr && wifi::global_wifi_component->is_connected())
return true;
#endif
#ifdef USE_OPENTHREAD
if (openthread::global_openthread_component != nullptr)
return openthread::global_openthread_component->is_connected();
if (openthread::global_openthread_component != nullptr && openthread::global_openthread_component->is_connected())
return true;
#endif
#ifdef USE_HOST
return true; // Assume it's connected
#endif
return false;
// NOLINTEND(readability-simplify-boolean-expr)
}
/// Return whether the network is disabled: every configured interface with a
+2 -2
View File
@@ -88,12 +88,12 @@ def encryption_schema(config: ConfigType | None) -> ConfigType:
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_NOISE")
cg.add_library("esphome/noise-c", "0.1.24")
cg.add_library("esphome/noise-c", "0.1.26")
# noise-c depends on libsodium, but declaring it here too lets the
# library manager see the full set up front instead of discovering
# libsodium only after noise-c has downloaded, so the two can download
# in parallel. The version must match noise-c's library.json.
cg.add_library("esphome/libsodium", "1.10021.6")
cg.add_library("esphome/libsodium", "1.10021.8")
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
@@ -52,14 +52,15 @@ RadioFrequencyCall &RadioFrequencyCall::set_repeat_count(uint32_t count) {
return *this;
}
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);
bool RadioFrequencyCall::perform() {
if (this->parent_ == nullptr) {
return false;
}
// 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);
return this->parent_->control(*this);
}
// ========== RadioFrequency ==========
@@ -108,4 +109,11 @@ bool RadioFrequency::on_receive(remote_base::RemoteReceiveData data) {
return false; // Don't consume the event, allow other listeners to process it
}
void RadioFrequency::notify_transmit_complete_() {
#if defined(USE_API) && defined(USE_RADIO_FREQUENCY)
if (api::global_api_server != nullptr)
api::global_api_server->send_infrared_rf_transmit_complete(*this);
#endif
}
} // namespace esphome::radio_frequency
@@ -64,8 +64,8 @@ class RadioFrequencyCall {
/// Set the number of times to repeat transmission (1 = transmit once, 2 = transmit twice, etc.)
RadioFrequencyCall &set_repeat_count(uint32_t count);
/// Perform the transmission
void perform();
/// Perform the transmission; returns true if a frame was handed to the transmitter
bool perform();
/// Get the frequency in Hz
const optional<uint32_t> &get_frequency() const { return this->frequency_hz_; }
@@ -184,7 +184,11 @@ class RadioFrequency : public Component, public EntityBase, public remote_base::
/// Perform the actual transmission (called by RadioFrequencyCall::perform())
/// Platforms must override this to implement hardware-specific transmission.
virtual void control(const RadioFrequencyCall &call) = 0;
/// Returns false if nothing was transmitted.
virtual bool control(const RadioFrequencyCall &call) = 0;
/// Forwards the transmitter's completion to the API server; platforms hook their transmitter to it
void notify_transmit_complete_();
uint32_t inflight_seq_{0}; // seq of the frame this entity submitted last
// Traits describing capabilities
RadioFrequencyTraits traits_;
@@ -163,7 +163,7 @@ bool RemoteTransmitData::set_data_from_base64url(const std::string &base64url) {
/* RemoteTransmitterBase */
void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait) {
void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait, uint32_t seq) {
#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE
const auto &vec = this->temp_.get_data();
char buffer[256];
@@ -195,6 +195,7 @@ void RemoteTransmitterBase::send_(uint32_t send_times, uint32_t send_wait) {
ESP_LOGVV(TAG, "%s", buffer);
}
#endif
this->current_seq_ = seq;
this->send_internal(send_times, send_wait);
}
} // namespace esphome::remote_base
+29 -5
View File
@@ -146,21 +146,24 @@ class RemoteTransmitterBase : public RemoteComponentBase {
RemoteTransmitterBase(InternalGPIOPin *pin) : RemoteComponentBase(pin) {}
class TransmitCall {
public:
explicit TransmitCall(RemoteTransmitterBase *parent) : parent_(parent) {}
TransmitCall(RemoteTransmitterBase *parent, uint32_t seq) : parent_(parent), seq_(seq) {}
RemoteTransmitData *get_data() { return &this->parent_->temp_; }
void set_send_times(uint32_t send_times) { send_times_ = send_times; }
void set_send_wait(uint32_t send_wait) { send_wait_ = send_wait; }
void perform() { this->parent_->send_(this->send_times_, this->send_wait_); }
/// Identifies this transmission in the completion callback
uint32_t get_seq() const { return this->seq_; }
void perform() { this->parent_->send_(this->send_times_, this->send_wait_, this->seq_); }
protected:
RemoteTransmitterBase *parent_;
uint32_t send_times_{1};
uint32_t send_wait_{0};
uint32_t seq_;
};
TransmitCall transmit() {
this->temp_.reset();
return TransmitCall(this);
return TransmitCall(this, ++this->next_seq_);
}
template<typename Protocol>
void transmit(const Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) {
@@ -170,11 +173,32 @@ class RemoteTransmitterBase : public RemoteComponentBase {
call.set_send_wait(send_wait);
call.perform();
}
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
/// Called with the TransmitCall's seq once that transmission has finished, after the last
/// repeat and before the on_complete trigger. One slot: a transmitter is driven by a single
/// infrared or radio_frequency entity.
template<typename F> void set_on_complete_callback(F &&callback) {
this->complete_callback_ = Callback<void(uint32_t)>::create(std::forward<F>(callback));
}
#endif
protected:
void send_(uint32_t send_times, uint32_t send_wait);
void send_(uint32_t send_times, uint32_t send_wait, uint32_t seq);
virtual void send_internal(uint32_t send_times, uint32_t send_wait) = 0;
void send_single_() { this->send_(1, 0); }
void send_single_() { this->send_(1, 0, ++this->next_seq_); }
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void notify_complete_(uint32_t seq) {
if (this->complete_callback_.fn_ != nullptr)
this->complete_callback_.call(seq);
}
Callback<void(uint32_t)> complete_callback_{};
#else
void notify_complete_(uint32_t seq) {}
#endif
// seq handed to the platform by send_(); platforms copy it when they accept the frame
uint32_t next_seq_{0};
uint32_t current_seq_{0};
/// Use same vector for all transmits, avoids many allocations
RemoteTransmitData temp_;
@@ -80,6 +80,7 @@ void RemoteTransmitterComponent::digital_write(bool value) { this->pin_->digital
void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) {
ESP_LOGD(TAG, "Sending remote code");
this->inflight_seq_ = this->current_seq_;
uint32_t on_time, off_time;
this->calculate_on_off_time_(this->temp_.get_carrier_frequency(), &on_time, &off_time);
this->transmit_trigger_.trigger();
@@ -114,7 +115,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen
}
}
}
this->complete_trigger_.trigger();
this->fire_complete_();
}
} // namespace esphome::remote_transmitter
@@ -82,6 +82,12 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa
protected:
void send_internal(uint32_t send_times, uint32_t send_wait) override;
// completion callbacks run before the user's on_complete automation
void fire_complete_() {
this->notify_complete_(this->inflight_seq_);
this->complete_trigger_.trigger();
}
uint32_t inflight_seq_{0}; // seq of the frame whose completion is still to be reported
#if defined(USE_ESP8266) || \
(defined(USE_LIBRETINY) && !defined(USE_LIBRETINY_VARIANT_RTL8720C) && !defined(REMOTE_TRANSMITTER_BK_PWM)) || \
defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
@@ -110,7 +110,7 @@ void RemoteTransmitterComponent::deliver_completion_() {
if (!this->stall_aborted_)
this->status_clear_warning();
this->complete_pending_ = false;
this->complete_trigger_.trigger();
this->fire_complete_();
}
// Waits until no chain is in flight, delivering any deferred completions; a completion
@@ -137,6 +137,7 @@ void RemoteTransmitterComponent::wait_until_idle_() {
// Stages the repeat schedule and stall deadline, then starts the interrupt chain
void RemoteTransmitterComponent::arm_chain_(uint32_t send_times, uint32_t send_wait) {
this->inflight_seq_ = this->current_seq_;
this->isr_repeats_left_ = send_times;
this->isr_send_wait_ = send_wait;
this->isr_index_ = 0;
@@ -213,7 +213,7 @@ void RemoteTransmitterComponent::wait_for_rmt_() {
this->status_set_warning();
}
this->complete_trigger_.trigger();
this->fire_complete_();
}
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 1)
@@ -228,6 +228,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen
if (this->non_blocking_ && this->cancel_timeout("complete")) {
this->wait_for_rmt_();
}
this->inflight_seq_ = this->current_seq_;
if (this->current_carrier_frequency_ != this->temp_.get_carrier_frequency()) {
this->current_carrier_frequency_ = this->temp_.get_carrier_frequency();
@@ -300,6 +301,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen
void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) {
if (this->is_failed())
return;
this->inflight_seq_ = this->current_seq_;
if (this->current_carrier_frequency_ != this->temp_.get_carrier_frequency()) {
this->current_carrier_frequency_ = this->temp_.get_carrier_frequency();
@@ -364,7 +366,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen
if (i + 1 < send_times)
delayMicroseconds(send_wait);
}
this->complete_trigger_.trigger();
this->fire_complete_();
}
#endif
@@ -149,6 +149,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen
return;
}
ESP_LOGD(TAG, "Sending remote code");
this->inflight_seq_ = this->current_seq_;
const uint32_t carrier_frequency = this->temp_.get_carrier_frequency();
// unmodulated protocols (no carrier or 100% duty) drive the pin constantly during marks
float mark_duty =
@@ -194,7 +195,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen
}
}
}
this->complete_trigger_.trigger();
this->fire_complete_();
}
#endif // USE_LIBRETINY_VARIANT_RTL8720C
@@ -153,8 +153,8 @@ void ResamplerSpeaker::loop() {
ESP_LOGV(TAG, "Stopping");
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::STATE_STOPPING);
}
if (event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) {
this->task_.deallocate();
// Retries on a subsequent loop if the task is still running on the other core
if ((event_group_bits & ResamplingEventGroupBits::STATE_STOPPED) && this->task_.deallocate()) {
ESP_LOGD(TAG, "Stopped");
xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ALL_BITS);
}
@@ -235,7 +235,7 @@ size_t ResamplerSpeaker::play(const uint8_t *data, size_t length, TickType_t tic
bytes_written = this->output_speaker_->play(data, length, ticks_to_wait);
} else {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (temp_ring_buffer) {
if (temp_ring_buffer != nullptr) {
// Only write to the ring buffer if the reference is valid
bytes_written = temp_ring_buffer->write_without_replacement(data, length, ticks_to_wait);
} else {
@@ -299,7 +299,7 @@ bool ResamplerSpeaker::has_buffered_data() const {
bool has_ring_buffer_data = false;
if (this->requires_resampling_()) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (temp_ring_buffer) {
if (temp_ring_buffer != nullptr) {
has_ring_buffer_data = (temp_ring_buffer->available() > 0);
}
}
@@ -342,7 +342,7 @@ void ResamplerSpeaker::resample_task(void *params) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(
this_resampler->audio_stream_info_.ms_to_bytes(this_resampler->buffer_duration_ms_));
if (!temp_ring_buffer) {
if (temp_ring_buffer == nullptr) {
err = ESP_ERR_NO_MEM;
} else {
this_resampler->ring_buffer_ = temp_ring_buffer;
+51 -7
View File
@@ -6,12 +6,17 @@ from esphome.components import esp32, network, psram, socket, wifi
import esphome.config_validation as cv
from esphome.const import (
CONF_BUFFER_SIZE,
CONF_ESPHOME,
CONF_FORMAT,
CONF_HEIGHT,
CONF_ID,
CONF_MODEL,
CONF_NAME,
CONF_PROJECT,
CONF_SAMPLE_RATE,
CONF_SOURCE,
CONF_TASK_STACK_IN_PSRAM,
CONF_VERSION,
CONF_WIDTH,
)
from esphome.core import CORE, ID
@@ -27,9 +32,18 @@ DOMAIN = "sendspin"
CONF_DISPLAY_OFFSET = "display_offset"
CONF_SENDSPIN_ID = "sendspin_id"
CONF_FIRMWARE_VERSION = "firmware_version"
CONF_MANUFACTURER = "manufacturer"
# An empty device information string would be sent to the server as an empty value rather than
# falling back, so reject it instead of silently substituting the fallback. The 127 byte cap keeps
# the length prefix of a protobuf string field to a single byte, matching `esphome: project:`.
DEVICE_INFO_STRING = cv.All(cv.string_strict, cv.Length(min=1), cv.ByteLength(max=127))
CONF_INITIAL_STATIC_DELAY = "initial_static_delay"
CONF_FIXED_DELAY = "fixed_delay"
CONF_DECODE_MEMORY = "decode_memory"
CONF_CODECS = "codecs"
# Matches ARTWORK_MAX_SLOTS in sendspin-cpp.
MAX_ARTWORK_SLOTS = 4
@@ -44,6 +58,20 @@ CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS")
CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM")
CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED")
CODEC_FLAC = "flac"
CODEC_OPUS = "opus"
CODEC_PCM = "pcm"
CODECS = {
CODEC_FLAC: CODEC_FORMAT_FLAC,
CODEC_OPUS: CODEC_FORMAT_OPUS,
CODEC_PCM: CODEC_FORMAT_PCM,
}
# Opus only supports 48 kHz audio, so it is left out of the default list at other rates.
DEFAULT_CODECS = [CODEC_FLAC, CODEC_OPUS, CODEC_PCM]
OPUS_SAMPLE_RATE = 48000
SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True)
IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG")
IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG")
@@ -183,6 +211,9 @@ CONFIG_SCHEMA = cv.All(
{
cv.GenerateID(): cv.declare_id(SendspinHub),
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
cv.Optional(CONF_MANUFACTURER): DEVICE_INFO_STRING,
cv.Optional(CONF_MODEL): DEVICE_INFO_STRING,
cv.Optional(CONF_FIRMWARE_VERSION): DEVICE_INFO_STRING,
}
),
cv.only_on_esp32,
@@ -233,6 +264,22 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_task_stack_in_psram(True))
psram.request_external_task_stack()
# Device information for the server's client/hello message. Falls back to the project
# information, which is written as `manufacturer.model`. Anything still unset keeps the
# default the hub itself applies: the ESPHome name and version.
project = CORE.config[CONF_ESPHOME].get(CONF_PROJECT, {})
project_manufacturer, _, project_model = project.get(CONF_NAME, "").partition(".")
for value, setter in (
(config.get(CONF_MANUFACTURER) or project_manufacturer, var.set_manufacturer),
(config.get(CONF_MODEL) or project_model, var.set_model),
(
config.get(CONF_FIRMWARE_VERSION) or project.get(CONF_VERSION),
var.set_firmware_version,
),
):
if value:
cg.add(setter(value))
# sendspin-cpp library
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.2")
@@ -286,16 +333,13 @@ async def to_code(config: ConfigType) -> None:
if data.player_support:
cg.add_define("USE_SENDSPIN_PLAYER", True)
# Configures the player role. We always assume support for 16 bits per sample mono and stereo FLAC, Opus, and PCM at the configured sample rate
# (with Opus only supported at 48 kHz since that's the only sample rate it supports). Users can configure the specific formats via the Sendspin server
# Configures the player role. Each configured codec is advertised for 16 bits per sample
# mono and stereo at the configured sample rate. The order is a preference order, both for
# the codecs themselves and for stereo over mono.
player_cfg = data.player_config
sample_rate = player_cfg[CONF_SAMPLE_RATE]
# OPUS only supports 48 kHz audio
codecs = [CODEC_FORMAT_FLAC]
if sample_rate == 48000:
codecs.append(CODEC_FORMAT_OPUS)
codecs.append(CODEC_FORMAT_PCM)
codecs = [CODECS[codec] for codec in player_cfg[CONF_CODECS]]
def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer:
return cg.StructInitializer(
@@ -13,11 +13,16 @@ from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType
from .. import (
CODEC_OPUS,
CODECS,
CONF_CODECS,
CONF_DECODE_MEMORY,
CONF_FIXED_DELAY,
CONF_INITIAL_STATIC_DELAY,
CONF_SENDSPIN_ID,
DEFAULT_CODECS,
MEMORY_LOCATIONS,
OPUS_SAMPLE_RATE,
SendspinHub,
register_player_config,
request_controller_support,
@@ -49,10 +54,32 @@ DisableStaticDelayAdjustmentAction = sendspin_ns.class_(
)
def _resolve_codecs(config: ConfigType) -> ConfigType:
"""Validate the codec preference list, filling in the default when it is not set."""
sample_rate = config[CONF_SAMPLE_RATE]
if (codecs := config.get(CONF_CODECS)) is None:
config[CONF_CODECS] = [
codec
for codec in DEFAULT_CODECS
if codec != CODEC_OPUS or sample_rate == OPUS_SAMPLE_RATE
]
return config
if len(set(codecs)) != len(codecs):
raise cv.Invalid("Each codec may only be listed once", path=[CONF_CODECS])
if CODEC_OPUS in codecs and sample_rate != OPUS_SAMPLE_RATE:
raise cv.Invalid(
f"Codec '{CODEC_OPUS}' requires a {CONF_SAMPLE_RATE} of {OPUS_SAMPLE_RATE}",
path=[CONF_CODECS],
)
return config
def _register(config: ConfigType) -> ConfigType:
request_controller_support()
register_player_config(
{
CONF_CODECS: config[CONF_CODECS],
CONF_SAMPLE_RATE: config[CONF_SAMPLE_RATE],
CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE],
CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY],
@@ -85,9 +112,13 @@ CONFIG_SCHEMA = cv.All(
min=16000, max=96000
),
cv.Optional(CONF_DECODE_MEMORY): cv.one_of(*MEMORY_LOCATIONS, lower=True),
cv.Optional(CONF_CODECS): cv.All(
cv.ensure_list(cv.enum(CODECS, lower=True)), cv.Length(min=1)
),
}
),
cv.only_on_esp32,
_resolve_codecs,
_register,
)
+12 -4
View File
@@ -76,8 +76,12 @@ void SendspinHub::dump_config() {
ESP_LOGCONFIG(TAG,
"Sendspin Hub:\n"
" Client ID: %s\n"
" Manufacturer: %s\n"
" Model: %s\n"
" Firmware version: %s\n"
" Task stack in PSRAM: %s",
get_client_id_into_buffer(mac_buf), YESNO(this->task_stack_in_psram_));
get_client_id_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(),
this->firmware_version_, YESNO(this->task_stack_in_psram_));
#ifdef USE_SENDSPIN_ARTWORK
// Slot indices come from the order the image platform entries were declared, so the log is the
@@ -127,15 +131,19 @@ const char *SendspinHub::get_client_id_into_buffer(std::span<char, MAC_ADDRESS_P
return get_mac_address_pretty_into_buffer(buf);
}
const char *SendspinHub::get_product_name_() const {
return this->model_ != nullptr ? this->model_ : App.get_name().c_str();
}
sendspin::SendspinClientConfig SendspinHub::build_client_config_() {
sendspin::SendspinClientConfig config;
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
config.client_id = SendspinHub::get_client_id_into_buffer(mac_buf);
config.name = App.get_friendly_name();
config.product_name = App.get_name();
config.manufacturer = "ESPHome";
config.software_version = ESPHOME_VERSION;
config.product_name = this->get_product_name_();
config.manufacturer = this->manufacturer_;
config.software_version = this->firmware_version_;
config.httpd_psram_stack = this->task_stack_in_psram_;
return config;
@@ -8,6 +8,7 @@
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/core/preferences.h"
#include "esphome/core/version.h"
#include <sendspin/client.h>
#include <sendspin/config.h>
@@ -125,6 +126,15 @@ class SendspinHub final : public Component,
void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; }
/// @brief Sets the device information reported to the server in the `client/hello` message.
///
/// Each takes a pointer to a string literal emitted by codegen, so it must stay valid for the
/// lifetime of the hub. Only called for values the configuration overrides; anything left alone
/// keeps the default described on the member below.
void set_manufacturer(const char *manufacturer) { this->manufacturer_ = manufacturer; }
void set_model(const char *model) { this->model_ = model; }
void set_firmware_version(const char *firmware_version) { this->firmware_version_ = firmware_version; }
// --- Sendspin role specific methods ---
#ifdef USE_SENDSPIN_ARTWORK
@@ -187,6 +197,9 @@ class SendspinHub final : public Component,
/// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info.
sendspin::SendspinClientConfig build_client_config_();
/// @brief Returns the product name reported to the server: the configured model, or the device name.
const char *get_product_name_() const;
/// @brief Writes the active network interface's MAC into @p buf and returns its data pointer.
/// Uses the ethernet MAC if ethernet is configured, otherwise the base MAC (used by wifi).
static const char *get_client_id_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf);
@@ -268,6 +281,12 @@ class SendspinHub final : public Component,
CallbackManager<void(const sendspin::GroupUpdateObject &)> group_update_callbacks_{};
bool task_stack_in_psram_{false};
// Device information sent in the `client/hello` message. Defaults apply when neither the
// sendspin configuration nor the project information supplies a value.
const char *manufacturer_{"ESPHome"};
const char *model_{nullptr}; // nullptr reports the device name instead
const char *firmware_version_{ESPHOME_VERSION};
};
/// @brief Base class for all sendspin subcomponents.
@@ -30,6 +30,7 @@ MULTI_CONF = True
serial_proxy_ns = cg.esphome_ns.namespace("serial_proxy")
SerialProxy = serial_proxy_ns.class_("SerialProxy", cg.Component, uart.UARTDevice)
SerialProxyTap = serial_proxy_ns.class_("SerialProxyTap")
api_enums_ns = cg.esphome_ns.namespace("api").namespace("enums")
SerialProxyPortType = api_enums_ns.enum("SerialProxyPortType")
+160 -22
View File
@@ -29,26 +29,57 @@ void SerialProxy::setup() {
#ifdef USE_API
// instance_index_ is fixed at registration time; pre-set it so loop() only needs to update data
this->outgoing_msg_.instance = this->instance_index_;
#endif
#ifdef USE_SERIAL_PROXY_TAP
// A tap sets itself up before this runs (its setup priority is higher), so it may
// already be waiting on the port -- a boot-time handshake with the device, say. Leaving
// the loop enabled is what lets that finish; without it the tap would stall until a
// client happened to subscribe.
if (this->tap_ != nullptr && this->tap_->tap_needs_port()) {
return;
}
#endif
// No subscriber at startup; disable loop until a client subscribes
this->disable_loop();
}
void SerialProxy::loop() {
#ifdef USE_API
// Safety check — loop should only run when subscribed, but guard against races
if (this->api_connection_ == nullptr) [[unlikely]] {
this->disable_loop();
#ifdef USE_SERIAL_PROXY_TAP
void SerialProxy::reset_mode_() {
// The mode belongs to a session, not to the port. Carrying a departed client's choice
// over to the next one would inject protocol bytes into a stream that never asked for
// them -- a firmware upload, or any client built before this request existed and so
// unable to turn it off. Guessing RAW is the safe direction: a client that wanted
// protocol handling and did not ask for it merely sends its own acknowledgements.
if (this->mode_ == api::enums::SERIAL_PROXY_MODE_RAW) {
return;
}
ESP_LOGD(TAG, "Session ended, returning serial proxy [%" PRIu32 "] to RAW mode", this->instance_index_);
this->mode_ = api::enums::SERIAL_PROXY_MODE_RAW;
}
#endif
void SerialProxy::loop() {
#ifdef USE_API
// Detect subscriber disconnect
if (this->api_connection_->is_marked_for_removal() || !this->api_connection_->is_connection_setup() ||
!api_is_connected()) {
if (this->api_connection_ != nullptr && (this->api_connection_->is_marked_for_removal() ||
!this->api_connection_->is_connection_setup() || !api_is_connected())) {
ESP_LOGW(TAG, "Subscriber disconnected");
this->api_connection_ = nullptr;
this->reset_mode_();
}
// With no subscriber there is normally nothing to do, but a tap may still need the port
// read -- it does its protocol work precisely while nobody else is listening.
if (this->api_connection_ == nullptr) [[unlikely]] {
#ifdef USE_SERIAL_PROXY_TAP
if (this->tap_ == nullptr || !this->tap_->tap_needs_port()) {
this->disable_loop();
return;
}
#else
this->disable_loop();
return;
#endif
}
// Read available data from UART and forward to subscribed client
@@ -69,11 +100,54 @@ void __attribute__((noinline)) SerialProxy::read_and_send_(size_t available) {
if (!this->read_array(buffer, to_read))
return;
#ifdef USE_SERIAL_PROXY_TAP
// Before forwarding, so a tap that answers the device (an acknowledgement, say) is not
// waiting on the network round trip to a subscriber that may not even exist.
if (this->tap_observing_()) {
this->tap_->on_device_rx(buffer, to_read);
}
#endif
if (this->api_connection_ == nullptr) {
return;
}
this->outgoing_msg_.set_data(buffer, to_read);
this->api_connection_->send_serial_proxy_data(this->outgoing_msg_);
}
#endif
#ifdef USE_SERIAL_PROXY_TAP
bool SerialProxy::tap_observing_() const {
if (this->tap_ == nullptr) {
return false;
}
// With no subscriber, a tap doing its own protocol work (the boot-time handshake with
// the device, say) is served regardless of mode -- nobody has chosen one yet. Once a
// subscriber holds the port, the mode alone decides, so RAW stays inert.
if (this->api_connection_ == nullptr && this->tap_->tap_needs_port()) {
return true;
}
// Otherwise the mode decides. RAW must be inert: a client that flips to RAW before
// flashing firmware is entitled to a byte pipe with nothing injecting protocol bytes
// into it, and "the tap turned out not to recognise the stream" is not good enough.
return this->mode_ == api::enums::SERIAL_PROXY_MODE_PROTOCOL;
}
void SerialProxy::tap_pump() {
#ifdef USE_API
// Nothing would consume the bytes; leave them in the FIFO
if (!this->tap_observing_() && this->api_connection_ == nullptr) {
return;
}
const size_t available = this->available();
if (available > 0) {
this->read_and_send_(available);
}
#endif
}
#endif
void SerialProxy::dump_config() {
ESP_LOGCONFIG(TAG,
"Serial Proxy [%" PRIu32 "]:\n"
@@ -92,8 +166,9 @@ void SerialProxy::dump_config() {
SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control,
uint8_t parity, uint8_t stop_bits, uint8_t data_size) {
#ifdef USE_API
if (this->port_claimed_by_other_(api_connection)) {
ESP_LOGW(TAG, "Ignoring configure request from client without port access [%" PRIu32 "]", this->instance_index_);
if (!this->is_subscriber_(api_connection)) {
ESP_LOGW(TAG, "Ignoring configure request from client without port subscription [%" PRIu32 "]",
this->instance_index_);
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
}
#endif
@@ -159,24 +234,80 @@ SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uin
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
SerialProxyResult SerialProxy::set_mode_from_client(api::APIConnection *api_connection,
api::enums::SerialProxyMode mode) {
#ifdef USE_API
// Only the live subscriber may change the mode, so the mode cannot outlive a session
if (!this->is_subscriber_(api_connection)) {
ESP_LOGW(TAG, "Ignoring mode request from client without port subscription [%" PRIu32 "]", this->instance_index_);
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
}
#endif
// Values come from a remote client
if (mode != api::enums::SERIAL_PROXY_MODE_RAW && mode != api::enums::SERIAL_PROXY_MODE_PROTOCOL) {
ESP_LOGW(TAG, "Invalid mode: %" PRIu32, static_cast<uint32_t>(mode));
return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT;
}
// PROTOCOL on a port with no tap would be a silent no-op; refuse so the client knows
#ifdef USE_SERIAL_PROXY_TAP
const bool has_tap = this->tap_ != nullptr;
#else
const bool has_tap = false;
#endif
if (mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL && !has_tap) {
ESP_LOGW(TAG, "No tap on serial proxy [%" PRIu32 "]; PROTOCOL mode unavailable", this->instance_index_);
return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED;
}
ESP_LOGD(TAG, "Serial proxy [%" PRIu32 "] mode set to %s", this->instance_index_,
mode == api::enums::SERIAL_PROXY_MODE_PROTOCOL ? LOG_STR_LITERAL("PROTOCOL") : LOG_STR_LITERAL("RAW"));
#ifdef USE_SERIAL_PROXY_TAP
const bool leaving_protocol_mode =
this->mode_ != api::enums::SERIAL_PROXY_MODE_RAW && mode == api::enums::SERIAL_PROXY_MODE_RAW;
this->mode_ = mode;
// Only for an explicit client request, not for reset_mode_() at the end of a session:
// an ordinary disconnect says nothing about the device, whereas a client deliberately
// asking for raw bytes usually precedes changing what the device is.
if (leaving_protocol_mode && this->tap_ != nullptr) {
this->tap_->on_protocol_disabled();
}
#endif
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
void SerialProxy::write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {
#ifdef USE_API
// Bytes from a client other than the live subscriber would interleave with the
// subscriber's traffic on the wire
if (this->port_claimed_by_other_(api_connection)) {
ESP_LOGW(TAG, "Ignoring write from client without port access [%" PRIu32 "]", this->instance_index_);
// Bytes from anyone but the live subscriber would interleave with the subscriber's
// traffic -- or with an active tap's -- on the wire
if (!this->is_subscriber_(api_connection)) {
if (this->api_connection_ != nullptr) {
ESP_LOGW(TAG, "Ignoring write from client that does not hold serial proxy [%" PRIu32 "]", this->instance_index_);
} else {
// A legacy client streaming writes without subscribing would flood WARN, one per
// request; writes are the only high-rate, unacknowledged operation, so keep this
// visible without drowning the log
ESP_LOGV(TAG, "Ignoring write from client without port subscription [%" PRIu32 "]", this->instance_index_);
}
return;
}
#endif
if (data == nullptr || len == 0)
return;
this->write_array(data, len);
#ifdef USE_SERIAL_PROXY_TAP
// After the write, so the tap observes the same ordering the device does
if (this->tap_observing_()) {
this->tap_->on_client_tx(data, len);
}
#endif
}
SerialProxyResult SerialProxy::set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {
#ifdef USE_API
if (this->port_claimed_by_other_(api_connection)) {
ESP_LOGW(TAG, "Ignoring modem pin request from client without port access [%" PRIu32 "]", this->instance_index_);
if (!this->is_subscriber_(api_connection)) {
ESP_LOGW(TAG, "Ignoring modem pin request from client without port subscription [%" PRIu32 "]",
this->instance_index_);
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
}
#endif
@@ -210,8 +341,8 @@ uint32_t SerialProxy::get_modem_pins() const {
SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) {
#ifdef USE_API
// Flushing stalls the port, so it gets the same ownership check as writes
if (this->port_claimed_by_other_(api_connection)) {
ESP_LOGW(TAG, "Ignoring flush from client without port access [%" PRIu32 "]", this->instance_index_);
if (!this->is_subscriber_(api_connection)) {
ESP_LOGW(TAG, "Ignoring flush from client without port subscription [%" PRIu32 "]", this->instance_index_);
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
}
#endif
@@ -230,11 +361,6 @@ SerialProxyResult SerialProxy::flush_port(api::APIConnection *api_connection) {
}
#ifdef USE_API
bool SerialProxy::port_claimed_by_other_(api::APIConnection *api_connection) const {
return this->api_connection_ != nullptr && this->api_connection_ != api_connection &&
this->api_connection_->is_connection_setup();
}
SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_connection,
api::enums::SerialProxyRequestType type) {
switch (type) {
@@ -252,6 +378,10 @@ SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_conn
return SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE;
}
ESP_LOGW(TAG, "Previous subscriber disconnected; taking over subscription");
// End the dead client's session before starting the new one, so its mode
// cannot leak into a session that never asked for it
this->api_connection_ = nullptr;
this->reset_mode_();
}
this->api_connection_ = api_connection;
this->enable_loop();
@@ -264,7 +394,15 @@ SerialProxyResult SerialProxy::serial_proxy_request(api::APIConnection *api_conn
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
this->api_connection_ = nullptr;
this->reset_mode_();
#ifdef USE_SERIAL_PROXY_TAP
// Keep the loop alive for a tap that still needs the port (mirrors loop())
if (this->tap_ == nullptr || !this->tap_->tap_needs_port()) {
this->disable_loop();
}
#else
this->disable_loop();
#endif
ESP_LOGV(TAG, "API connection unsubscribed from serial proxy [%" PRIu32 "]", this->instance_index_);
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
default:
+100 -3
View File
@@ -26,6 +26,7 @@ class APIConnection;
namespace enums {
enum SerialProxyPortType : uint32_t;
enum SerialProxyRequestType : uint32_t;
enum SerialProxyMode : uint32_t;
} // namespace enums
} // namespace esphome::api
@@ -52,6 +53,36 @@ enum class SerialProxyResult : uint8_t {
/// Maximum bytes to read from UART in a single loop iteration
inline constexpr size_t SERIAL_PROXY_MAX_READ_SIZE = 256;
#ifdef USE_SERIAL_PROXY_TAP
/// Observes a port's traffic without owning it, and may inject bytes of its own.
///
/// This exists so protocol-aware behaviour can be layered onto a plain byte pipe without
/// the pipe knowing anything about the protocol: the tap is compiled in only when some
/// component asks for one, so a proxy carrying an RS485 meter pays nothing for it.
///
/// A tap is an observer, never a gatekeeper -- it cannot suppress or alter the bytes
/// flowing in either direction, so a misbehaving tap cannot corrupt the stream.
class SerialProxyTap {
public:
/// Bytes read from the device, before they are forwarded to any subscriber.
virtual void on_device_rx(const uint8_t *data, size_t len) = 0;
/// Bytes a subscriber sent towards the device, after they have been written.
virtual void on_client_tx(const uint8_t *data, size_t len) = 0;
/// True when the port must keep reading even with no subscriber attached, so a tap can
/// do its own protocol work while nobody is listening. Honoured only while no
/// subscriber holds the port; with one attached, the port mode alone decides.
virtual bool tap_needs_port() const = 0;
/// A client explicitly turned protocol handling off for this port. Distinct from the
/// automatic reset when a session ends: this one means a client intends to do something
/// else with the device -- reflash it, most likely -- so anything the tap believes about
/// it should be treated as suspect.
virtual void on_protocol_disabled() = 0;
};
#endif
class SerialProxy final : public uart::UARTDevice, public Component {
public:
void setup() override;
@@ -77,6 +108,9 @@ class SerialProxy final : public uart::UARTDevice, public Component {
/// Get the port type
api::enums::SerialProxyPortType get_port_type() const { return this->port_type_; }
/// Handle a mode change requested by an API client
SerialProxyResult set_mode_from_client(api::APIConnection *api_connection, api::enums::SerialProxyMode mode);
/// Configure UART parameters and apply them
/// @param api_connection The API connection requesting the change
/// @param baudrate Baud rate in bits per second
@@ -121,13 +155,67 @@ class SerialProxy final : public uart::UARTDevice, public Component {
/// Set the DTR GPIO pin (from YAML configuration)
void set_dtr_pin(GPIOPin *pin) { this->dtr_pin_ = pin; }
#ifdef USE_SERIAL_PROXY_TAP
/// Attach a traffic observer. At most one, set once at setup time.
void set_tap(SerialProxyTap *tap) { this->tap_ = tap; }
/// Write bytes originating from the tap rather than from a client. Bypasses the
/// subscriber ownership check, but only while the tap is being served bytes -- so a
/// port in RAW mode with a subscriber attached stays inert. Returns false when the
/// bytes were dropped for that reason.
bool write_from_tap(const uint8_t *data, size_t len) {
if (!this->tap_observing_()) {
return false;
}
this->write_array(data, len);
return true;
}
/// Whether the tap is currently being served bytes. Can flip false with no callback
/// (a subscriber attaching in RAW mode, say), so a tap should check before starting
/// protocol work and when a reply seems overdue.
bool tap_is_observed() const { return this->tap_observing_(); }
/// Resume reading after a tap's needs change. loop() disables itself when there is
/// neither a subscriber nor a tap that wants the port, so a tap starting fresh work
/// must ask for it back. Must be called from the main loop.
void tap_request_port() { this->enable_loop(); }
/// Whether the underlying device is present. On a USB UART this tracks enumeration, so
/// a tap can notice the device being unplugged and plugged back in.
bool is_device_connected() const { return this->parent_->is_connected(); }
/// Run one read-and-dispatch cycle immediately. Lets a tap make progress before the
/// main loop is running -- during setup, for instance, while a component is still
/// blocking on can_proceed(). Must not be called from on_device_rx() or
/// on_client_tx(): each nested cycle costs a 256-byte stack frame.
void tap_pump();
#endif
protected:
#ifdef USE_API
/// Read from UART and send to API client (slow path with 256-byte stack buffer)
/// Read from UART, hand the bytes to any tap, and forward them to a subscriber
/// (slow path with a 256-byte stack buffer)
void read_and_send_(size_t available);
/// True when a live subscriber other than the given connection holds the port
bool port_claimed_by_other_(api::APIConnection *api_connection) const;
/// True when the given connection is the live subscriber. Every port operation
/// (write, configure, modem pins, flush, mode) requires this, so an unsubscribed
/// client can never share the wire with the subscriber or an active tap.
bool is_subscriber_(api::APIConnection *api_connection) const { return this->api_connection_ == api_connection; }
#endif
#ifdef USE_SERIAL_PROXY_TAP
/// Return the port to RAW when a subscriber goes away, so the mode never outlives it
void reset_mode_();
#else
/// Without a tap, PROTOCOL is refused, so the mode is fixed at RAW and there is
/// nothing to reset
void reset_mode_() {}
#endif
#ifdef USE_SERIAL_PROXY_TAP
/// True when the tap should be shown the traffic passing through this port
bool tap_observing_() const;
#endif
/// Instance index for identifying this proxy in API messages
@@ -147,6 +235,11 @@ class SerialProxy final : public uart::UARTDevice, public Component {
/// Port type
api::enums::SerialProxyPortType port_type_{};
#ifdef USE_SERIAL_PROXY_TAP
/// How the bytes passing through are treated; zero is SERIAL_PROXY_MODE_RAW
api::enums::SerialProxyMode mode_{};
#endif
/// Optional GPIO pins for modem control
GPIOPin *rts_pin_{nullptr};
GPIOPin *dtr_pin_{nullptr};
@@ -154,6 +247,10 @@ class SerialProxy final : public uart::UARTDevice, public Component {
/// Current modem pin states
bool rts_state_{false};
bool dtr_state_{false};
#ifdef USE_SERIAL_PROXY_TAP
SerialProxyTap *tap_{nullptr};
#endif
};
} // namespace esphome::serial_proxy
@@ -202,8 +202,15 @@ AudioPipelineState AudioPipeline::process_state() {
if (!this->is_playing_) {
// The tasks have been stopped for two ``process_state`` calls in a row, so delete the tasks
if (this->read_task_.is_created() || this->decode_task_.is_created()) {
this->read_task_.deallocate();
this->decode_task_.deallocate();
// Both are attempted every time; a task that is still running on the other core is freed by a
// subsequent call, and freeing an already freed task succeeds without doing anything
bool read_task_freed = this->read_task_.deallocate();
bool decode_task_freed = this->decode_task_.deallocate();
if (!read_task_freed || !decode_task_freed) {
// A task is still running on the other core, so keep the pipeline in its current state and try
// again on the next call
return AudioPipelineState::PLAYING;
}
if (this->hard_stop_) {
// Stop command was sent, so immediately end the playback
this->speaker_->stop();
@@ -315,17 +322,17 @@ void AudioPipeline::read_task(void *params) {
if (err == ESP_OK) {
size_t file_ring_buffer_size = this_pipeline->buffer_size_;
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer;
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this_pipeline->raw_file_ring_buffer_.lock();
if (!this_pipeline->raw_file_ring_buffer_.use_count()) {
if (temp_ring_buffer == nullptr) {
temp_ring_buffer = ring_buffer::RingBuffer::create(file_ring_buffer_size);
this_pipeline->raw_file_ring_buffer_ = temp_ring_buffer;
}
if (!this_pipeline->raw_file_ring_buffer_.use_count()) {
if (temp_ring_buffer == nullptr) {
err = ESP_ERR_NO_MEM;
} else {
reader->add_sink(this_pipeline->raw_file_ring_buffer_);
err = reader->add_sink(temp_ring_buffer);
}
}
@@ -396,7 +403,9 @@ void AudioPipeline::decode_task(void *params) {
make_unique<audio::AudioDecoder>(this_pipeline->transfer_buffer_size_, this_pipeline->transfer_buffer_size_);
esp_err_t err = decoder->start(this_pipeline->current_audio_file_type_);
decoder->add_source(this_pipeline->raw_file_ring_buffer_);
if (err == ESP_OK) {
err = decoder->add_source(this_pipeline->raw_file_ring_buffer_);
}
if (err != ESP_OK) {
// Send specific error message
@@ -2,7 +2,13 @@ from esphome import automation
import esphome.codegen as cg
from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.const import CONF_CONDITION, CONF_ID, CONF_LAMBDA, CONF_STATE
from esphome.const import (
CONF_CONDITION,
CONF_DEVICE_CLASS,
CONF_ID,
CONF_LAMBDA,
CONF_STATE,
)
from esphome.cpp_generator import LambdaExpression
from .. import template_ns
@@ -12,7 +18,11 @@ TemplateBinarySensor = template_ns.class_(
)
CONFIG_SCHEMA = (
binary_sensor.binary_sensor_schema(TemplateBinarySensor)
cv.with_visibility(
binary_sensor.binary_sensor_schema(TemplateBinarySensor),
cv.Visibility.UI,
CONF_DEVICE_CLASS,
)
.extend(
{
cv.Exclusive(CONF_LAMBDA, CONF_CONDITION): cv.returning_lambda,
@@ -1,10 +1,14 @@
from esphome.components import button
import esphome.config_validation as cv
from esphome.const import CONF_DEVICE_CLASS
from .. import template_ns
TemplateButton = template_ns.class_("TemplateButton", button.Button)
CONFIG_SCHEMA = button.button_schema(TemplateButton)
CONFIG_SCHEMA = cv.with_visibility(
button.button_schema(TemplateButton), cv.Visibility.UI, CONF_DEVICE_CLASS
)
async def to_code(config):
@@ -6,6 +6,7 @@ from esphome.const import (
CONF_ASSUMED_STATE,
CONF_CLOSE_ACTION,
CONF_CURRENT_OPERATION,
CONF_DEVICE_CLASS,
CONF_ID,
CONF_LAMBDA,
CONF_OPEN_ACTION,
@@ -38,7 +39,11 @@ CONF_HAS_POSITION = "has_position"
CONF_TOGGLE_ACTION = "toggle_action"
CONFIG_SCHEMA = (
cover.cover_schema(TemplateCover)
cv.with_visibility(
cover.cover_schema(TemplateCover),
cv.Visibility.UI,
CONF_DEVICE_CLASS,
)
.extend(
{
cv.Optional(CONF_LAMBDA): cv.returning_lambda,
@@ -1,7 +1,7 @@
import esphome.codegen as cg
from esphome.components import event
import esphome.config_validation as cv
from esphome.const import CONF_EVENT_TYPES
from esphome.const import CONF_DEVICE_CLASS, CONF_EVENT_TYPES
from .. import template_ns
@@ -9,7 +9,9 @@ CODEOWNERS = ["@nohat"]
TemplateEvent = template_ns.class_("TemplateEvent", event.Event, cg.Component)
CONFIG_SCHEMA = event.event_schema(TemplateEvent).extend(
CONFIG_SCHEMA = cv.with_visibility(
event.event_schema(TemplateEvent), cv.Visibility.UI, CONF_DEVICE_CLASS
).extend(
{
cv.Required(CONF_EVENT_TYPES): cv.ensure_list(cv.string_strict),
}
@@ -3,6 +3,7 @@ import esphome.codegen as cg
from esphome.components import number
import esphome.config_validation as cv
from esphome.const import (
CONF_DEVICE_CLASS,
CONF_ID,
CONF_INITIAL_VALUE,
CONF_LAMBDA,
@@ -12,6 +13,7 @@ from esphome.const import (
CONF_RESTORE_VALUE,
CONF_SET_ACTION,
CONF_STEP,
CONF_UNIT_OF_MEASUREMENT,
)
from .. import template_ns
@@ -46,7 +48,12 @@ def validate(config):
CONFIG_SCHEMA = cv.All(
number.number_schema(TemplateNumber)
cv.with_visibility(
number.number_schema(TemplateNumber),
cv.Visibility.UI,
CONF_DEVICE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
)
.extend(
{
cv.Required(CONF_MAX_VALUE): cv.float_,
+18 -4
View File
@@ -2,7 +2,16 @@ from esphome import automation
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_LAMBDA, CONF_STATE
from esphome.const import (
CONF_ACCURACY_DECIMALS,
CONF_DEVICE_CLASS,
CONF_FORCE_UPDATE,
CONF_ID,
CONF_LAMBDA,
CONF_STATE,
CONF_STATE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
)
from .. import template_ns
@@ -11,9 +20,14 @@ TemplateSensor = template_ns.class_(
)
CONFIG_SCHEMA = (
sensor.sensor_schema(
TemplateSensor,
accuracy_decimals=1,
cv.with_visibility(
sensor.sensor_schema(TemplateSensor, accuracy_decimals=1),
cv.Visibility.UI,
CONF_UNIT_OF_MEASUREMENT,
CONF_ACCURACY_DECIMALS,
CONF_DEVICE_CLASS,
CONF_STATE_CLASS,
CONF_FORCE_UPDATE,
)
.extend(
{
@@ -4,6 +4,7 @@ from esphome.components import switch
import esphome.config_validation as cv
from esphome.const import (
CONF_ASSUMED_STATE,
CONF_DEVICE_CLASS,
CONF_ID,
CONF_LAMBDA,
CONF_OPTIMISTIC,
@@ -31,7 +32,11 @@ def validate(config):
CONFIG_SCHEMA = cv.All(
switch.switch_schema(TemplateSwitch)
cv.with_visibility(
switch.switch_schema(TemplateSwitch),
cv.Visibility.UI,
CONF_DEVICE_CLASS,
)
.extend(
{
cv.Optional(CONF_LAMBDA): cv.returning_lambda,
@@ -3,7 +3,7 @@ import esphome.codegen as cg
from esphome.components import text_sensor
from esphome.components.text_sensor import TextSensorPublishAction
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_LAMBDA, CONF_STATE
from esphome.const import CONF_DEVICE_CLASS, CONF_ID, CONF_LAMBDA, CONF_STATE
from .. import template_ns
@@ -12,7 +12,11 @@ TemplateTextSensor = template_ns.class_(
)
CONFIG_SCHEMA = (
text_sensor.text_sensor_schema()
cv.with_visibility(
text_sensor.text_sensor_schema(),
cv.Visibility.UI,
CONF_DEVICE_CLASS,
)
.extend(
{
cv.GenerateID(): cv.declare_id(TemplateTextSensor),
@@ -6,6 +6,7 @@ from esphome.const import (
CONF_ASSUMED_STATE,
CONF_CLOSE_ACTION,
CONF_CURRENT_OPERATION,
CONF_DEVICE_CLASS,
CONF_ID,
CONF_LAMBDA,
CONF_OPEN_ACTION,
@@ -36,7 +37,11 @@ CONF_HAS_POSITION = "has_position"
CONF_TOGGLE_ACTION = "toggle_action"
CONFIG_SCHEMA = (
valve.valve_schema(TemplateValve)
cv.with_visibility(
valve.valve_schema(TemplateValve),
cv.Visibility.UI,
CONF_DEVICE_CLASS,
)
.extend(
{
cv.Optional(CONF_LAMBDA): cv.returning_lambda,
@@ -1,6 +1,7 @@
#include "zigbee_time_zephyr.h"
#if defined(USE_ZIGBEE) && defined(USE_NRF52) && defined(USE_TIME)
#include "esphome/core/log.h"
#include "esphome/core/application.h"
namespace esphome::zigbee {
@@ -47,6 +48,7 @@ void ZigbeeTime::set_epoch_time(uint32_t epoch) {
this->synchronize_epoch_(epoch);
this->has_time_ = true;
});
App.wake_loop_threadsafe();
}
void ZigbeeTime::zcl_device_cb_(zb_bufid_t bufid) {
+4 -1
View File
@@ -49,7 +49,8 @@ void ZigbeeComponent::factory_reset() {
void ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(ezb_bdb_comm_mode_mask_t mode) {
if (!esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) {
global_zigbee->set_timeout("zb_init", 10, [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); });
global_zigbee->set_timeout("zb_init", 100, [mode]() { ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(mode); });
App.wake_loop_threadsafe();
return;
}
if (ezb_bdb_start_top_level_commissioning(mode) != EZB_ERR_NONE) {
@@ -88,6 +89,7 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) {
global_zigbee->set_timeout("zb_init", 1000, []() {
ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_INITIALIZATION);
});
App.wake_loop_threadsafe();
}
} break;
case EZB_BDB_SIGNAL_STEERING: {
@@ -113,6 +115,7 @@ bool ZigbeeComponent::app_signal_handler(const ezb_app_signal_t *app_signal) {
ZigbeeComponent::esp_zigbee_alarm_bdb_commissioning(EZB_BDB_MODE_NETWORK_STEERING);
});
}
App.wake_loop_threadsafe();
}
} break;
case EZB_ZDO_SIGNAL_LEAVE: {
+4 -2
View File
@@ -1,10 +1,10 @@
#include "zigbee_zephyr.h"
#if defined(USE_ZIGBEE) && defined(USE_NRF52)
#include "esphome/core/log.h"
#include "esphome/core/application.h"
#include <zephyr/settings/settings.h>
#include <zephyr/storage/flash_map.h>
#include "esphome/core/hal.h"
#include "esphome/core/wake.h"
extern "C" {
#include <zboss_api.h>
@@ -120,7 +120,7 @@ void ZigbeeComponent::zcl_device_cb(zb_bufid_t bufid) {
/* Set default response value. */
p_device_cb_param->status = RET_OK;
esphome::wake_loop_threadsafe();
App.wake_loop_threadsafe();
// endpoints are enumerated from 1
if (global_zigbee->callbacks_.size() >= endpoint) {
@@ -138,6 +138,7 @@ void ZigbeeComponent::on_join_(bool factory_new) {
ESP_LOGD(TAG, "Joined the network");
this->join_cb_.call(factory_new);
});
App.wake_loop_threadsafe();
}
void ZigbeeComponent::on_start_() {
@@ -145,6 +146,7 @@ void ZigbeeComponent::on_start_() {
ESP_LOGD(TAG, "Started zigbee stack");
this->start_cb_.call();
});
App.wake_loop_threadsafe();
}
#ifdef USE_ZIGBEE_WIPE_ON_BOOT
+32
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
from contextlib import contextmanager, suppress
import copy
from datetime import datetime
from ipaddress import (
AddressValueError,
@@ -419,6 +420,37 @@ class Required(vol.Required):
self.visibility: Visibility | None = visibility
def with_visibility(schema: Schema, visibility: Visibility, *keys: str) -> Schema:
"""Return a copy of ``schema`` with the given ``keys`` re-marked at ``visibility``.
Lets a platform override the editor :class:`Visibility` of fields it
inherits from a shared schema builder without that builder needing a
visibility parameter of its own. The canonical use is a ``template``
platform promoting the value metadata its user is expected to define
(``device_class``, ``unit_of_measurement``, ) onto the main form:
CONFIG_SCHEMA = cv.with_visibility(
sensor.sensor_schema(TemplateSensor),
cv.Visibility.UI,
CONF_DEVICE_CLASS, CONF_UNIT_OF_MEASUREMENT,
)
The original marker's key, default and validator are preserved; only the
visibility changes, and the input ``schema`` is left untouched. Raises if
a requested key is not present so typos fail at schema-build time.
"""
wanted = {str(k) for k in keys}
overrides = {}
for marker, validator in schema.schema.items():
if str(marker) in wanted:
marker = copy.copy(marker)
marker.visibility = visibility
overrides[marker] = validator
if missing := wanted - {str(m) for m in overrides}:
raise ValueError(f"with_visibility: keys not in schema: {sorted(missing)}")
return schema.extend(overrides)
class FinalExternalInvalid(Invalid):
"""Represents an invalid value in the final validation phase where the path should not be prepended."""
+1 -8
View File
@@ -555,14 +555,7 @@ NATIVE_ARDUINO_PIO_OPTIONS = frozenset({"board_build.f_cpu", "board_build.ldscri
# that is stored rather than translated away. Consumed by the esp8266 native
# backend (later in this chain) for its ignored-option warning; defined here
# so it stays adjacent to the routing.
# build_src_flags and board_build.flash_mode: set unconditionally by
# esp8266/__init__ and read by the native generator; not user-routable, so
# not in the set above
NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS = NATIVE_ARDUINO_PIO_OPTIONS | {
"lib_ignore",
"build_src_flags",
"board_build.flash_mode",
}
NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS = NATIVE_ARDUINO_PIO_OPTIONS | {"lib_ignore"}
@coroutine_with_priority(CoroPriority.FINAL)
+1
View File
@@ -181,6 +181,7 @@
#define USE_SENSOR
#define USE_SENSOR_FILTER
#define USE_SERIAL_PROXY
#define USE_SERIAL_PROXY_TAP
#define USE_SETUP_PRIORITY_OVERRIDE
#define USE_STATUS_LED
#define USE_STATUS_SENSOR
+23 -7
View File
@@ -40,16 +40,31 @@ bool StaticTask::create(TaskFunction_t fn, const char *name, uint32_t stack_size
return true;
}
void StaticTask::destroy() {
if (this->handle_ != nullptr) {
TaskHandle_t handle = this->handle_;
this->handle_ = nullptr;
vTaskDelete(handle);
bool StaticTask::destroy() {
if (this->handle_ == nullptr) {
return true;
}
// Suspending takes the task off the ready and event lists, so nothing can schedule it again. It only asks
// the other core to yield though, so the task may still be running on it for a moment.
vTaskSuspend(this->handle_);
if (eTaskGetState(this->handle_) != eSuspended) {
// The task is still running on the other core and using its stack. Deleting it now would only put it on
// the termination list and return, so the caller has to try again once it has been swapped out.
return false;
}
// The task cannot run again, so the delete completes right away instead of being left to the idle task.
TaskHandle_t handle = this->handle_;
this->handle_ = nullptr;
vTaskDelete(handle);
return true;
}
void StaticTask::deallocate() {
this->destroy();
bool StaticTask::deallocate() {
if (!this->destroy()) {
return false;
}
if (this->stack_buffer_ != nullptr) {
RAMAllocator<StackType_t> allocator(this->use_psram_ ? RAMAllocator<StackType_t>::ALLOC_EXTERNAL
: RAMAllocator<StackType_t>::ALLOC_INTERNAL);
@@ -57,6 +72,7 @@ void StaticTask::deallocate() {
this->stack_buffer_ = nullptr;
this->stack_size_ = 0;
}
return true;
}
} // namespace esphome
+12 -5
View File
@@ -11,6 +11,7 @@ namespace esphome {
/** Helper for FreeRTOS static task management.
* Bundles TaskHandle_t, StaticTask_t, and the stack buffer into one object with create/destroy methods.
* Call destroy() and deallocate() from another task: a task cannot free the stack it is still running on.
*/
class StaticTask {
public:
@@ -23,7 +24,7 @@ class StaticTask {
/// @brief Allocate stack and create task.
/// @param fn Task function
/// @param name Task name (for debug)
/// @param stack_size Stack size in StackType_t words
/// @param stack_size Stack size in bytes (StackType_t is a byte on ESP-IDF)
/// @param param Parameter passed to task function
/// @param priority FreeRTOS task priority
/// @param use_psram If true, allocate stack in PSRAM; otherwise internal RAM
@@ -31,11 +32,17 @@ class StaticTask {
bool create(TaskFunction_t fn, const char *name, uint32_t stack_size, void *param, UBaseType_t priority,
bool use_psram);
/// @brief Delete the task but keep the stack buffer allocated for reuse by a subsequent create() call.
void destroy();
/// @brief Delete the task, keeping the stack buffer allocated for reuse by a subsequent create() call.
/// The task must have finished its work and parked itself, either suspended or blocked indefinitely: it is
/// suspended here so that it cannot be scheduled again, and it is given no chance to clean up.
/// @return true if the task was deleted; false if it is still running on another core, in which case the
/// caller should try again later.
bool destroy();
/// @brief Delete the task (if running) and free the stack buffer.
void deallocate();
/// @brief Delete the task (if created) and free the stack buffer.
/// @return true if the stack buffer was freed; false if the task is still running on another core, in
/// which case the caller should try again later.
bool deallocate();
protected:
TaskHandle_t handle_{nullptr};
+6 -3
View File
@@ -96,6 +96,10 @@ UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8
# across the addresses on top of that.
EXTRA_UPLOAD_ATTEMPTS = 2
UPLOAD_RETRY_DELAY = 5.0
# Data phase timeout; must stay longer than the device's OTA_SOCKET_TIMEOUT_DATA
# (105 s) so a stalled session is gone before a retry, and long enough for lwIP
# to get a lost chunk ack through after the retransmit run seen in practice
DATA_PHASE_TIMEOUT = 160.0
_LOGGER = logging.getLogger(__name__)
@@ -694,8 +698,7 @@ def perform_ota(
_LOGGER.info("Handshake complete")
# Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures
sock.settimeout(90.0)
sock.settimeout(DATA_PHASE_TIMEOUT)
if extended_proto:
send_check(sock, ota_type, "ota type")
@@ -854,7 +857,7 @@ def run_ota_impl_(
# clean up a half-open connection (its handshake watchdog runs at 20s);
# moving on to the next address family stays immediate. Known limitation:
# a silent mid-transfer drop with no reset can wedge the device until its
# 90s data timeout, which outlasts this budget; the retries target the
# 105s data timeout, which outlasts this budget; the retries target the
# common failures where the device resets or closes the link promptly.
total_attempts = len(res) + EXTRA_UPLOAD_ATTEMPTS
last_error = ""
-364
View File
@@ -1,364 +0,0 @@
import hashlib
import os
from pathlib import Path
import posixpath
import re
import shlex
import stat
import subprocess
import traceback
# pylint: disable=E0602
Import("env") # noqa: F821
_projenv_error = None
try:
Import("projenv") # noqa: F821
except Exception as err: # noqa: BLE001 -- not exported under -t nobuild
projenv = None
_projenv_error = err
# Precompile the src force-includes plus defines.h and force-include the
# result into C++ src compiles only; their preprocessed output is unchanged.
# Registration is gated host-side (pch_enabled()). Keep the closure, ccache
# values, and stamp flow in sync with build_helpers/pch.py.
# Compiler failures that clear on their own must not latch the .failed marker
_TRANSIENT_ERRORS = ("No space left", "Cannot allocate", "Resource temporarily")
_INCLUDE_RE = re.compile(rb'^\s*#\s*include\s+["<]([^">]+)[">]', re.MULTILINE)
_CORE_HEADER = "esphome/core/defines.h"
def _raise_walk_error(err: OSError) -> None:
raise err
def _resolves_dir(path: Path) -> bool:
"""False when missing; other stat failures propagate."""
try:
return stat.S_ISDIR(path.stat().st_mode)
except (FileNotFoundError, NotADirectoryError):
return False
def _resolves(path: Path) -> bool:
"""False when missing; other stat failures propagate (identity unknown)."""
try:
return stat.S_ISREG(path.stat().st_mode)
except (FileNotFoundError, NotADirectoryError):
return False
def _include_closure(src_dir: Path, roots: list) -> dict:
"""Quoted-include closure: src-relative name -> contents (mirror of
build_helpers/pch.py)."""
seen = {}
stack = [(name, "") for name in roots]
while stack:
name, from_dir = stack.pop()
for candidate in (f"{from_dir}/{name}" if from_dir else name, name):
rel = posixpath.normpath(candidate)
if not rel.startswith("..") and _resolves(src_dir / rel):
break
else:
continue
if rel in seen:
continue
try:
data = (src_dir / rel).read_bytes()
except OSError as err:
# A marker would truncate the transitive walk; fail closed
print(f"ESPHome: could not read {rel} for the pch checksum: {err}")
raise
seen[rel] = data
parent = posixpath.dirname(rel)
stack.extend(
(inc.decode(errors="surrogateescape"), parent)
for inc in _INCLUDE_RE.findall(data)
)
return seen
def _shell_arg(element) -> str | None:
"""One compiler argv from one SCons element, matching the real spawn:
spaced elements pass whole, the rest get one shell unquote (skipped on
Windows, where shlex would eat path backslashes)."""
arg = str(element)
if " " in arg or os.name == "nt":
return arg.replace('\\"', '"')
if not arg.strip():
return arg
try:
tokens = shlex.split(arg)
except ValueError as err:
print(f"ESPHome: could not lex flag {arg!r} for the pch: {err}")
return None
if len(tokens) != 1:
# A flag the model cannot reproduce would diverge the .gch's flags
print(f"ESPHome: cannot model flag {arg!r} for the pch")
return None
return tokens[0]
def _compile_gch(cxx, flags, header: Path, gch: Path, proj_dir: Path):
"""Compile the .gch, then probe that the toolchain can load it back
(GCC 10 on macOS arm64 rejects its own per-process). Returns an error
string or None; OSError propagates as transient."""
result = subprocess.run( # noqa: PLW1510
[cxx, "-x", "c++-header", *flags, "-c", str(header), "-o", str(gch)],
cwd=proj_dir,
# C locale keeps diagnostics matchable by _TRANSIENT_ERRORS
env={**os.environ, "LC_ALL": "C"},
capture_output=True,
text=True,
)
if result.returncode < 0:
# Signal-killed (OOM, ^C): route to the transient no-marker path
raise OSError(f"compiler killed by signal {-result.returncode}")
if result.returncode != 0:
return result.stderr
# -MF is only legal alongside a dependency flag; pass it solely to
# redirect a depfile that -MD/-MMD in the flags would otherwise write
dep_redirect = (
["-MF", os.devnull]
if any(f in ("-MD", "-MMD", "-M", "-MM") for f in flags)
else []
)
probe = subprocess.run( # noqa: PLW1510
[
cxx,
*flags,
*dep_redirect,
"-Winvalid-pch",
"-include",
str(header),
"-fsyntax-only",
"-x",
"c++",
"-",
],
cwd=proj_dir,
env={**os.environ, "LC_ALL": "C"},
input="",
capture_output=True,
text=True,
)
if probe.returncode < 0:
raise OSError(f"probe killed by signal {-probe.returncode}")
if probe.returncode != 0 or ".gch" in probe.stderr:
return f"toolchain cannot load the pch: {probe.stderr.strip()}"
return None
def _read_stamp(path: Path) -> str:
"""A corrupt sidecar must read as stale, not kill the pch forever."""
try:
return path.read_text(encoding="utf-8").strip()
except (OSError, UnicodeDecodeError):
return ""
def _setup_pch() -> None:
if projenv is None:
# Expected under -t nobuild; anything else must leave a trail
print(f"ESPHome: projenv unavailable ({_projenv_error}); skipping pch")
return
# Project root: SCons compiles run here, so the relative -include
# resolves; an absolute path would break cross-device ccache sharing.
proj_dir = Path(env.subst("$PROJECT_DIR")) # noqa: F821
src_dir = Path(env.subst("$PROJECT_SRC_DIR")) # noqa: F821
header = proj_dir / "esphome_pch.h"
gch = Path(f"{header}.gch")
sum_path = Path(f"{gch}.sum")
cxx = projenv.subst("$CXX") # noqa: F821
# The header holds the -include entries itself, so the .gch compile must
# not see them; consumers keep theirs, which the .gch then satisfies.
flags = []
include_headers = []
raw_args = [
_shell_arg(element)
for element in projenv.subst_list("$CXXFLAGS $CCFLAGS $_CCCOMCOM")[0] # noqa: F821
]
if any(arg is None for arg in raw_args):
print("ESPHome: skipping precompiled header: unmodelable flag")
return
flag_it = iter(raw_args)
for tok in flag_it:
if tok == "-include":
include_headers.append(next(flag_it, ""))
elif tok.startswith("-include") and not tok.startswith("-include-"):
include_headers.append(tok[len("-include") :])
else:
flags.append(tok)
if any(not name for name in include_headers):
print("ESPHome: build_src_flags has a trailing -include; skipping pch")
return
# Fold only relative names resolving under src/: consumers keep their
# own -include entries, so folding an unguarded user header would
# include it twice; unfolded ones stay consumer-only.
try:
folded = [
name
for name in include_headers
if not Path(name).is_absolute() and _resolves(src_dir / name)
]
except OSError as err:
print(f"ESPHome: skipping precompiled header: {err}")
return
if unfolded := [n for n in include_headers if n not in folded]:
print(f"ESPHome: not precompiling non-src force-includes: {unfolded}")
content = "".join(f'#include "{name}"\n' for name in (*folded, _CORE_HEADER))
digest = hashlib.sha256()
digest.update(content.encode(errors="surrogateescape"))
digest.update(cxx.encode(errors="surrogateescape"))
# Mirror CCACHE_BASEDIR: strip the per-device build path so identical
# configs produce identical .sum files and share cache entries
flags_id = " ".join(flags)
if basedir := os.environ.get("CCACHE_BASEDIR"):
flags_id = flags_id.replace(basedir, "")
digest.update(flags_id.encode(errors="surrogateescape"))
# GCC never validates a .gch against its source headers, and PlatformIO
# package paths carry no version, so a package bump must invalidate here
platform = env.PioPlatform() # noqa: F821
for package in sorted(platform.packages):
try:
version = platform.get_package_version(package)
except KeyError:
# An unresolved manifest must not hash as a constant
if platform.get_package(package) is not None:
print(f"ESPHome: skipping precompiled header: no version for {package}")
return
version = None # absent optional package
except Exception as err: # noqa: BLE001
# No trustworthy package identity: a stale .gch could survive
print(f"ESPHome: skipping precompiled header: {err}")
return
digest.update(f"{package}={version}".encode())
digest.update(b"\0")
try:
closure = _include_closure(src_dir, [*folded, _CORE_HEADER])
except OSError as err:
print(f"ESPHome: skipping precompiled header: {err}")
return
for rel in sorted(closure):
digest.update(rel.encode(errors="surrogateescape"))
digest.update(closure[rel])
digest.update(b"\0")
# Project-local -I dirs (e.g. rp2's lwip_override) hold generated
# headers the src closure cannot see; hash them too
prev = ""
try:
for tok in flags:
inc = tok[2:] if tok.startswith("-I") and len(tok) > 2 else ""
if prev == "-I":
inc = tok
prev = tok
if not inc:
continue
inc_dir = Path(inc)
if not (
_resolves_dir(inc_dir)
and inc_dir.is_relative_to(proj_dir)
and not inc_dir.is_relative_to(src_dir)
# Library trees never enter the prefix closure; walking them
# would read every library file each build
and not inc_dir.is_relative_to(proj_dir / ".piolibdeps")
and not inc_dir.is_relative_to(proj_dir / ".pioenvs")
):
continue
local_headers = []
# os.walk with onerror: rglob would swallow unlistable subtrees
for root, _dirs, files in os.walk(inc_dir, onerror=_raise_walk_error):
local_headers.extend(
Path(root) / f
for f in files
if f.endswith((".h", ".hpp", ".hh", ".inc"))
)
for local in sorted(local_headers):
digest.update(str(local.relative_to(proj_dir)).encode())
digest.update(local.read_bytes())
digest.update(b"\0")
except OSError as err:
print(f"ESPHome: skipping precompiled header: {err}")
return
checksum = digest.hexdigest()
# The ccache .sum sidecar doubles as the freshness stamp
if (
not header.is_file()
or not gch.is_file()
or not sum_path.is_file()
or (_read_stamp(sum_path) != checksum)
):
failed_marker = Path(f"{gch}.failed")
if _read_stamp(failed_marker) == checksum:
print(
"ESPHome: skipping precompiled header (previous attempt "
f"failed); delete {failed_marker.name} to retry"
)
return
header.write_text(content, encoding="utf-8")
try:
error = _compile_gch(cxx, flags, header, gch, proj_dir)
except OSError as err:
# Transient spawn/IO failure: no marker, retry next build
print(f"ESPHome: precompiled header compile did not run: {err}")
gch.unlink(missing_ok=True)
sum_path.unlink(missing_ok=True)
return
if error is not None:
print("ESPHome: precompiled header failed; compiling without it")
print(error)
gch.unlink(missing_ok=True)
sum_path.unlink(missing_ok=True)
if any(m in error for m in _TRANSIENT_ERRORS):
# Resource exhaustion clears on its own; retry next build
return
# Skip retries until a flag/header/platform change alters the checksum
failed_marker.write_text(checksum + "\n", encoding="utf-8")
return
failed_marker.unlink(missing_ok=True)
sum_path.write_text(checksum + "\n", encoding="utf-8")
# Computed first so the flags and env land together: a raise between
# them would leave a pch-consuming build without its ccache settings.
# projenv["ENV"] aliases os.environ, so these reach all TUs; only
# time_macros affects non-pch TUs. User values win.
ccache_updates = {
key: value
for key, value in (
("CCACHE_SLOPPINESS", "pch_defines,time_macros"),
("CCACHE_PCH_EXTSUM", "true"),
)
if key not in os.environ
}
sloppiness = os.environ.get("CCACHE_SLOPPINESS")
if sloppiness is not None:
tokens = {tok.strip() for tok in sloppiness.split(",")}
missing = [t for t in ("pch_defines", "time_macros") if t not in tokens]
if missing:
# Without these ccache declines every pch-consuming compile
ccache_updates["CCACHE_SLOPPINESS"] = ",".join((sloppiness, *missing))
print(f"ESPHome: adding {','.join(missing)} to CCACHE_SLOPPINESS for the pch")
extsum = os.environ.get("CCACHE_PCH_EXTSUM")
if extsum is not None and extsum.strip().lower() not in ("1", "true", "yes", "on"):
# ccache then hashes the non-reproducible .gch bytes: permanent misses
print(f"ESPHome: CCACHE_PCH_EXTSUM={extsum} disables pch caching")
# Prepended: GCC only uses a .gch while no other tokens precede it.
# The relative name also reaches "pio run -t idedata" output.
# -Wno-error: the per-process probe can pass while a later cc1plus
# rejects the .gch; that must stay a warning under user -Werror.
projenv.Prepend( # noqa: F821
CXXFLAGS=["-Winvalid-pch", "-Wno-error=invalid-pch", "-include", header.name]
)
projenv["ENV"].update(ccache_updates) # noqa: F821
print("ESPHome: Compiling with precompiled header")
try:
_setup_pch()
except Exception: # noqa: BLE001 -- a speedup must never break the build
print("ESPHome: pch internal error; compiling without it")
traceback.print_exc()
-9
View File
@@ -289,15 +289,6 @@ def copy_ccache_script() -> None:
)
def copy_pch_script() -> None:
"""Copy the shared precompiled-header SCons post-script into the build
dir; platform components pair it with ``post:pch.py`` in extra_scripts."""
copy_file_if_changed(
Path(__file__).parent / "pch.py.script",
CORE.relative_build_path("pch.py"),
)
def default_libdeps_dir() -> str:
"""The PLATFORMIO_LIBDEPS_DIR value a pio run defaults to; the package
prefetch must resolve installed libraries against the same dir."""
+4 -14
View File
@@ -7,7 +7,6 @@ import re
import time
from esphome import loader
from esphome.build_helpers.pch import PCH_ARTIFACT_NAMES
from esphome.compiled_config import save_compiled_config
from esphome.config import iter_component_configs, iter_components
from esphome.const import (
@@ -610,20 +609,11 @@ def clean_build(clear_pio_cache: bool = True, *, full: bool = False):
if idf_path.is_dir():
_LOGGER.info("Deleting %s", idf_path)
rmtree(idf_path)
# The PlatformIO pch artifacts live at the project root so the
# relative -include resolves; a partial clean must drop them too
for name in PCH_ARTIFACT_NAMES:
CORE.relative_build_path(name).unlink(missing_ok=True)
# The idedata caches are derived from the build but live under the data
# dir, not the build path, so they must be removed separately in both
# modes. Globbed (name.json plus name.<backend>.json) so a future
# backend suffix cannot silently drift out of clean-all.
idedata_dir = CORE.relative_internal_path("idedata")
for idedata_cache in (
*idedata_dir.glob(f"{CORE.name}.json"),
*idedata_dir.glob(f"{CORE.name}.*.json"),
):
# The idedata cache is derived from the build but lives under the data dir,
# not the build path, so it must be removed separately in both modes.
idedata_cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json")
if idedata_cache.is_file():
_LOGGER.info("Deleting %s", idedata_cache)
idedata_cache.unlink()
+3 -3
View File
@@ -45,7 +45,7 @@ lib_deps_base =
lib_deps =
${common.lib_deps_base}
https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea
esphome/noise-c@0.1.24 ; noise (api, ota)
esphome/noise-c@0.1.26 ; noise (api, ota)
improv/Improv@1.2.7 ; improv_serial / esp32_improv
kikuchan98/pngle@1.1.0 ; online_image
; Using the repository directly, otherwise ESP-IDF can't use the library
@@ -244,7 +244,7 @@ lib_deps =
${common:idf-component-libs.lib_deps}
ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base
droscy/esp_wireguard@0.4.5 ; wireguard
esphome/noise-c@0.1.24 ; noise (api, ota)
esphome/noise-c@0.1.26 ; noise (api, ota)
ESP32Async/AsyncTCP@3.4.5 ; async_tcp
DNSServer ; captive_portal
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
@@ -641,7 +641,7 @@ build_unflags =
extends = common
platform = platformio/native
lib_deps =
esphome/noise-c@0.1.24 ; used by noise (api, ota)
esphome/noise-c@0.1.26 ; used by noise (api, ota)
lvgl/lvgl@9.5.0 ; lvgl
build_flags =
${common.build_flags}
+2 -2
View File
@@ -10,9 +10,9 @@ tzlocal==5.4.4 # from time
tzdata>=2026.3 # from time
pyserial==3.5
platformio==6.1.19
esptool==5.3.1
esptool==5.4.0
click==8.3.3
aioesphomeapi==46.3.0
aioesphomeapi==46.4.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.151.3
puremagic==2.2.0
+24 -132
View File
@@ -50,7 +50,6 @@ from __future__ import annotations
import argparse
from collections import Counter
from collections.abc import Callable
from enum import StrEnum
from functools import cache
import json
@@ -532,53 +531,37 @@ ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES = ("esphome/platformio/",)
# - esphome/build_gen/platformio.py -- the PlatformIO build generator
# - script/test_build_components.py -- the harness the job invokes
# - .github/workflows/ci.yml -- the job's own definition
# Shared by every toolchain smoke-test job: the harness it invokes and the
# workflow that defines it
_SMOKE_HARNESS_TRIGGER_FILES = frozenset(
ESP32_PLATFORMIO_TRIGGER_FILES = frozenset(
{
"esphome/build_gen/platformio.py",
"script/test_build_components.py",
".github/workflows/ci.yml",
}
)
ESP32_PLATFORMIO_TRIGGER_FILES = _SMOKE_HARNESS_TRIGGER_FILES | {
"esphome/build_gen/platformio.py",
}
def _path_or_file_trigger(
files: list[str],
trigger_files: frozenset[str],
trigger_prefixes: tuple[str, ...],
) -> bool:
"""Whether any changed file matches the given infrastructure triggers."""
return any(
file in trigger_files or file.startswith(trigger_prefixes) for file in files
)
@cache
def _cached_components_closure(files: tuple[str, ...]) -> frozenset[str]:
"""Dependency closure of the changed components, from the changed files.
The walk is expensive and every toolchain smoke-test job asks for the
same file list, so compute it once per run."""
component_files = [f for f in files if filter_component_and_test_files(f)]
return frozenset(get_components_with_dependencies(component_files, True))
def _esp32_platformio_path_or_file_trigger(files: list[str]) -> bool:
"""Whether any changed file is a PlatformIO infrastructure / harness trigger."""
return _path_or_file_trigger(
files, ESP32_PLATFORMIO_TRIGGER_FILES, ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES
)
for file in files:
if file in ESP32_PLATFORMIO_TRIGGER_FILES:
return True
if any(
file.startswith(prefix) for prefix in ESP32_PLATFORMIO_TRIGGER_PATH_PREFIXES
):
return True
return False
def _esp_idf_infra_changed(files: list[str]) -> bool:
"""Whether any changed file is ESP-IDF build/runner infrastructure."""
return _path_or_file_trigger(
files, ESP_IDF_INFRA_TRIGGER_FILES, ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES
)
for file in files:
if file in ESP_IDF_INFRA_TRIGGER_FILES:
return True
if any(
file.startswith(prefix) for prefix in ESP_IDF_INFRA_TRIGGER_PATH_PREFIXES
):
return True
return False
def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]:
@@ -616,23 +599,15 @@ def esp32_platformio_components_to_test(branch: str | None = None) -> list[str]:
Returns:
Sorted list of component names to compile.
"""
return _toolchain_components_to_test(
branch, ESP32_PLATFORMIO_TEST_COMPONENTS, _esp32_platformio_path_or_file_trigger
)
def _toolchain_components_to_test(
branch: str | None,
test_set: frozenset[str],
infra_trigger: Callable[[list[str]], bool],
) -> list[str]:
"""The shared narrowing rule for the per-toolchain smoke-test jobs."""
files = changed_files(branch)
if core_changed(files) or infra_trigger(files):
return sorted(test_set)
if core_changed(files) or _esp32_platformio_path_or_file_trigger(files):
return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS)
return sorted(test_set & _cached_components_closure(tuple(files)))
component_files = [f for f in files if filter_component_and_test_files(f)]
changed = get_components_with_dependencies(component_files, True)
return sorted(ESP32_PLATFORMIO_TEST_COMPONENTS & set(changed))
def should_run_esp32_platformio(branch: str | None = None) -> bool:
@@ -653,83 +628,6 @@ def should_run_esp32_platformio(branch: str | None = None) -> bool:
return bool(esp32_platformio_components_to_test(branch))
# The `--toolchain arduino` smoke-test set: covers the core, the bundled and
# converted registry libraries, and the waveform path.
ESP8266_NATIVE_TEST_COMPONENTS = frozenset(
{
"esp8266",
"api",
"web_server",
"captive_portal",
"mqtt",
"esp8266_pwm",
"neopixelbus",
"bme280_i2c",
"uart",
}
)
# Infrastructure whose changes always trigger the native ESP8266 compile
# test. esphome/build_helpers/ holds the idedata and size-summary helpers
# the backend shares with the native ESP-IDF build.
ESP8266_NATIVE_TRIGGER_PATH_PREFIXES = (
"esphome/arduino8266/",
"esphome/arduino/",
"esphome/build_helpers/",
)
# Shared library-conversion modules every native build imports; espidf-only
# infra (build_gen/espidf.py) deliberately stays out of the esp8266 set.
_NATIVE_SHARED_TRIGGER_FILES = frozenset(
{
"esphome/framework_helpers.py",
"esphome/platformio/library.py",
"esphome/platformio/extra_script.py",
}
)
# Tripwire: the shared modules must stay in the ESP-IDF trigger set too
# (now defined in clang_tidy_hash), or its smoke test silently skips them
assert _NATIVE_SHARED_TRIGGER_FILES <= ESP_IDF_INFRA_TRIGGER_FILES
ESP8266_NATIVE_TRIGGER_FILES = (
_NATIVE_SHARED_TRIGGER_FILES
| _SMOKE_HARNESS_TRIGGER_FILES
| {
"esphome/build_gen/arduino8266.py",
"esphome/build_gen/build_tool.py",
"esphome/components/esp8266/build_surgery.py",
"esphome/components/esp8266/boards.py",
"esphome/platformio/registry.py",
# esp8266/__init__.py imports copy_ccache_script from it
"esphome/platformio/toolchain.py",
".github/actions/cache-arduino8266/action.yml",
}
)
def _esp8266_native_path_or_file_trigger(files: list[str]) -> bool:
"""Whether any changed file is native-ESP8266 infrastructure / harness."""
# base_python_changed covers the top-level esphome/*.py modules the
# native backend imports directly (framework_helpers, helpers, writer,
# __main__); without it a change there would silently skip this job.
# base_python_changed is deliberately broad (any top-level esphome/*.py)
# as belt-and-braces while the backend is new; narrow it to the modules
# the backend imports once the toolchain has soaked a few releases
return base_python_changed(files) or _path_or_file_trigger(
files, ESP8266_NATIVE_TRIGGER_FILES, ESP8266_NATIVE_TRIGGER_PATH_PREFIXES
)
def esp8266_native_components_to_test(branch: str | None = None) -> list[str]:
"""Subset of ``ESP8266_NATIVE_TEST_COMPONENTS`` the job needs to compile.
Same narrowing logic as ``esp32_platformio_components_to_test``: the full
list on core or infrastructure changes, otherwise the intersection with
the changed-component dependency closure (empty list skips the job).
"""
return _toolchain_components_to_test(
branch, ESP8266_NATIVE_TEST_COMPONENTS, _esp8266_native_path_or_file_trigger
)
def determine_cpp_unit_tests(
branch: str | None = None,
) -> tuple[bool, list[str]]:
@@ -1328,8 +1226,6 @@ def main() -> None:
run_device_builder = True
esp32_platformio_components = sorted(ESP32_PLATFORMIO_TEST_COMPONENTS)
run_esp32_platformio = True
esp8266_native_components = sorted(ESP8266_NATIVE_TEST_COMPONENTS)
run_esp8266_native = True
else:
integration_run_all, integration_test_files = determine_integration_tests(
args.branch
@@ -1341,8 +1237,6 @@ def main() -> None:
run_device_builder = should_run_device_builder(args.branch)
esp32_platformio_components = esp32_platformio_components_to_test(args.branch)
run_esp32_platformio = bool(esp32_platformio_components)
esp8266_native_components = esp8266_native_components_to_test(args.branch)
run_esp8266_native = bool(esp8266_native_components)
run_integration, integration_test_buckets = _compute_integration_test_buckets(
integration_run_all, integration_test_files
)
@@ -1538,8 +1432,6 @@ def main() -> None:
"device_builder": run_device_builder,
"esp32_platformio": run_esp32_platformio,
"esp32_platformio_components": ",".join(esp32_platformio_components),
"esp8266_native": run_esp8266_native,
"esp8266_native_components": ",".join(esp8266_native_components),
"changed_components": changed_components,
"changed_components_with_tests": changed_components_with_tests,
"directly_changed_components_with_tests": list(directly_changed_with_tests),
+37 -9
View File
@@ -1,27 +1,49 @@
#!/bin/sh
# Prepare the dev environment for a new checkout or worktree.
#
# Installed into the git hooks directory by script/setup. Deliberately tiny and
# self-contained: it stays valid on branches where script/setup does not exist,
# and simply does nothing there.
# Installed into the git hooks directory by script/setup.py. Deliberately tiny
# and self-contained: it stays valid on branches where the setup script does not
# exist, and simply does nothing there.
# $3 is 1 for a branch checkout, 0 for a file checkout.
[ "$3" = "1" ] || exit 0
top=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
# This also runs on ordinary branch switches, where there is nothing to do.
# This also runs on ordinary branch switches, where there is nothing to do. Both
# layouts are checked because git for Windows runs hooks under its own bundled
# shell, where the environment lives in venv/Scripts rather than venv/bin.
[ -x "$top/venv/bin/python" ] && exit 0
[ -x "$top/script/setup" ] || exit 0
[ -f "$top/venv/Scripts/python.exe" ] && exit 0
# Branches from before the setup script moved to Python carry only the shell
# entry point, so whichever one the checked out branch has is used.
py=
if [ -f "$top/script/setup.py" ]; then
# The interpreter goes by different names across platforms, and on Windows
# "python3" is often a stub that opens the app store instead of running
# anything, so each candidate is tried before it is used. Doing nothing is the
# right outcome when none of them work.
for candidate in "python3" "python" "py -3"; do
# Unquoted on purpose: the launcher candidate is a command plus a flag.
if $candidate -c "" >/dev/null 2>&1; then
py=$candidate
break
fi
done
[ -n "$py" ] || exit 0
elif ! [ -x "$top/script/setup" ]; then
exit 0
fi
# Every worktree shares the hooks directory of the checkout it was created
# from, and the script/setup run below is the one from whichever branch was just
# from, and the setup script run below is the one from whichever branch was just
# checked out. Older branches install their own pre-commit hook without checking
# for a worktree: that moves the shared hook aside as pre-commit.legacy and
# replaces it with one tied to this worktree's virtual environment, so commits
# break in every checkout. To rule that out, the hooks directory is copied
# before script/setup runs and put back exactly as it was afterwards, including
# removing any file script/setup added.
# before the setup script runs and put back exactly as it was afterwards,
# including removing any file the setup script added.
hooks=$(git rev-parse --path-format=absolute --git-path hooks 2>/dev/null) || exit 0
snap=$(mktemp -d "$hooks/.post-checkout.XXXXXX") || exit 0
cp -p "$hooks"/* "$snap"/ 2>/dev/null
@@ -29,7 +51,13 @@ cp -p "$hooks"/* "$snap"/ 2>/dev/null
# Clear VIRTUAL_ENV so a checkout made from a shell with an environment already
# activated still gets its own, rather than having the active one repointed at
# this working tree.
env -u VIRTUAL_ENV "$top/script/setup"
unset VIRTUAL_ENV
if [ -n "$py" ]; then
# Unquoted on purpose, as above.
$py "$top/script/setup.py"
else
"$top/script/setup"
fi
status=$?
for f in "$hooks"/*; do
+17
View File
@@ -1104,6 +1104,10 @@ def get_components_per_integration_fixture() -> dict[str, set[str]]:
_TEST_FUNC_RE = re.compile(r"async def (test_\w+)")
# Any usage form (decorator, pytestmark assignment or list element); only
# test_*.py files are scanned, so the marker docs elsewhere cannot false-hit
_SHARED_YAML_USE_RE = re.compile(r"\bmark\.shared_yaml")
_SHARED_YAML_ARG_RE = re.compile(r"\(\s*[\"'](\w+)[\"']\s*\)")
@cache
@@ -1123,6 +1127,19 @@ def get_fixture_to_test_files() -> dict[str, frozenset[str]]:
for func in _TEST_FUNC_RE.findall(content):
base_name = func.replace("test_", "").partition("[")[0]
result.setdefault(base_name, set()).add(rel_path)
# Shared fixtures are named by marker, not by a test function; each
# decorator must carry a string literal or its fixture would silently
# map to no tests
for use in _SHARED_YAML_USE_RE.finditer(content):
arg = _SHARED_YAML_ARG_RE.match(content, use.end())
if arg is None:
line = content.count("\n", 0, use.start()) + 1
raise ValueError(
f"{rel_path}:{line}: shared_yaml marker must take a "
"single-line string literal so CI test selection can map "
"its fixture"
)
result.setdefault(arg.group(1), set()).add(rel_path)
return {k: frozenset(v) for k, v in result.items()}
+5 -69
View File
@@ -1,71 +1,7 @@
#!/usr/bin/env bash
# Set up ESPHome dev environment
# Set up ESPHome dev environment.
#
# The work is done by setup.py, which script/setup.bat also runs, so the Unix
# and Windows entry points share one implementation.
set -e
cd "$(dirname "$0")/.."
if [ -n "$VIRTUAL_ENV" ]; then
# A virtual environment is already active (e.g. the devcontainer's pre-provisioned
# esphome-venv). Install into it rather than creating a ./venv in the workspace.
venv_state=active
elif [ -x venv/bin/python ]; then
# Reuse the environment from an earlier run, so this script can be run again
# at any time to pick up dependency changes.
venv_state=reused
source venv/bin/activate
else
venv_state=created
# --clear replaces a partial environment left behind by an interrupted run.
if [ -x "$(command -v uv)" ]; then
uv venv --clear --seed venv
else
python3 -m venv --clear venv
fi
source venv/bin/activate
fi
if ! [ -x "$(command -v uv)" ]; then
python3 -m pip install uv
fi
uv pip install setuptools wheel
uv pip install -e ".[dev,test]" --config-settings editable_mode=compat
# A worktree shares one git hooks directory with the main checkout it was
# created from, so hooks are installed from the main checkout only. Installing
# from a worktree would point the shared hook at that worktree's virtual
# environment, breaking it for everyone once the worktree is removed.
git_dir="$(git rev-parse --absolute-git-dir 2>/dev/null || true)"
common_dir="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)"
if [ -n "$common_dir" ] && [ "$git_dir" = "$common_dir" ]; then
# --overwrite replaces any hook already in place. Without it, prek finds a
# previously installed pre-commit hook, moves it aside to
# .git/hooks/pre-commit.legacy and keeps calling it, so every commit would
# run both tools.
prek install --overwrite
# Prepares the virtual environment for new checkouts and worktrees. Installed
# once here, it covers every worktree created from this checkout.
if [ -d "$common_dir/hooks" ]; then
cp script/git-hooks/post-checkout "$common_dir/hooks/post-checkout"
chmod +x "$common_dir/hooks/post-checkout"
fi
fi
mkdir -p .temp
echo
echo
case "$venv_state" in
created)
echo "Virtual environment created at ./venv. Run 'source venv/bin/activate' to use it."
;;
reused)
echo "Dependencies updated in the existing ./venv. Run 'source venv/bin/activate' to use it."
;;
active)
echo "Dependencies installed into the active virtual environment:"
echo " $VIRTUAL_ENV"
echo "It is already active in this shell, so no 'source venv/bin/activate' is needed."
;;
esac
exec python3 "$(dirname "$0")/setup.py" "$@"
+1 -28
View File
@@ -1,28 +1 @@
@echo off
if defined VIRTUAL_ENV goto :install
echo Starting the Virtual Environment
python -m venv venv
call venv/Scripts/activate
echo Running the Virtual Environment
:install
echo Installing required packages...
python.exe -m pip install --upgrade pip
pip3 install -r requirements.txt -r requirements_test.txt -r requirements_dev.txt
pip3 install setuptools wheel
pip3 install -e ".[dev,test]" --config-settings editable_mode=compat
rem --overwrite replaces any hook already in place. Without it, prek finds a
rem previously installed pre-commit hook, moves it aside to
rem .git/hooks/pre-commit.legacy and keeps calling it, so every commit would
rem run both tools.
prek install --overwrite
echo .
echo .
echo Virtual environment created. Run 'venv/Scripts/activate' to use it.
@python "%~dp0setup.py" %*
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""Set up the ESPHome development environment.
Shared implementation behind script/setup and script/setup.bat, so the Unix and
Windows entry points cannot drift apart. Uses only the standard library: it runs
before any dependency has been installed.
"""
import os
from pathlib import Path
import shutil
import subprocess
import sys
import sysconfig
MIN_PYTHON = (3, 12)
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_VENV = ROOT / "venv"
POST_CHECKOUT_HOOK = ROOT / "script" / "git-hooks" / "post-checkout"
# State of the environment the dependencies end up in, used for the closing
# message.
VENV_ACTIVE = "active"
VENV_REUSED = "reused"
VENV_CREATED = "created"
def bin_dir(venv: Path) -> Path:
"""Return the directory holding a virtual environment's executables.
The "venv" scheme resolves to bin on Unix and Scripts on Windows, so the
layout does not have to be hardcoded here.
"""
base = str(venv)
return Path(
sysconfig.get_path("scripts", "venv", vars={"base": base, "platbase": base})
)
def venv_python(venv: Path) -> Path:
"""Return the path to a virtual environment's interpreter."""
name = "python.exe" if os.name == "nt" else "python"
return bin_dir(venv) / name
def run(command: list[str], env: dict[str, str] | None = None) -> None:
"""Run a command, aborting the whole script if it fails."""
print(f"+ {' '.join(command)}", flush=True)
result = subprocess.run(command, cwd=ROOT, env=env, check=False)
if result.returncode != 0:
# Some tools fail without printing anything, so name the step that broke.
print(
f"Failed with exit code {result.returncode}: {command[0]}", file=sys.stderr
)
raise SystemExit(result.returncode)
def git_output(*args: str) -> str:
"""Return the trimmed output of a git command, or "" if it cannot be run."""
try:
result = subprocess.run(
["git", *args], cwd=ROOT, capture_output=True, text=True, check=False
)
except OSError:
# Git is not required to install the dependencies, only to install hooks.
return ""
if result.returncode != 0:
return ""
return result.stdout.strip()
def create_venv(venv: Path) -> None:
"""Create a virtual environment, replacing anything already at the path."""
# --clear replaces a partial environment left behind by an interrupted run.
if (uv := shutil.which("uv")) is not None:
run([uv, "venv", "--clear", "--seed", str(venv)])
else:
run([sys.executable, "-m", "venv", "--clear", str(venv)])
def venv_environment(venv: Path) -> dict[str, str]:
"""Return the environment child processes need to target a virtual env.
Equivalent to sourcing the environment's activate script: tools such as uv
and prek pick the environment up from VIRTUAL_ENV and PATH.
"""
env = dict(os.environ)
env["VIRTUAL_ENV"] = str(venv)
env.pop("PYTHONHOME", None)
path = str(bin_dir(venv))
# An empty entry would be appended if PATH is unset, and on Unix that means
# the working directory is searched for executables.
if existing := env.get("PATH"):
path = os.pathsep.join([path, existing])
env["PATH"] = path
return env
def find_uv(venv: Path, env: dict[str, str]) -> str:
"""Return the path to uv, installing it into the environment if needed."""
if (uv := shutil.which("uv", path=env["PATH"])) is not None:
return uv
run([str(venv_python(venv)), "-m", "pip", "install", "uv"], env=env)
if (uv := shutil.which("uv", path=env["PATH"])) is not None:
return uv
raise SystemExit("uv could not be installed, aborting.")
def install_dependencies(venv: Path, env: dict[str, str]) -> None:
"""Install ESPHome and its development dependencies into the environment."""
uv = find_uv(venv, env)
run([uv, "pip", "install", "setuptools", "wheel"], env=env)
# The dev and test extras pull in requirements_dev.txt and
# requirements_test.txt, and the package itself pulls in requirements.txt,
# so this single install covers every requirements file.
run(
[
uv,
"pip",
"install",
"-e",
".[dev,test]",
"--config-settings",
"editable_mode=compat",
],
env=env,
)
def install_git_hooks(env: dict[str, str]) -> None:
"""Install the git hooks, but only when run from the main checkout.
A worktree shares one git hooks directory with the main checkout it was
created from. Installing from a worktree would point the shared hook at that
worktree's virtual environment, breaking it for everyone once the worktree is
removed.
"""
git_dir = git_output("rev-parse", "--absolute-git-dir")
common_dir = git_output("rev-parse", "--path-format=absolute", "--git-common-dir")
if not git_dir or not common_dir or Path(git_dir) != Path(common_dir):
return
prek = shutil.which("prek", path=env["PATH"])
if prek is None:
raise SystemExit("prek was not installed, aborting.")
# --overwrite replaces any hook already in place. Without it, prek finds a
# previously installed pre-commit hook, moves it aside to
# .git/hooks/pre-commit.legacy and keeps calling it, so every commit would
# run both tools.
run([prek, "install", "--overwrite"], env=env)
# Prepares the virtual environment for new checkouts and worktrees. Installed
# once here, it covers every worktree created from this checkout.
hooks_dir = Path(common_dir) / "hooks"
if hooks_dir.is_dir():
installed = hooks_dir / "post-checkout"
shutil.copyfile(POST_CHECKOUT_HOOK, installed)
installed.chmod(0o755)
def activate_hint() -> str:
"""Return the command that activates the environment this script creates."""
activate = bin_dir(DEFAULT_VENV).relative_to(ROOT) / "activate"
if os.name == "nt":
return str(activate)
return f"source {activate.as_posix()}"
def report(state: str, venv: Path) -> None:
"""Print the closing message for the environment that was set up."""
location = f"./{DEFAULT_VENV.name}"
print()
print()
if state == VENV_ACTIVE:
print("Dependencies installed into the active virtual environment:")
print(f" {venv}")
print(
f"It is already active in this shell, so no '{activate_hint()}' is needed."
)
elif state == VENV_REUSED:
print(
f"Dependencies updated in the existing {location}. "
f"Run '{activate_hint()}' to use it."
)
else:
print(
f"Virtual environment created at {location}. "
f"Run '{activate_hint()}' to use it."
)
def main() -> None:
"""Set up the development environment."""
if sys.version_info < MIN_PYTHON:
raise SystemExit(
f"ESPHome needs Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]} or newer, "
f"but this is Python {sys.version.split()[0]}."
)
# A virtual environment that is already active (for example the
# devcontainer's pre-provisioned esphome-venv) is installed into rather than
# creating a ./venv in the workspace.
if active := os.environ.get("VIRTUAL_ENV"):
state, venv = VENV_ACTIVE, Path(active)
elif venv_python(DEFAULT_VENV).is_file():
# Reuse the environment from an earlier run, so this script can be run
# again at any time to pick up dependency changes.
state, venv = VENV_REUSED, DEFAULT_VENV
else:
state, venv = VENV_CREATED, DEFAULT_VENV
create_venv(venv)
env = venv_environment(venv)
install_dependencies(venv, env)
install_git_hooks(env)
(ROOT / ".temp").mkdir(exist_ok=True)
report(state, venv)
if __name__ == "__main__":
main()
+8 -50
View File
@@ -1027,7 +1027,6 @@ def test_components(
isolated_components: set[str] | None = None,
base_only: bool = False,
toolchain: str | None = None,
fail_on_no_tests: bool = False,
) -> int:
"""Test components with optional intelligent grouping.
@@ -1062,34 +1061,20 @@ def test_components(
# toolchain build.
include_validate = esphome_command != "compile"
# Find all component tests; remember which components each pattern
# (wildcards included) matched, for the deferred no-tests accounting
# Find all component tests
all_tests = {}
pattern_components: dict[str, set[str]] = {}
for pattern in component_patterns:
# Skip empty patterns (happens when components list is empty string)
if not pattern:
continue
found = find_component_tests(
tests_dir, pattern, base_only, include_validate=include_validate
all_tests.update(
find_component_tests(
tests_dir, pattern, base_only, include_validate=include_validate
)
)
pattern_components[pattern] = set(found)
all_tests.update(found)
# The flag's contract is "no test matched fails": a fully blank pattern
# list would otherwise slide into the reference-baseline fallback and
# exit green while building nothing a caller asked for
if fail_on_no_tests and not any(component_patterns):
print("No components requested (blank component list)")
return 1
if fail_on_no_tests and not all_tests:
# Nothing matched at all: fail before the synthetic baseline build,
# which would spend a compile reporting success on nothing. Partial
# matches defer to the per-pattern accounting after the summary.
print(f"No components found matching: {component_patterns}")
return 1
# If no components found, build a reference configuration for baseline comparison
# Create a synthetic "empty" component test that will build just the base config
if not all_tests:
print(f"No components found matching: {component_patterns}")
print(
@@ -1193,26 +1178,6 @@ def test_components(
toolchain=toolchain,
)
silent: list[str] = []
if fail_on_no_tests:
# A green run that built nothing for a requested pattern (renamed
# fixture, missing base file, version-suffix mismatch, a wildcard
# matching no component) must not pass CI. Per pattern: an
# all-or-nothing check would let one silent pattern hide behind the
# others. Opt-in: some legs (the esp32-ard smoke subset)
# legitimately match nothing. Failing is deferred past the summary
# so a real failure's reproduce commands still print.
built = {c for r in test_results for c in r.components}
# A pattern is silent when it matched no fixture, or when none of
# its matched components produced a build (wildcards included)
silent = [
p
for p in component_patterns
if p and not (pattern_components.get(p, set()) & built)
]
if silent:
print(f"No tests ran for requested pattern(s): {', '.join(silent)}")
# Separate results into passed and failed
passed_results = [r for r in test_results if r.success]
failed_results = [r for r in test_results if not r.success]
@@ -1244,7 +1209,7 @@ def test_components(
if os.environ.get("GITHUB_STEP_SUMMARY"):
write_github_summary(test_results, toolchain=toolchain)
if failed_results or silent:
if failed_results:
return 1
return 0
@@ -1299,12 +1264,6 @@ def main() -> int:
"--toolchain",
help="Select toolchain for compiling.",
)
parser.add_argument(
"--fail-on-no-tests",
action="store_true",
help="Exit non-zero when no test matched (for CI legs whose "
"components must all have fixtures)",
)
args = parser.parse_args()
@@ -1323,7 +1282,6 @@ def main() -> int:
continue_on_fail=args.continue_on_fail,
enable_grouping=not args.no_grouping,
isolated_components=isolated_components,
fail_on_no_tests=args.fail_on_no_tests,
base_only=args.base_only,
toolchain=args.toolchain,
)
+2
View File
@@ -17,6 +17,8 @@ CONFIG_ESP_TASK_WDT_INIT=y
CONFIG_ESP_TASK_WDT_PANIC=y
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n
CONFIG_FREERTOS_USE_TICKLESS_IDLE=y
CONFIG_PM_ENABLE=y
# esp32_ble
CONFIG_BT_ENABLED=y
@@ -19,7 +19,7 @@ class InfraredCall {
return *this;
}
InfraredCall &set_repeat_count(uint32_t /*count*/) { return *this; }
void perform() {}
bool perform() { return false; }
protected:
Infrared *parent_;
@@ -23,7 +23,7 @@ class RadioFrequencyCall {
RadioFrequencyCall &set_raw_timings_packed(const uint8_t * /*data*/, uint16_t /*length*/, uint16_t /*count*/) {
return *this;
}
void perform() {}
bool perform() { return false; }
protected:
RadioFrequency *parent_;
@@ -40,6 +40,9 @@ class SerialProxy {
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
void write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {}
SerialProxyResult set_mode_from_client(api::APIConnection *api_connection, api::enums::SerialProxyMode mode) {
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
SerialProxyResult set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
@@ -0,0 +1,12 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ap:
sendspin:
@@ -0,0 +1,18 @@
esphome:
name: test
project:
name: project_manufacturer.project_model
version: 9.9.9
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ap:
sendspin:
manufacturer: Explicit Manufacturer
model: Explicit Model
firmware_version: 1.2.3
@@ -0,0 +1,15 @@
esphome:
name: test
project:
name: project_manufacturer.project_model
version: 9.9.9
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ap:
sendspin:
@@ -0,0 +1,83 @@
"""Tests for the device information the sendspin hub reports to the server."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome import config_validation as cv
from esphome.components.sendspin import (
CONF_FIRMWARE_VERSION,
CONF_MANUFACTURER,
CONFIG_SCHEMA,
)
from esphome.const import CONF_MODEL, PlatformFramework
from tests.component_tests.types import SetCoreConfigCallable
def test_explicit_device_info_wins_over_project(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Configured values take precedence over the project information."""
main_cpp = generate_main(component_config_path("device_info_explicit.yaml"))
assert 'set_manufacturer("Explicit Manufacturer")' in main_cpp
assert 'set_model("Explicit Model")' in main_cpp
assert 'set_firmware_version("1.2.3")' in main_cpp
def test_project_supplies_device_info(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Without configured values, the project name splits into manufacturer and model."""
main_cpp = generate_main(component_config_path("device_info_project.yaml"))
assert 'set_manufacturer("project_manufacturer")' in main_cpp
assert 'set_model("project_model")' in main_cpp
assert 'set_firmware_version("9.9.9")' in main_cpp
def test_no_device_info_leaves_hub_defaults(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""With neither source, nothing is emitted and the hub keeps its own defaults."""
main_cpp = generate_main(component_config_path("device_info_default.yaml"))
assert "set_manufacturer(" not in main_cpp
assert "set_model(" not in main_cpp
assert "set_firmware_version(" not in main_cpp
@pytest.mark.parametrize(
"conf_key", [CONF_MANUFACTURER, CONF_MODEL, CONF_FIRMWARE_VERSION]
)
def test_empty_device_info_rejected(
set_core_config: SetCoreConfigCallable, conf_key: str
) -> None:
"""An empty string would be sent to the server as an empty value, so it is not accepted."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA({conf_key: ""})
@pytest.mark.parametrize(
"conf_key", [CONF_MANUFACTURER, CONF_MODEL, CONF_FIRMWARE_VERSION]
)
def test_device_info_capped_at_127_bytes(
set_core_config: SetCoreConfigCallable, conf_key: str
) -> None:
"""The cap is in bytes so the protobuf length prefix stays a single byte."""
set_core_config(PlatformFramework.ESP32_IDF)
CONFIG_SCHEMA({conf_key: "a" * 127})
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA({conf_key: "a" * 128})
# 64 two-byte characters is 128 bytes.
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA({conf_key: "é" * 64})

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