Merge remote-tracking branch 'origin/dev' into integration

This commit is contained in:
J. Nick Koston
2026-07-18 10:21:31 -10:00
466 changed files with 14801 additions and 3978 deletions
+2
View File
@@ -1,3 +1,5 @@
# Normalize line endings to LF in the repository
* text eol=lf
*.png binary
*.gif binary
*.apng binary
+4 -1
View File
@@ -32,9 +32,12 @@ runs:
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
# downstream jobs rely on is preserved.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
# reuse; dev pushes seed the shared copy instead.
save-cache: ${{ github.event_name != 'pull_request' }}
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
+4 -1
View File
@@ -29,9 +29,12 @@ jobs:
- name: Set up uv
# ``--system`` (below) installs into the setup-python interpreter;
# no venv is created or restored by this workflow.
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
# Pull-request-only workflow: a save could never be shared and
# would only consume quota.
save-cache: "false"
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
+88 -14
View File
@@ -49,9 +49,12 @@ jobs:
# detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
# that ``. venv/bin/activate`` see an identical layout.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
# reuse; dev pushes seed the shared copy instead.
save-cache: ${{ github.event_name != 'pull_request' }}
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
@@ -171,9 +174,12 @@ jobs:
# install step (order-of-magnitude faster on cold boots,
# with its own wheel cache). actions/setup-python still
# provides the interpreter.
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
# reuse; dev pushes seed the shared copy instead.
save-cache: ${{ github.event_name != 'pull_request' }}
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
@@ -372,9 +378,12 @@ jobs:
- name: Set up uv
# Only needed on cache miss to populate the venv.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
# Pull request saves land in per-PR scopes nothing else can
# reuse; dev pushes seed the shared copy instead.
save-cache: ${{ github.event_name != 'pull_request' }}
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
@@ -456,7 +465,7 @@ jobs:
echo "binary=$BINARY" >> $GITHUB_OUTPUT
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@a4a36bb07c0638b0b4ca52bf1f3dad1b4289e52f # v4.18.1
uses: CodSpeedHQ/action@f99becdce5e5d51fd556489ebef684f4ecfd6286 # v4.18.5
with:
run: |
. venv/bin/activate
@@ -495,6 +504,15 @@ jobs:
options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52
cache_sdk_nrf: true
ignore_errors: false
- id: clang-tidy
name: Run script/clang-tidy for RP2
options: --environment rp2-tidy --grep USE_RP2
pio_cache_key: tidyrp2
- id: clang-tidy
name: Run script/clang-tidy for LibreTiny
environments: bk72xx-tidy ln882h-tidy rtl87xxb-tidy rtl87xxc-tidy
options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X
pio_cache_key: tidylibretiny
steps:
- name: Check out code from GitHub
@@ -558,10 +576,21 @@ jobs:
. venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
script/clang-tidy --all-headers --fix ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }}
changed=""
else
echo "Running clang-tidy on changed files only"
script/clang-tidy --all-headers --fix --changed ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }}
changed="--changed"
fi
if [ -n "${{ matrix.environments }}" ]; then
rc=0
for env in ${{ matrix.environments }}; do
echo "::group::clang-tidy $env"
script/clang-tidy --all-headers --fix $changed --environment "$env" ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} || rc=1
echo "::endgroup::"
done
exit $rc
else
script/clang-tidy --all-headers --fix $changed ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }}
fi
env:
# Also cache libdeps, store them in a ~/.platformio subfolder
@@ -735,7 +764,8 @@ jobs:
include:
- id: clang-tidy
name: Run script/clang-tidy for ESP32 S3
options: --environment esp32s3-idf-tidy --grep USE_ESP32_VARIANT_ESP32S3
# yamllint disable-line rule:line-length
options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC
- id: clang-tidy
name: Run script/clang-tidy for ESP32 P4
# P4 has no native Wi-Fi/BLE; those run over the hosted co-processor,
@@ -745,7 +775,7 @@ jobs:
- id: clang-tidy
name: Run script/clang-tidy for ESP32 C6
# yamllint disable-line rule:line-length
options: --environment esp32c6-idf-tidy --grep USE_ESP32_VARIANT_ESP32C6 --grep USE_OPENTHREAD --grep USE_ZIGBEE
options: --environment esp32c6-idf-tidy --grep SOC_LP_I2C_SUPPORTED --grep USE_ESP32_VARIANT_ESP32C6 --grep USE_OPENTHREAD --grep USE_ZIGBEE
steps:
- name: Check out code from GitHub
@@ -828,11 +858,12 @@ jobs:
- name: List components
run: echo ${{ matrix.batch.components }}
- name: Cache apt packages
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev ccache
version: 1.1
- name: Install apt packages
# Not cached: this job is pull-request-only, so a cache save could
# never be shared and would only consume quota.
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends libsdl2-dev ccache
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -1006,6 +1037,36 @@ 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
pre-commit-seed-cache:
name: Seed pre-commit cache
runs-on: ubuntu-latest
needs:
- common
# Saves a dev-scoped pre-commit cache that pull request runs can
# restore, since pre-commit.ci lite itself never runs on dev pushes.
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache pre-commit environments
id: cache-pre-commit
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/pre-commit
# Must match the restore key in pre-commit-ci-lite
# yamllint disable-line rule:line-length
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
- name: Install pre-commit hook environments
if: steps.cache-pre-commit.outputs.cache-hit != 'true'
run: |
python -m pip install pre-commit
pre-commit install-hooks
pre-commit-ci-lite:
name: pre-commit.ci lite
runs-on: ubuntu-latest
@@ -1021,9 +1082,22 @@ jobs:
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache
# Inlined from esphome/pre-commit-action with a restore-only cache
# step: the pre-commit-seed-cache job owns saving this cache, so
# pull request runs never write per-PR copies.
- name: Restore pre-commit cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/pre-commit
# Must match the key pre-commit-seed-cache saves
# yamllint disable-line rule:line-length
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
- name: Run pre-commit
env:
SKIP: pylint,ci-custom
run: |
python -m pip install pre-commit
pre-commit run --show-diff-on-failure --color=always --all-files
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
if: always()
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
with:
category: "/language:${{matrix.language}}"
+1 -1
View File
@@ -14,4 +14,4 @@ jobs:
permissions:
issues: write # issues.lock on closed issues
pull-requests: write # issues.lock on closed pull requests
uses: esphome/workflows/.github/workflows/lock.yml@025a1e6255610c498ed590403b7e510b69e474df # 2026.4.1
uses: esphome/workflows/.github/workflows/lock.yml@9f6577fd37b5cf773ab1b9be929714a0dcd15661 # 2026.7.0
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Stale
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
with:
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
remove-stale-when-updated: true
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
# setup-python interpreter so subsequent ``pre-commit`` /
# ``script/run-in-env.py`` steps find the deps without a
# ``uv run`` prefix.
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
+4
View File
@@ -145,6 +145,7 @@ esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz
esphome/components/dps310/* @kbx81
esphome/components/ds1307/* @badbadc0ffee
esphome/components/ds2484/* @mrk-its
esphome/components/ds248x/* @tomwellnitz
esphome/components/dsmr/* @glmnet @PolarGoose
esphome/components/duty_time/* @dudanov
esphome/components/ee895/* @Stock-M
@@ -187,6 +188,7 @@ esphome/components/ezo_pmp/* @carlos-sarmiento
esphome/components/factory_reset/* @anatoly-savchenkov
esphome/components/fastled_base/* @OttoWinter
esphome/components/feedback/* @ianchi
esphome/components/file/* @esphome/core
esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund
esphome/components/font/* @clydebarrow @esphome/core
esphome/components/fs3000/* @kahrendt
@@ -208,6 +210,7 @@ esphome/components/gree/switch/* @nagyrobi
esphome/components/grove_gas_mc_v2/* @YorkshireIoT
esphome/components/grove_tb6612fng/* @max246
esphome/components/growatt_solar/* @leeuwte
esphome/components/gsl3670/* @clydebarrow
esphome/components/gt911/* @clydebarrow @jesserockz
esphome/components/haier/* @paveldn
esphome/components/haier/binary_sensor/* @paveldn
@@ -404,6 +407,7 @@ esphome/components/pn7160_i2c/* @jesserockz @kbx81
esphome/components/pn7160_spi/* @jesserockz @kbx81
esphome/components/power_supply/* @esphome/core
esphome/components/preferences/* @esphome/core
esphome/components/provisioning/* @esphome/core
esphome/components/psram/* @esphome/core
esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston
esphome/components/pvvx_mithermometer/* @pasiz
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.7.0-dev
PROJECT_NUMBER = 2026.8.0-dev
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
+1
View File
@@ -6,3 +6,4 @@ recursive-include esphome *.cpp *.h *.tcc *.c
recursive-include esphome *.py.script
recursive-include esphome *.jinja
recursive-include esphome LICENSE.txt
recursive-include esphome requirements.txt
+46
View File
@@ -79,6 +79,48 @@ These *are* security bugs in this repo, and we want to hear about them privately
- Flaws that weaken the device's API encryption (Noise), OTA, or web server auth
below their documented guarantees.
## The web server is an open HTTP API by design
The `web_server` component exposes a plain HTTP interface for viewing and
controlling entities, and, when the `web_server` OTA platform is enabled, for
uploading firmware at `/update`. Its only access controls are the optional
`web_server` `auth:` credentials and the network the device sits on.
When `auth:` is not configured, every endpoint is reachable by any client that
can reach the device. This is intentional; enabling `web_server` without `auth:`
is choosing an open control surface, in the same way that running native OTA
without a password leaves OTA open. The API is documented and is meant to be
called by other devices, scripts, and pages.
As defense-in-depth, the web server checks the `Origin` header on browser requests
to its entity control and state endpoints: a request whose `Origin` does not match
the address the device is served on is rejected, and the `allowed_origins` option
widens that list. This blocks the common "confused deputy" (CSRF) case where a page
the operator visits drives the device through their browser. It is **not** an
authentication boundary: it only constrains browsers. Any client that omits the
`Origin` header — `curl`, scripts, or other non-browser callers on the same
network — reaches every endpoint exactly as before. The check also does not cover
the web OTA `/update` endpoint. The device performs no CSRF-token or `Referer`
validation. The following are therefore **not** vulnerabilities in this repository:
- Requests without an `Origin` header (for example `curl`) reaching the control
endpoints, whether or not `web_server` `auth:` is set.
- Requests from an origin the operator added to `allowed_origins`.
- Cross-origin or CSRF firmware upload through the web OTA endpoint (`/update`) when
web OTA is enabled without `web_server` `auth:`. The `/update` endpoint is not
covered by the `Origin` check; this is the same exposure as running OTA without a
password.
The supported defenses are `web_server` `auth:`, protecting OTA (a web password or
a native OTA password), and keeping devices on a trusted, segmented network. See
the security best practices guide linked above.
What remains in scope is bypassing `web_server` `auth:` when it *is* configured,
and any memory-safety or protocol bug in the server reachable without credentials.
This section documents the current design and scope; it is not a judgment that the
design is optimal or that it will not change.
## Explicitly out of scope
- Local attackers who already have shell access on the host that runs `esphome`.
@@ -86,6 +128,10 @@ These *are* security bugs in this repo, and we want to hear about them privately
- Operator-supplied hostile YAML (covered above — config authoring is trusted).
- Attacks that require an already-authenticated device peer (someone who already
holds the API key / OTA / web credentials).
- Access to the device web server or its web OTA endpoint by non-browser clients
(those that send no `Origin` header). The web server is an open HTTP API by
design (see above); browser cross-origin requests are blocked by default, but the
real controls are `web_server` `auth:` and network isolation.
- Anything in the dashboard / device-builder — report that in its own repository
(linked at the top).
- Deployments where the operator removed protections or exposed credentials. See
+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.2.0
RUN uv pip install --no-cache-dir esphome-device-builder==1.6.4
RUN \
platformio settings set enable_telemetry No \
+8 -27
View File
@@ -21,6 +21,7 @@ from . import (
RAM_SECTIONS,
MemoryAnalyzer,
)
from .toolchain import find_elf_path, find_idedata_path, idedata_candidates
if TYPE_CHECKING:
from . import ComponentMemory
@@ -760,45 +761,25 @@ def main():
print(f"Error: {build_path} is not a directory", file=sys.stderr)
sys.exit(1)
# Find firmware.elf
elf_file = None
for elf_candidate in [
build_path / "firmware.elf",
build_path / ".pioenvs" / build_path.name / "firmware.elf",
]:
if elf_candidate.exists():
elf_file = str(elf_candidate)
break
if not elf_file:
print(f"Error: firmware.elf not found in {build_dir}", file=sys.stderr)
elf_path = find_elf_path(build_path)
if not elf_path:
print(f"Error: no firmware ELF found in {build_dir}", file=sys.stderr)
sys.exit(1)
# Find idedata.json - check current directory first, then home
device_name = build_path.name
idedata_candidates = [
Path.cwd() / ".esphome" / "idedata" / f"{device_name}.json",
Path.home() / ".esphome" / "idedata" / f"{device_name}.json",
]
elf_file = str(elf_path)
idedata = None
for idedata_path in idedata_candidates:
if not idedata_path.exists():
continue
if idedata_path := find_idedata_path(build_path):
try:
with idedata_path.open(encoding="utf-8") as f:
raw_data = json.load(f)
idedata = IDEData(raw_data)
print(f"Loaded idedata from: {idedata_path}", file=sys.stderr)
break
except (json.JSONDecodeError, OSError) as e:
print(f"Warning: Failed to load idedata: {e}", file=sys.stderr)
if not idedata:
print(
f"Warning: idedata not found (searched {idedata_candidates[0]} and {idedata_candidates[1]})",
file=sys.stderr,
)
searched = "\n ".join(str(p) for p in idedata_candidates(build_path))
print(f"Warning: idedata not found, searched:\n {searched}", file=sys.stderr)
analyzer = MemoryAnalyzerCLI(elf_file, idedata=idedata)
analyzer.analyze()
+72
View File
@@ -23,6 +23,78 @@ TOOLCHAIN_PREFIXES = [
]
def find_elf_path(build_path: Path) -> Path | None:
"""Locate the firmware ELF inside an ESPHome build directory.
The layout depends on the toolchain that produced the build, so try each
known one in turn.
Args:
build_path: Path to an ESPHome build directory
Returns:
Path to the ELF file, or None if no known layout matches
"""
name = build_path.name
for candidate in (
# Native ESP-IDF: idf.py writes build/<name>.elf, which ESPHome copies
# to build/firmware.elf (see espidf.toolchain.create_elf_copy)
build_path / "build" / "firmware.elf",
# PlatformIO
build_path / "firmware.elf",
build_path / ".pioenvs" / name / "firmware.elf",
# LibreTiny uses raw_firmware.elf
build_path / "raw_firmware.elf",
build_path / ".pioenvs" / name / "raw_firmware.elf",
# Zephyr (nRF52); the SDK nests the artifacts one level deeper from 2.9.2
build_path / ".pioenvs" / name / "zephyr" / "zephyr" / "zephyr.elf",
build_path / ".pioenvs" / name / "zephyr" / "zephyr.elf",
):
if candidate.is_file():
return candidate
return None
def idedata_candidates(build_path: Path) -> list[Path]:
"""Return the idedata locations searched for a build directory, in order.
Exposed so a caller reporting "not found" can name the paths it tried
without keeping its own copy of the list.
Args:
build_path: Path to an ESPHome build directory
Returns:
The candidate idedata JSON paths, most specific first
"""
name = build_path.name
return [
# In .pioenvs for test builds
build_path / ".pioenvs" / name / "idedata.json",
# Both toolchains cache it in the data dir, which holds this build dir:
# <data_dir>/idedata/<name>.json next to <data_dir>/build/<name>
build_path.parent.parent / "idedata" / f"{name}.json",
# Regular builds, invoked from the config dir or from anywhere
Path.cwd() / ".esphome" / "idedata" / f"{name}.json",
Path.home() / ".esphome" / "idedata" / f"{name}.json",
]
def find_idedata_path(build_path: Path) -> Path | None:
"""Locate the idedata JSON belonging to an ESPHome build directory.
Args:
build_path: Path to an ESPHome build directory
Returns:
Path to the idedata JSON, or None if it was not found
"""
for candidate in idedata_candidates(build_path):
if candidate.is_file():
return candidate
return None
def _find_in_platformio_packages(tool_name: str) -> str | None:
"""Search for a tool in PlatformIO package directories.
+35 -2
View File
@@ -7,7 +7,7 @@ and compiled directly: ``esphome compile my_device.esphomebundle.tar.gz``
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from enum import StrEnum
import io
import json
@@ -32,6 +32,8 @@ from esphome.core import CORE, EsphomeError
_LOGGER = logging.getLogger(__name__)
DOMAIN = "bundle"
BUNDLE_EXTENSION = ".esphomebundle.tar.gz"
MANIFEST_FILENAME = "manifest.json"
CURRENT_MANIFEST_VERSION = 1
@@ -120,6 +122,32 @@ def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]:
return keys
@dataclass
class BundleData:
"""Files components asked to include, keyed under DOMAIN in CORE.data."""
extra_files: list[Path] = field(default_factory=list)
def _get_data() -> BundleData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = BundleData()
return CORE.data[DOMAIN]
def add_bundle_file(path: Path) -> None:
"""Register a file that a bundle must include.
Bundle discovery walks the validated config, so it only finds files the config
names. Components call this during validation for files it cannot see, such as a
file that is referenced from inside another file.
A relative path is taken as relative to the config directory. Files outside the
config directory are skipped when the bundle is built.
"""
_get_data().extra_files.append(CORE.relative_config_path(path))
@dataclass
class BundleFile:
"""A file to include in the bundle."""
@@ -286,13 +314,18 @@ class ConfigBundleCreator:
with known file extensions are also resolved and checked.
Core ESPHome concepts that use relative paths or directories
are handled explicitly.
are handled explicitly. Files the config does not name at all are
registered by their component with add_bundle_file().
"""
config = self._config
# Generic walk: find all file paths in the validated config
self._walk_config_for_files(config)
# Files registered by components during validation
for extra_file in _get_data().extra_files:
self._add_file(extra_file)
# --- Core ESPHome concepts needing explicit handling ---
# esphome.includes / includes_c - can be relative paths and directories
+20 -98
View File
@@ -1,114 +1,36 @@
import logging
# ---------------------------------------------------------------------------
# Legacy top-level `animation:` deprecation shim -- REMOVE this whole file after
# 2027.1.0.
#
# Animations are now a platform of the `image:` component (`platform:
# animation`); the real schema, actions and codegen live in `image.py`. This
# module only keeps the deprecated top-level `animation:` key working during the
# deprecation window: it reuses that schema/codegen and adds a one-shot
# deprecation warning (with a pasteable migrated `image:` block) at validation
# time. Deleting this file drops the top-level form entirely.
# ---------------------------------------------------------------------------
from esphome import automation
import esphome.codegen as cg
from esphome.components.const import CONF_LOOP
import esphome.components.image as espImage
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_REPEAT
_LOGGER = logging.getLogger(__name__)
from .image import ANIMATION_CONFIG_SCHEMA, setup_animation
AUTO_LOAD = ["image"]
AUTO_LOAD = ["image", "file"]
CODEOWNERS = ["@syndlex"]
DEPENDENCIES = ["display"]
MULTI_CONF = True
MULTI_CONF_NO_DEFAULT = True
CONF_START_FRAME = "start_frame"
CONF_END_FRAME = "end_frame"
CONF_FRAME = "frame"
DOMAIN = "animation"
animation_ns = cg.esphome_ns.namespace("animation")
LEGACY_REMOVAL_VERSION = "2027.1.0"
Animation_ = animation_ns.class_("Animation", espImage.Image_)
# Actions
NextFrameAction = animation_ns.class_(
"AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_)
)
PrevFrameAction = animation_ns.class_(
"AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_)
)
SetFrameAction = animation_ns.class_(
"AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_)
_capture_legacy_entry, _warn_legacy_animation = (
espImage.legacy_platform_migration_warning(DOMAIN, DOMAIN, LEGACY_REMOVAL_VERSION)
)
CONFIG_SCHEMA = cv.All(
espImage.IMAGE_SCHEMA.extend(
{
cv.Required(CONF_ID): cv.declare_id(Animation_),
cv.Optional(CONF_LOOP): cv.All(
{
cv.Optional(CONF_START_FRAME, default=0): cv.positive_int,
cv.Optional(CONF_END_FRAME): cv.positive_int,
cv.Optional(CONF_REPEAT): cv.positive_int,
}
),
},
),
espImage.validate_settings,
)
CONFIG_SCHEMA = cv.All(_capture_legacy_entry, ANIMATION_CONFIG_SCHEMA)
FINAL_VALIDATE_SCHEMA = _warn_legacy_animation
NEXT_FRAME_SCHEMA = automation.maybe_simple_id(
{
cv.GenerateID(): cv.use_id(Animation_),
}
)
PREV_FRAME_SCHEMA = automation.maybe_simple_id(
{
cv.GenerateID(): cv.use_id(Animation_),
}
)
SET_FRAME_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.use_id(Animation_),
cv.Required(CONF_FRAME): cv.uint16_t,
}
)
@automation.register_action(
"animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True
)
@automation.register_action(
"animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True
)
@automation.register_action(
"animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True
)
async def animation_action_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, paren)
if (frame := config.get(CONF_FRAME)) is not None:
template_ = await cg.templatable(frame, args, cg.uint16)
cg.add(var.set_frame(template_))
return var
async def to_code(config):
(
prog_arr,
width,
height,
image_type,
trans_value,
frame_count,
) = await espImage.write_image(config, all_frames=True)
var = cg.new_Pvariable(
config[CONF_ID],
prog_arr,
width,
height,
frame_count,
image_type,
trans_value,
)
if loop_config := config.get(CONF_LOOP):
start = loop_config[CONF_START_FRAME]
end = loop_config.get(CONF_END_FRAME, frame_count)
count = loop_config.get(CONF_REPEAT, -1)
cg.add(var.set_loop(start, end, count))
to_code = setup_animation
+115
View File
@@ -0,0 +1,115 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components.const import CONF_LOOP
from esphome.components.file.image import image_schema, write_image
from esphome.components.image import Image_, validate_settings
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_REPEAT
from esphome.types import ConfigType
CODEOWNERS = ["@syndlex"]
AUTO_LOAD = ["file"]
DEPENDENCIES = ["display"]
CONF_START_FRAME = "start_frame"
CONF_END_FRAME = "end_frame"
CONF_FRAME = "frame"
animation_ns = cg.esphome_ns.namespace("animation")
Animation_ = animation_ns.class_("Animation", Image_)
# Actions
NextFrameAction = animation_ns.class_(
"AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_)
)
PrevFrameAction = animation_ns.class_(
"AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_)
)
SetFrameAction = animation_ns.class_(
"AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_)
)
ANIMATION_SCHEMA = image_schema(Animation_).extend(
{
cv.Optional(CONF_LOOP): cv.All(
{
cv.Optional(CONF_START_FRAME, default=0): cv.positive_int,
cv.Optional(CONF_END_FRAME): cv.positive_int,
cv.Optional(CONF_REPEAT): cv.positive_int,
}
),
},
)
# Shared schema used by both the (deprecated) top-level `animation:` key and the
# `image:` `platform: animation` entry.
ANIMATION_CONFIG_SCHEMA = cv.All(ANIMATION_SCHEMA, validate_settings)
NEXT_FRAME_SCHEMA = automation.maybe_simple_id(
{
cv.GenerateID(): cv.use_id(Animation_),
}
)
PREV_FRAME_SCHEMA = automation.maybe_simple_id(
{
cv.GenerateID(): cv.use_id(Animation_),
}
)
SET_FRAME_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.use_id(Animation_),
cv.Required(CONF_FRAME): cv.uint16_t,
}
)
@automation.register_action(
"animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True
)
@automation.register_action(
"animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True
)
@automation.register_action(
"animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True
)
async def animation_action_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, paren)
if (frame := config.get(CONF_FRAME)) is not None:
template_ = await cg.templatable(frame, args, cg.uint16)
cg.add(var.set_frame(template_))
return var
async def setup_animation(config: ConfigType) -> None:
(
prog_arr,
width,
height,
image_type,
trans_value,
frame_count,
) = await write_image(config, all_frames=True)
var = cg.new_Pvariable(
config[CONF_ID],
prog_arr,
width,
height,
frame_count,
image_type,
trans_value,
)
if loop_config := config.get(CONF_LOOP):
start = loop_config[CONF_START_FRAME]
end = loop_config.get(CONF_END_FRAME, frame_count)
count = loop_config.get(CONF_REPEAT, -1)
cg.add(var.set_loop(start, end, count))
CONFIG_SCHEMA = ANIMATION_CONFIG_SCHEMA
to_code = setup_animation
+23 -2
View File
@@ -112,6 +112,23 @@ CONF_MAX_SEND_QUEUE = "max_send_queue"
CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
def _register_provisioning_source(config: ConfigType) -> ConfigType:
"""Register the API as a provisioning source when encryption is enabled.
With no ``key`` the device boots unprovisioned and is set up on first
connection; a YAML ``key`` means it is born provisioned. Either way the API
drives the provisioning manager, so it counts as a source for `provisioning:`.
A hardcoded ``key`` is reported so `provisioning:` can warn about it.
"""
if (encryption := config.get(CONF_ENCRYPTION)) is not None:
from esphome.components import provisioning
provisioning.register_source("api")
if CONF_KEY in encryption:
provisioning.report_hardcoded_credentials("api")
return config
def validate_encryption_key(value):
value = cv.string_strict(value)
try:
@@ -337,6 +354,7 @@ CONFIG_SCHEMA = cv.All(
).extend(cv.COMPONENT_SCHEMA),
cv.rename_key(CONF_SERVICES, CONF_ACTIONS),
_consume_api_sockets,
_register_provisioning_source,
)
@@ -470,8 +488,11 @@ async def to_code(config: ConfigType) -> None:
cg.add_define("USE_API_NOISE_PSK_FROM_YAML")
else:
# No key provided, but encryption desired
# This will allow a plaintext client to provide a noise key,
# send it to the device, and then switch to noise.
# Until a key is set, the device accepts both Noise connections
# using the well-known all-zeros PSK (preferred: the key travels
# encrypted, protecting against passive sniffing) and plaintext
# connections (deprecated, remove after 2027.2.0) so a client can
# provide a noise key and the device then switches to noise only.
# The key will be saved in flash and used for future connections
# and plaintext disabled. Only a factory reset can remove it.
cg.add_define("USE_API_PLAINTEXT")
+19
View File
@@ -158,6 +158,16 @@ message AuthenticationResponse {
bool invalid_password = 1;
}
// Reason a party is requesting the connection be closed.
enum DisconnectReason {
// No specific reason / not provided (default for older peers).
DISCONNECT_REASON_UNSPECIFIED = 0;
// The device's provisioning window has expired. The device must be reset
// (power-cycled) to reopen the provisioning window before it will accept a
// connection again.
DISCONNECT_REASON_PROVISIONING_CLOSED = 1;
}
// Request to close the connection.
// Can be sent by both the client and server
message DisconnectRequest {
@@ -166,6 +176,10 @@ message DisconnectRequest {
option (no_delay) = true;
// Do not close the connection before the acknowledgement arrives
// Optional reason the connection is being closed. Older peers that do not
// send this field will report DISCONNECT_REASON_UNSPECIFIED (0).
DisconnectReason reason = 1;
}
message DisconnectResponse {
@@ -296,6 +310,11 @@ message DeviceInfoResponse {
// Serial proxy instance metadata
repeated SerialProxyInfo serial_proxies = 25 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
// Device is unprovisioned and accepts Noise handshakes with the well-known
// all-zeros PSK, so the api encryption key can be provisioned without being
// sent in plaintext (protects against passive sniffing, not active MITM)
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
}
message ListEntitiesRequest {
+77 -8
View File
@@ -25,6 +25,9 @@
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "esphome/core/version.h"
#ifdef USE_PROVISIONING
#include "esphome/components/provisioning/provisioning.h"
#endif
#ifdef USE_DEEP_SLEEP
#include "esphome/components/deep_sleep/deep_sleep_component.h"
@@ -195,6 +198,29 @@ APIConnection::~APIConnection() {
#endif
}
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
void APIConnection::upgrade_helper_to_noise_() {
// The client opened with a Noise hello while this device has no encryption
// key set. Replace the plaintext helper with a Noise helper so the key can
// be provisioned over an encrypted channel: the noise context PSK is all
// zeros when unprovisioned, and NNpsk0 still runs a fresh ephemeral X25519
// exchange, so a passive listener cannot read the session. A publicly known
// PSK authenticates nobody; this protects against sniffing only.
auto *plaintext = static_cast<APIPlaintextFrameHelper *>(this->helper_.get());
uint8_t header[3];
uint8_t header_len = plaintext->get_consumed_header(header);
auto *noise = new APINoiseFrameHelper(plaintext->release_socket_for_switch(), this->parent_->get_noise_ctx());
// Carry over the peername-based client name (Hello has not arrived yet)
const char *name = plaintext->get_client_name();
noise->set_client_name(name, strlen(name));
this->helper_.reset(noise); // destroys the plaintext helper
APIError err = noise->init_from_handoff(header, header_len);
if (err != APIError::OK) {
this->fatal_error_with_log_(LOG_STR("Noise handoff failed"), err);
}
}
#endif // USE_API_NOISE && USE_API_PLAINTEXT
void APIConnection::destroy_active_iterator_() {
switch (this->active_iterator_) {
case ActiveIterator::LIST_ENTITIES:
@@ -253,6 +279,15 @@ void APIConnection::loop() {
// No more data available
break;
} else if (err != APIError::OK) {
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
// Checked inside the error branch to keep the hot err == OK path
// free of it; this can only fire on the first bytes of a plaintext
// helper on an unprovisioned device
if (err == APIError::PROTOCOL_SWITCH_TO_NOISE) {
this->upgrade_helper_to_noise_();
return;
}
#endif
this->fatal_error_with_log_(LOG_STR("Reading failed"), err);
return;
} else {
@@ -1348,7 +1383,7 @@ void APIConnection::on_voice_assistant_set_configuration(const VoiceAssistantSet
#ifdef USE_ZWAVE_PROXY
void APIConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) {
zwave_proxy::global_zwave_proxy->send_frame(msg.data, msg.data_len);
zwave_proxy::global_zwave_proxy->send_frame(this, msg.data, msg.data_len);
}
void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) {
@@ -1711,12 +1746,6 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %" PRIu16 ".%" PRIu16, this->helper_->get_client_name(),
this->helper_->get_peername_to(peername), this->client_api_version_major_, this->client_api_version_minor_);
// TODO: Remove before 2026.8.0 (one version after get_object_id backward compat removal)
if (!this->client_supports_api_version(1, 14)) {
ESP_LOGW(TAG, "'%s' using outdated API %" PRIu16 ".%" PRIu16 ", update to 1.14+", this->helper_->get_client_name(),
this->client_api_version_major_, this->client_api_version_minor_);
}
HelloResponse resp;
resp.api_version_major = 1;
resp.api_version_minor = 14;
@@ -1724,6 +1753,19 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
resp.server_info = ESPHOME_VERSION_REF;
resp.name = StringRef(App.get_name());
#ifdef USE_PROVISIONING
if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) {
// The provisioning window has closed without the device being provisioned.
// Acknowledge the hello so the client can read the server name, then request
// disconnect with the reason. Authentication is intentionally not completed.
this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Provisioning closed; rejecting connection"));
this->send_message(resp);
DisconnectRequest req;
req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED;
return this->send_message(req);
}
#endif
// Auto-authenticate - password auth was removed in ESPHome 2026.1.0
this->complete_authentication_();
@@ -1844,6 +1886,12 @@ bool APIConnection::send_device_info_response_() {
#endif
#ifdef USE_API_NOISE
resp.api_encryption_supported = true;
#ifndef USE_API_NOISE_PSK_FROM_YAML
// No key from YAML: while no key is set, the key can be provisioned over a
// zero-PSK Noise connection. Gated on the YAML define (not the plaintext
// one) so this advertisement survives the plaintext removal in 2027.2.0.
resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk();
#endif
#endif
#ifdef USE_DEVICES
size_t device_index = 0;
@@ -1874,7 +1922,8 @@ void APIConnection::on_hello_request(const HelloRequest &msg) {
this->on_fatal_error();
}
}
void APIConnection::on_disconnect_request() {
void APIConnection::on_disconnect_request(const DisconnectRequest & /*msg*/) {
// The reason is informational when a client disconnects us; we always ack and close.
if (!this->send_disconnect_response_()) {
this->on_fatal_error();
}
@@ -2002,6 +2051,15 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
NoiseEncryptionSetKeyResponse resp;
resp.success = false;
#ifdef USE_PROVISIONING
// Refuse to set a key once the provisioning window has closed (defense in depth;
// such connections are already rejected at hello).
if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) {
ESP_LOGW(TAG, "Provisioning closed; rejecting key set");
return this->send_message(resp);
}
#endif
psk_t psk{};
if (msg.key_len == 0) {
if (this->parent_->clear_noise_psk(true)) {
@@ -2011,10 +2069,21 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
}
} else if (base64_decode(msg.key, msg.key_len, psk.data(), psk.size()) != psk.size()) {
ESP_LOGW(TAG, "Invalid encryption key length");
} else if (APINoiseContext::is_all_zeros(psk)) {
// Accepting the reserved provisioning PSK would report success without
// enabling encryption (or silently clear an existing key)
ESP_LOGW(TAG, "Rejecting all-zero encryption key");
} else if (!this->parent_->save_noise_psk(psk, true)) {
ESP_LOGW(TAG, "Failed to save encryption key");
} else {
resp.success = true;
#ifdef USE_API_PLAINTEXT
if (this->helper_->frame_footer_size() == 0) {
// Plaintext transport has no frame footer; Noise always has the MAC footer.
// Remove after 2027.2.0 together with plaintext support on keyless devices.
ESP_LOGW(TAG, "Key received over plaintext; deprecated, will be removed in 2027.2.0");
}
#endif
}
return this->send_message(resp);
+12 -3
View File
@@ -166,10 +166,14 @@ class APIConnection final : public APIServerConnectionBase {
#endif
bool try_send_log_message(int level, const char *tag, const char *line, size_t message_len);
#ifdef USE_API_HOMEASSISTANT_SERVICES
void send_homeassistant_action(const HomeassistantActionRequest &call) {
// Returns whether this client has subscribed to Home Assistant actions; the message
// is only handed to the send path when subscribed. A true return does not guarantee
// delivery - it lets the caller warn when no connected client has the subscription.
bool send_homeassistant_action(const HomeassistantActionRequest &call) {
if (!this->flags_.service_call_subscription)
return;
return false;
this->send_message(call);
return true;
}
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
void on_homeassistant_action_response(const HomeassistantActionResponse &msg);
@@ -259,7 +263,7 @@ class APIConnection final : public APIServerConnectionBase {
void on_get_time_response(const GetTimeResponse &value);
#endif
void on_hello_request(const HelloRequest &msg);
void on_disconnect_request();
void on_disconnect_request(const DisconnectRequest &msg);
void on_ping_request();
void on_device_info_request();
void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
@@ -626,6 +630,11 @@ class APIConnection final : public APIServerConnectionBase {
void destroy_active_iterator_();
void begin_iterator_(ActiveIterator type);
void finalize_iterator_sync_();
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
// Swap the plaintext helper for a Noise helper after the client opened
// with a Noise hello on an unprovisioned device (zero-PSK provisioning).
void upgrade_helper_to_noise_();
#endif
#ifdef USE_CAMERA
std::unique_ptr<camera::CameraImageReader> image_reader_;
#endif
@@ -97,6 +97,8 @@ const LogString *api_error_to_logstr(APIError err) {
return LOG_STR("BAD_HANDSHAKE_ERROR_BYTE");
}
#endif
// PROTOCOL_SWITCH_TO_NOISE is intercepted in APIConnection::loop() before
// any logging can happen, so it intentionally has no entry here.
return LOG_STR("UNKNOWN");
}
+11
View File
@@ -88,6 +88,11 @@ enum class APIError : uint16_t {
HANDSHAKESTATE_SPLIT_FAILED = 1020,
BAD_HANDSHAKE_ERROR_BYTE = 1021,
#endif
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
// Not an error: an unprovisioned device received a Noise client hello on a
// plaintext connection; the caller must hand the socket off to a Noise helper.
PROTOCOL_SWITCH_TO_NOISE = 1023,
#endif
};
const LogString *api_error_to_logstr(APIError err);
@@ -200,6 +205,12 @@ class APIFrameHelper {
// or track that they stopped early and retry without this check.
// See Socket::ready() for details.
bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); }
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
// Move the socket out of this helper so a replacement helper can take it
// over (plaintext to Noise handoff on unprovisioned devices). The drained
// helper must be destroyed right after.
std::unique_ptr<socket::Socket> release_socket_for_switch() { return std::move(this->socket_); }
#endif
// Release excess memory from internal buffers after initial sync
void release_buffers() {
// rx_buf_: Safe to clear only if no partial read in progress.
@@ -109,6 +109,40 @@ APIError APINoiseFrameHelper::init() {
state_ = State::CLIENT_HELLO;
return APIError::OK;
}
#ifdef USE_API_PLAINTEXT
APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) {
APIError err = this->init();
if (err != APIError::OK) {
return err;
}
// Seed the header bytes the plaintext helper consumed before detecting the
// Noise indicator; try_read_frame_ resumes from rx_header_buf_len_.
std::memcpy(this->rx_header_buf_, header, header_len);
this->rx_header_buf_len_ = header_len;
// Pump the handshake without gating on socket_->ready(): on LWIP the
// plaintext helper's partial read can drain rcvevent while the rest of the
// client hello sits in the lastdata cache, so ready() may report false even
// though data is available.
return this->pump_handshake_();
}
#endif // USE_API_PLAINTEXT
/// Drive the handshake state machine until DATA, WOULD_BLOCK, or a fatal
/// error. WOULD_BLOCK is not an error: reads stop naturally on EWOULDBLOCK
/// and resume on the next loop().
APIError APINoiseFrameHelper::pump_handshake_() {
while (this->state_ != State::DATA) {
APIError err = this->state_action_();
if (err == APIError::WOULD_BLOCK) {
break;
}
if (err != APIError::OK) {
return err;
}
}
return APIError::OK;
}
// Helper for handling handshake frame errors
APIError APINoiseFrameHelper::handle_handshake_frame_error_(APIError aerr) {
if (aerr == APIError::BAD_INDICATOR) {
@@ -131,16 +165,13 @@ APIError APINoiseFrameHelper::handle_noise_error_(int err, const LogString *func
/// Run through handshake messages (if in that phase)
APIError APINoiseFrameHelper::loop() {
// Cache ready() outside the loop. On ESP8266 LWIP raw TCP, ready() returns false once
// the rx buffer is consumed. Re-checking each iteration would block handshake writes
// that must follow reads, deadlocking the handshake. state_action() will return
// WOULD_BLOCK when no more data is available to read.
bool socket_ready = this->socket_->ready();
while (state_ != State::DATA && socket_ready) {
APIError err = state_action_();
if (err == APIError::WOULD_BLOCK) {
break;
}
// Check ready() once, not per state transition. On ESP8266 LWIP raw TCP,
// ready() returns false once the rx buffer is consumed. Re-checking each
// iteration would block handshake writes that must follow reads,
// deadlocking the handshake. pump_handshake_() stops on WOULD_BLOCK when
// no more data is available to read.
if (state_ != State::DATA && this->socket_->ready()) {
APIError err = this->pump_handshake_();
if (err != APIError::OK) {
return err;
}
@@ -22,12 +22,20 @@ class APINoiseFrameHelper final : public APIFrameHelper {
}
~APINoiseFrameHelper() override;
APIError init() override;
#ifdef USE_API_PLAINTEXT
// Take over a connection whose first bytes were consumed by a plaintext
// helper on an unprovisioned device (see APIError::PROTOCOL_SWITCH_TO_NOISE).
// Seeds the already-read header bytes and pumps the handshake state machine
// until it would block.
APIError init_from_handoff(const uint8_t *header, uint8_t header_len);
#endif
APIError loop() override;
APIError read_packet(ReadPacketBuffer *buffer) override;
APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override;
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) override;
protected:
APIError pump_handshake_();
APIError state_action_();
APIError state_action_client_hello_();
APIError state_action_server_hello_();
@@ -89,6 +89,17 @@ APIError APIPlaintextFrameHelper::try_read_frame_() {
// If this was the first read, validate the indicator byte
if (rx_header_buf_pos_ == 0 && received > 0) {
if (rx_header_buf_[0] != 0x00) {
#ifdef USE_API_NOISE
// Dual build (encryption supported but no key set): a 0x01 first byte
// is a Noise client hello. Hand the connection off to a Noise helper
// running the all-zeros provisioning PSK so the encryption key can be
// set without crossing the wire in plaintext. Preserve the bytes we
// already consumed; they are the start of the Noise 3-byte header.
if (rx_header_buf_[0] == 0x01) {
rx_header_buf_pos_ = static_cast<uint8_t>(received);
return APIError::PROTOCOL_SWITCH_TO_NOISE;
}
#endif
state_ = State::FAILED;
HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]);
return APIError::BAD_INDICATOR;
@@ -23,6 +23,15 @@ class APIPlaintextFrameHelper final : public APIFrameHelper {
APIError read_packet(ReadPacketBuffer *buffer) override;
APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override;
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) override;
#ifdef USE_API_NOISE
// After try_read_frame_ returned PROTOCOL_SWITCH_TO_NOISE: copy out the
// header bytes already consumed from the socket (at most 3, the size of the
// Noise fixed header) so the replacement Noise helper can be seeded with them.
uint8_t get_consumed_header(uint8_t out[3]) const {
memcpy(out, this->rx_header_buf_, this->rx_header_buf_pos_);
return this->rx_header_buf_pos_;
}
#endif
protected:
APIError try_read_frame_();
+12 -5
View File
@@ -10,13 +10,20 @@ using psk_t = std::array<uint8_t, 32>;
class APINoiseContext {
public:
// The all-zeros PSK is reserved: it marks the device as unprovisioned and
// doubles as the well-known provisioning PSK that unprovisioned devices
// accept for Noise handshakes (passive-sniffing protection only, no
// authentication). It is never a valid real key.
static bool is_all_zeros(const psk_t &psk) {
uint8_t acc = 0;
for (uint8_t b : psk) {
acc |= b;
}
return acc == 0;
}
void set_psk(psk_t psk) {
this->psk_ = psk;
bool has_psk = false;
for (auto i : psk) {
has_psk |= i;
}
this->has_psk_ = has_psk;
this->has_psk_ = !is_all_zeros(psk);
}
const psk_t &get_psk() const { return this->psk_; }
bool has_psk() const { return this->has_psk_; }
+26
View File
@@ -47,6 +47,26 @@ uint32_t HelloResponse::calculate_size() const {
size += 2 + this->name.size();
return size;
}
bool DisconnectRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) {
case 1:
this->reason = static_cast<enums::DisconnectReason>(value);
break;
default:
return false;
}
return true;
}
uint8_t *DisconnectRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->reason));
return pos;
}
uint32_t DisconnectRequest::calculate_size() const {
uint32_t size = 0;
size += this->reason ? 2 : 0;
return size;
}
#ifdef USE_AREAS
uint8_t *AreaInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
@@ -150,6 +170,9 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_
for (const auto &it : this->serial_proxies) {
ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 25, it);
}
#endif
#ifdef USE_API_NOISE
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable);
#endif
return pos;
}
@@ -212,6 +235,9 @@ uint32_t DeviceInfoResponse::calculate_size() const {
for (const auto &it : this->serial_proxies) {
size += ProtoSize::calc_message_force(2, it.calculate_size());
}
#endif
#ifdef USE_API_NOISE
size += ProtoSize::calc_bool(2, this->api_encryption_provisionable);
#endif
return size;
}
+14 -3
View File
@@ -11,6 +11,10 @@ namespace esphome::api {
namespace enums {
enum DisconnectReason : uint32_t {
DISCONNECT_REASON_UNSPECIFIED = 0,
DISCONNECT_REASON_PROVISIONING_CLOSED = 1,
};
enum SerialProxyPortType : uint32_t {
SERIAL_PROXY_PORT_TYPE_TTL = 0,
SERIAL_PROXY_PORT_TYPE_RS232 = 1,
@@ -427,18 +431,22 @@ class HelloResponse final : public ProtoMessage {
protected:
};
class DisconnectRequest final : public ProtoMessage {
class DisconnectRequest final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 5;
static constexpr uint8_t ESTIMATED_SIZE = 0;
static constexpr uint8_t ESTIMATED_SIZE = 2;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("disconnect_request"); }
#endif
enums::DisconnectReason reason{};
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:
bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
};
class DisconnectResponse final : public ProtoMessage {
public:
@@ -525,7 +533,7 @@ class SerialProxyInfo final : public ProtoMessage {
class DeviceInfoResponse final : public ProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 10;
static constexpr uint16_t ESTIMATED_SIZE = 309;
static constexpr uint16_t ESTIMATED_SIZE = 312;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("device_info_response"); }
#endif
@@ -580,6 +588,9 @@ class DeviceInfoResponse final : public ProtoMessage {
#endif
#ifdef USE_SERIAL_PROXY
std::array<SerialProxyInfo, SERIAL_PROXY_COUNT> serial_proxies{};
#endif
#ifdef USE_API_NOISE
bool api_encryption_provisionable{false};
#endif
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
+15 -1
View File
@@ -125,6 +125,16 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint
}
#pragma GCC diagnostic pop
template<> const char *proto_enum_to_string<enums::DisconnectReason>(enums::DisconnectReason value) {
switch (value) {
case enums::DISCONNECT_REASON_UNSPECIFIED:
return ESPHOME_PSTR("DISCONNECT_REASON_UNSPECIFIED");
case enums::DISCONNECT_REASON_PROVISIONING_CLOSED:
return ESPHOME_PSTR("DISCONNECT_REASON_PROVISIONING_CLOSED");
default:
return ESPHOME_PSTR("UNKNOWN");
}
}
template<> const char *proto_enum_to_string<enums::SerialProxyPortType>(enums::SerialProxyPortType value) {
switch (value) {
case enums::SERIAL_PROXY_PORT_TYPE_TTL:
@@ -864,7 +874,8 @@ const char *HelloResponse::dump_to(DumpBuffer &out) const {
return out.c_str();
}
const char *DisconnectRequest::dump_to(DumpBuffer &out) const {
out.append_p(ESPHOME_PSTR("DisconnectRequest {}"));
MessageDumpHelper helper(out, ESPHOME_PSTR("DisconnectRequest"));
dump_field(out, ESPHOME_PSTR("reason"), static_cast<enums::DisconnectReason>(this->reason));
return out.c_str();
}
const char *DisconnectResponse::dump_to(DumpBuffer &out) const {
@@ -971,6 +982,9 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
it.dump_to(out);
out.append("\n");
}
#endif
#ifdef USE_API_NOISE
dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable);
#endif
return out.c_str();
}
+4 -2
View File
@@ -51,10 +51,12 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
case DisconnectRequest::MESSAGE_TYPE: {
DisconnectRequest msg;
msg.decode(msg_data, msg_size);
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_receive_message_(LOG_STR("on_disconnect_request"));
this->log_receive_message_(LOG_STR("on_disconnect_request"), msg);
#endif
this->on_disconnect_request();
this->on_disconnect_request(msg);
break;
}
case DisconnectResponse::MESSAGE_TYPE: {
+1 -1
View File
@@ -21,7 +21,7 @@ class APIServerConnectionBase {
void on_hello_request(const HelloRequest &value){};
void on_disconnect_request(){};
void on_disconnect_request(const DisconnectRequest &value){};
void on_disconnect_response(){};
void on_ping_request(){};
void on_ping_response(){};
+60 -11
View File
@@ -107,8 +107,30 @@ void APIServer::setup() {
// Initialize last_connected_ for reboot timeout tracking
this->last_connected_ = App.get_loop_component_start_time();
// Set warning status if reboot timeout is enabled
if (this->reboot_timeout_ != 0) {
#if defined(USE_PROVISIONING) && defined(USE_API_NOISE)
// Register with the provisioning manager (provisioning:) as a source and
// report our current state (provisioned == an encryption key is set). When the
// window closes, disconnect any client still attempting to provision so it learns
// the reason. The manager owns the timeout, window state and on_timeout automation.
if (provisioning::global_provisioning_manager != nullptr) {
this->provisioning_source_ = provisioning::global_provisioning_manager->register_source();
provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_,
this->noise_ctx_.has_psk());
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
for (auto &c : this->active_clients()) {
DisconnectRequest req;
req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED;
// Best-effort: if the send buffer is full the reason is dropped, but the
// client still learns the window is closed when it reconnects (rejected at
// hello) or via the socket close.
c->send_message(req);
}
});
}
#endif
// Set warning status if reboot timeout is enabled (suppressed while provisioning
// is pending so the device waits to be onboarded instead of rebooting).
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_set_warning(LOG_STR("waiting for client connection"));
}
}
@@ -121,8 +143,10 @@ void APIServer::loop() {
if (this->api_connection_count_ == 0) {
// Check reboot timeout - done in loop to avoid scheduler heap churn
// (cancelled scheduler items sit in heap memory until their scheduled time)
if (this->reboot_timeout_ != 0) {
// (cancelled scheduler items sit in heap memory until their scheduled time).
// Suppressed while a provisioning window is pending so the device waits to be
// onboarded / reset instead of rebooting itself; resumes once provisioned.
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
const uint32_t now = App.get_loop_component_start_time();
if (now - this->last_connected_ > this->reboot_timeout_) {
ESP_LOGE(TAG, "No clients; rebooting");
@@ -194,7 +218,8 @@ void APIServer::remove_client_(uint8_t client_index) {
this->clients_[last_index].reset();
// Last client disconnected - set warning and start tracking for reboot timeout
if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0) {
// (suppressed while provisioning is pending - see loop()).
if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_set_warning(LOG_STR("waiting for client connection"));
this->last_connected_ = App.get_loop_component_start_time();
}
@@ -232,7 +257,7 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
conn->start();
// First client connected - clear warning and update timestamp
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0) {
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_clear_warning();
this->last_connected_ = App.get_loop_component_start_time();
}
@@ -401,8 +426,16 @@ void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = bat
#ifdef USE_API_HOMEASSISTANT_SERVICES
void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) {
bool has_subscriber = false;
for (auto &client : this->active_clients()) {
client->send_homeassistant_action(call);
has_subscriber |= client->send_homeassistant_action(call);
}
if (!has_subscriber) {
// Home Assistant subscribes to actions shortly *after* authenticating, so actions
// fired right at connection time (on_client_connected, on_time_sync, ...) can
// arrive before the subscription and are lost - warn instead of failing silently.
ESP_LOGW(TAG, "Home Assistant %s '%s' dropped; %s", call.is_event ? "event" : "action", call.service.c_str(),
this->is_connected() ? "client has not subscribed to actions (yet)" : "no client connected");
}
}
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
@@ -572,8 +605,16 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) {
}
SavedNoisePsk new_saved_psk{psk};
return this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"),
make_active);
bool result = this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"),
make_active);
#ifdef USE_PROVISIONING
// The device now has a key; report provisioned so the provisioning window is
// satisfied and the reboot timeout resumes normal operation.
if (result && provisioning::global_provisioning_manager != nullptr) {
provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, true);
}
#endif
return result;
#endif
}
bool APIServer::clear_noise_psk(bool make_active) {
@@ -584,8 +625,16 @@ bool APIServer::clear_noise_psk(bool make_active) {
return false;
#else
SavedNoisePsk empty_psk{};
return this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"),
make_active);
bool result = this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"),
make_active);
#ifdef USE_PROVISIONING
// The key was cleared; report unprovisioned so a subsequent reboot reopens the
// provisioning window.
if (result && provisioning::global_provisioning_manager != nullptr) {
provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, false);
}
#endif
return result;
#endif
}
#endif
+20 -1
View File
@@ -14,6 +14,9 @@
#include "esphome/core/controller.h"
#include "esphome/core/log.h"
#include "esphome/core/string_ref.h"
#ifdef USE_PROVISIONING
#include "esphome/components/provisioning/provisioning.h"
#endif
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
#endif
@@ -255,6 +258,19 @@ class APIServer final : public Component,
// Remove a disconnected client by index. Swaps with the last populated slot and resets it.
void __attribute__((noinline)) remove_client_(uint8_t client_index);
#ifdef USE_PROVISIONING
// True while a configured provisioning window is still pending (the device is
// unprovisioned). Suppresses the reboot timeout and its warning so the device is
// not auto-rebooted while waiting to be provisioned. False when no provisioning
// window is configured.
bool provisioning_pending_() const {
return provisioning::global_provisioning_manager != nullptr &&
provisioning::global_provisioning_manager->window_pending();
}
#else
bool provisioning_pending_() const { return false; }
#endif
#ifdef USE_API_NOISE
bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg,
bool make_active);
@@ -332,7 +348,10 @@ class APIServer final : public Component,
uint8_t listen_backlog_{4};
bool shutting_down_ = false;
uint8_t api_connection_count_{0};
// 7 bytes used, 1 byte padding
#if defined(USE_PROVISIONING) && defined(USE_API_NOISE)
// Index assigned by the provisioning manager for reporting this transport's state.
uint8_t provisioning_source_{0};
#endif
#ifdef USE_API_NOISE
APINoiseContext noise_ctx_;
+17 -11
View File
@@ -18,7 +18,7 @@ with warnings.catch_warnings():
import contextlib
from esphome.const import CONF_KEY, CONF_PORT, __version__
from esphome.core import CORE, EsphomeError
from esphome.core import CORE
from esphome.util import safe_print
from . import CONF_ENCRYPTION
@@ -36,15 +36,17 @@ class _LogLineProcessor:
"""Feeds incoming log lines to the stack-trace decoder.
Two responsibilities beyond just calling the decoder:
1. Catch EsphomeError. on_log runs inside an asyncio protocol
callback; if an exception escapes, the loop tears the transport
down with "Fatal error: protocol.data_received() call failed."
and ReconnectLogic immediately reconnects, the device replays
the same crash trace, and we loop forever.
2. Disable decoding after the first failure. _decode_pc shells out
to PlatformIO via _run_idedata, which is expensive; a single
crash dump can contain many PC/BT lines and we don't want to
retry the failing subprocess for each one.
1. Catch everything the decoder can raise. aioesphomeapi isolates
exceptions raised by log handlers, so an escaping one no longer
kills the session, but it does log a full traceback per line. A
crash dump carries a PC line plus one per backtrace frame, so the
tracebacks bury the dump the user is trying to read. Decoding is a
diagnostic nicety; nothing it raises is worth that noise.
2. Disable decoding after the first failure. _decode_pc shells out to
the toolchain to resolve addr2line, which is expensive; a single
crash dump can contain many PC/BT lines and we don't want to retry
the failing subprocess for each one. This only works if every
failure is caught, which is why 1 is not narrowed to EsphomeError.
"""
def __init__(self, config: dict[str, Any], platform_handler: Any | None) -> None:
@@ -61,12 +63,13 @@ class _LogLineProcessor:
self.backtrace_state = self._platform_handler(
self._config, raw_line, self.backtrace_state
)
except EsphomeError as exc:
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except
self._decode_enabled = False
self.backtrace_state = False
# _run_idedata raises EsphomeError with no message; fall back
# to a generic explanation when str(exc) is empty.
detail = str(exc) or "build artifacts not found locally"
_LOGGER.debug("Stack-trace decoding failed", exc_info=True)
_LOGGER.warning(
"Crash trace decoding unavailable: %s. "
"Run 'esphome compile' for this device to enable PC decoding.",
@@ -152,6 +155,9 @@ async def async_run_logs(
name=name,
subscribe_states=subscribe_states,
allow_plaintext_fallback=True,
# A top-level ``deep_sleep:`` block means the device is only awake
# briefly; cap the reconnect backoff so a wake window is not missed.
deep_sleep="deep_sleep" in config,
)
try:
await asyncio.Event().wait()
+1
View File
@@ -7,6 +7,7 @@ AQICalculatorType = aqi_ns.enum("AQICalculatorType")
CONF_AQI = "aqi"
CONF_CALCULATION_TYPE = "calculation_type"
CONF_EXTENDED_RANGE = "extended_range"
AQI_CALCULATION_TYPE = {
"CAQI": AQICalculatorType.CAQI_TYPE,
@@ -6,7 +6,7 @@ namespace esphome::aqi {
class AbstractAQICalculator {
public:
virtual uint16_t get_aqi(float pm2_5_value, float pm10_0_value) = 0;
virtual uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool extended_range) = 0;
};
} // namespace esphome::aqi
+20 -10
View File
@@ -11,10 +11,12 @@ namespace esphome::aqi {
class AQICalculator : public AbstractAQICalculator {
public:
uint16_t get_aqi(float pm2_5_value, float pm10_0_value) override {
float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID);
float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID);
uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool extended_range) override {
float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID, extended_range);
float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID, extended_range);
float aqi = std::max({pm2_5_index, pm10_0_index, 0.0f});
// extended_range lets the index run past the standard maximum, so clamp to the sensor's range.
aqi = std::min(aqi, static_cast<float>(std::numeric_limits<uint16_t>::max()));
return static_cast<uint16_t>(std::lround(aqi));
}
@@ -30,7 +32,7 @@ class AQICalculator : public AbstractAQICalculator {
{35.5f, 55.5f},
{55.5f, 125.5f},
{125.5f, 225.5f},
{225.5f, std::numeric_limits<float>::max()}
{225.5f, 500.4f} // EPA 2024: AQI 301-500 maps to PM2.5 225.5-500.4 ug/m3
// clang-format on
};
@@ -41,11 +43,11 @@ class AQICalculator : public AbstractAQICalculator {
{155.0f, 255.0f},
{255.0f, 355.0f},
{355.0f, 425.0f},
{425.0f, std::numeric_limits<float>::max()}
{425.0f, 604.0f} // EPA: AQI 301-500 maps to PM10 425-604 ug/m3 (top of the 401-500 band)
// clang-format on
};
static float calculate_index(float value, const float array[NUM_LEVELS][2]) {
static float calculate_index(float value, const float array[NUM_LEVELS][2], bool extended_range) {
int grid_index = get_grid_index(value, array);
if (grid_index == -1) {
return -1.0f;
@@ -55,14 +57,22 @@ class AQICalculator : public AbstractAQICalculator {
float conc_lo = array[grid_index][0];
float conc_hi = array[grid_index][1];
return (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo;
float index = (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo;
// Concentrations above the highest breakpoint run the linear fit past aqi_hi. By default we
// clamp to the standard maximum; with extended_range we keep the extrapolated "over-range"
// value so heavy pollution reports numbers beyond what the standard defines.
if (grid_index == NUM_LEVELS - 1 && !extended_range && index > aqi_hi) {
return aqi_hi;
}
return index;
}
static int get_grid_index(float value, const float array[NUM_LEVELS][2]) {
for (int i = 0; i < NUM_LEVELS; i++) {
const bool in_range =
(value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive
: (value < array[i][1])); // others exclusive on hi
// The top band is open-ended: any value at or above its lower breakpoint falls into it,
// and calculate_index() decides whether to clamp or extrapolate.
const bool in_range = (value >= array[i][0]) && (i == NUM_LEVELS - 1 || value < array[i][1]);
if (in_range) {
return i;
}
+2 -1
View File
@@ -24,6 +24,7 @@ void AQISensor::setup() {
void AQISensor::dump_config() {
ESP_LOGCONFIG(TAG, "AQI Sensor:");
ESP_LOGCONFIG(TAG, " Calculation Type: %s", this->aqi_calc_type_ == AQI_TYPE ? "AQI" : "CAQI");
ESP_LOGCONFIG(TAG, " Extended Range: %s", this->extended_range_ ? "enabled" : "disabled");
if (this->pm_2_5_sensor_ != nullptr) {
ESP_LOGCONFIG(TAG, " PM2.5 Sensor: '%s'", this->pm_2_5_sensor_->get_name().c_str());
}
@@ -44,7 +45,7 @@ void AQISensor::calculate_aqi_() {
return;
}
uint16_t aqi = calculator->get_aqi(this->pm_2_5_value_, this->pm_10_0_value_);
uint16_t aqi = calculator->get_aqi(this->pm_2_5_value_, this->pm_10_0_value_, this->extended_range_);
this->publish_state(aqi);
}
+2
View File
@@ -14,6 +14,7 @@ class AQISensor final : public sensor::Sensor, public Component {
void set_pm_2_5_sensor(sensor::Sensor *sensor) { this->pm_2_5_sensor_ = sensor; }
void set_pm_10_0_sensor(sensor::Sensor *sensor) { this->pm_10_0_sensor_ = sensor; }
void set_aqi_calculation_type(AQICalculatorType type) { this->aqi_calc_type_ = type; }
void set_extended_range(bool extended_range) { this->extended_range_ = extended_range; }
protected:
void calculate_aqi_();
@@ -21,6 +22,7 @@ class AQISensor final : public sensor::Sensor, public Component {
sensor::Sensor *pm_2_5_sensor_{nullptr};
sensor::Sensor *pm_10_0_sensor_{nullptr};
AQICalculatorType aqi_calc_type_{AQI_TYPE};
bool extended_range_{false};
AQICalculatorFactory aqi_calculator_factory_;
float pm_2_5_value_{NAN};
+13 -10
View File
@@ -9,25 +9,28 @@ namespace esphome::aqi {
class CAQICalculator : public AbstractAQICalculator {
public:
uint16_t get_aqi(float pm2_5_value, float pm10_0_value) override {
// The CAQI (CITEAIR) scale defines no maximum: its top "Very high" class is simply ">100". We
// therefore always extrapolate the top band past 100 without limit, so the extended_range flag
// (which lifts the AQI calculator's fixed 500 cap) has no meaning here and is ignored.
uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool /*extended_range*/) override {
float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID);
float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID);
float aqi = std::max({pm2_5_index, pm10_0_index, 0.0f});
aqi = std::min(aqi, static_cast<float>(std::numeric_limits<uint16_t>::max()));
return static_cast<uint16_t>(std::lround(aqi));
}
protected:
static constexpr int NUM_LEVELS = 5;
static constexpr int NUM_LEVELS = 4;
static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}, {101, 400}};
static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}};
static constexpr float PM2_5_GRID[NUM_LEVELS][2] = {
// clang-format off
{0.0f, 15.1f},
{15.1f, 30.1f},
{30.1f, 55.1f},
{55.1f, 110.1f},
{110.1f, std::numeric_limits<float>::max()}
{55.1f, 110.1f}
// clang-format on
};
@@ -36,8 +39,7 @@ class CAQICalculator : public AbstractAQICalculator {
{0.0f, 25.1f},
{25.1f, 50.1f},
{50.1f, 90.1f},
{90.1f, 180.1f},
{180.1f, std::numeric_limits<float>::max()}
{90.1f, 180.1f}
// clang-format on
};
@@ -52,14 +54,15 @@ class CAQICalculator : public AbstractAQICalculator {
float conc_lo = array[grid_index][0];
float conc_hi = array[grid_index][1];
// The top band is open-ended (see get_grid_index), so for concentrations above the last
// breakpoint this linear fit extrapolates past 100 unbounded, matching CAQI's open ">100" class.
return (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo;
}
static int get_grid_index(float value, const float array[NUM_LEVELS][2]) {
for (int i = 0; i < NUM_LEVELS; i++) {
const bool in_range =
(value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive
: (value < array[i][1])); // others exclusive on hi
// The top band is open-ended: any value at or above its lower breakpoint falls into it.
const bool in_range = (value >= array[i][0]) && (i == NUM_LEVELS - 1 || value < array[i][1]);
if (in_range) {
return i;
}
+17 -3
View File
@@ -8,14 +8,25 @@ from esphome.const import (
STATE_CLASS_MEASUREMENT,
)
from . import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE, aqi_ns
from . import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE, CONF_EXTENDED_RANGE, aqi_ns
CODEOWNERS = ["@jasstrong"]
DEPENDENCIES = ["sensor"]
AQISensor = aqi_ns.class_("AQISensor", sensor.Sensor, cg.Component)
CONFIG_SCHEMA = (
def _validate_extended_range(config):
if CONF_EXTENDED_RANGE in config and config[CONF_CALCULATION_TYPE] == "CAQI":
raise cv.Invalid(
f"'{CONF_EXTENDED_RANGE}' is not supported with 'calculation_type: CAQI'. "
"CAQI has no maximum value by specification, so it is always reported unbounded.",
[CONF_EXTENDED_RANGE],
)
return config
CONFIG_SCHEMA = cv.All(
sensor.sensor_schema(
AQISensor,
accuracy_decimals=0,
@@ -29,9 +40,11 @@ CONFIG_SCHEMA = (
cv.Required(CONF_CALCULATION_TYPE): cv.enum(
AQI_CALCULATION_TYPE, upper=True
),
cv.Optional(CONF_EXTENDED_RANGE): cv.boolean,
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(cv.COMPONENT_SCHEMA),
_validate_extended_range,
)
@@ -46,3 +59,4 @@ async def to_code(config):
cg.add(var.set_pm_10_0_sensor(pm_10_0_sensor))
cg.add(var.set_aqi_calculation_type(config[CONF_CALCULATION_TYPE]))
cg.add(var.set_extended_range(config.get(CONF_EXTENDED_RANGE, False)))
+1 -5
View File
@@ -24,11 +24,7 @@ void I2CAS3935Component::write_register(uint8_t reg, uint8_t mask, uint8_t bits,
uint8_t I2CAS3935Component::read_register(uint8_t reg) {
uint8_t value;
if (write(&reg, 1) != i2c::ERROR_OK) {
ESP_LOGW(TAG, "Writing register failed!");
return 0;
}
if (read(&value, 1) != i2c::ERROR_OK) {
if (!this->read_byte(reg, &value)) {
ESP_LOGW(TAG, "Reading register failed!");
return 0;
}
+2 -2
View File
@@ -5,7 +5,7 @@ from esphome.const import (
CONF_CLEAR,
CONF_GAIN,
CONF_ID,
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_EMPTY,
ICON_BRIGHTNESS_5,
STATE_CLASS_MEASUREMENT,
)
@@ -54,7 +54,7 @@ SENSOR_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_COUNTS,
icon=ICON_BRIGHTNESS_5,
accuracy_decimals=0,
device_class=DEVICE_CLASS_ILLUMINANCE,
device_class=DEVICE_CLASS_EMPTY,
state_class=STATE_CLASS_MEASUREMENT,
)
@@ -65,12 +65,11 @@ optional<ParseResult> ATCMiThermometer::parse_header_(const esp32_ble_tracker::S
return {};
}
static uint8_t last_frame_count = 0;
if (last_frame_count == raw[12]) {
ESP_LOGVV(TAG, "parse_header(): duplicate data packet received (%hhu).", last_frame_count);
if (this->last_frame_count_ == raw[12]) {
ESP_LOGVV(TAG, "parse_header(): duplicate data packet received (%hhu).", this->last_frame_count_);
return {};
}
last_frame_count = raw[12];
this->last_frame_count_ = raw[12];
return result;
}
@@ -38,6 +38,8 @@ class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBT
sensor::Sensor *battery_voltage_{nullptr};
sensor::Sensor *signal_strength_{nullptr};
uint8_t last_frame_count_{0};
optional<ParseResult> parse_header_(const esp32_ble_tracker::ServiceData &service_data);
bool parse_message_(const std::vector<uint8_t> &message, ParseResult &result);
bool report_results_(const optional<ParseResult> &result, const char *address);
+3 -1
View File
@@ -113,7 +113,9 @@ def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]:
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["NONE"]
if file_type == "wav":
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["WAV"]
elif file_type in ("mp3", "mpeg", "mpga"):
elif file_type in ("mp1", "mp2", "mp3", "mpeg", "mpga"):
# With puremagic >=2.0 this can cause some MP3 (Layer III) files to be labeled as "mp1"/"mp2".
# Treat those labels as MP3 so we still pick the MP3 decoder.
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["MP3"]
elif file_type == "flac":
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["FLAC"]
@@ -37,15 +37,15 @@ namespace esphome::beken_spi_led_strip {
static const char *const TAG = "beken_spi_led_strip";
struct spi_data_t {
struct SpiData {
SemaphoreHandle_t dma_tx_semaphore;
volatile bool tx_in_progress;
bool first_run;
};
static spi_data_t *spi_data = nullptr;
static SpiData *spi_data = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static void set_spi_ctrl_register(unsigned long bit, bool val) {
static void set_spi_ctrl_register(uint32_t bit, bool val) {
uint32_t value = REG_READ(SPI_CTRL);
if (val == 0) {
value &= ~bit;
@@ -55,7 +55,7 @@ static void set_spi_ctrl_register(unsigned long bit, bool val) {
REG_WRITE(SPI_CTRL, value);
}
static void set_spi_config_register(unsigned long bit, bool val) {
static void set_spi_config_register(uint32_t bit, bool val) {
uint32_t value = REG_READ(SPI_CONFIG);
if (val == 0) {
value &= ~bit;
@@ -67,7 +67,7 @@ static void set_spi_config_register(unsigned long bit, bool val) {
void spi_dma_tx_enable(bool enable) {
GDMA_CFG_ST en_cfg;
set_spi_config_register(SPI_TX_EN, enable ? 1 : 0);
set_spi_config_register(SPI_TX_EN, enable);
en_cfg.channel = SPI_TX_DMA_CHANNEL;
en_cfg.param = enable ? 1 : 0;
sddev_control(GDMA_DEV_NAME, CMD_GDMA_SET_DMA_ENABLE, &en_cfg);
@@ -110,13 +110,13 @@ static void spi_set_clock(uint32_t max_hz) {
param &= ~(SPI_CKR_MASK << SPI_CKR_POSI);
param |= (div << SPI_CKR_POSI);
REG_WRITE(SPI_CTRL, param);
ESP_LOGD(TAG, "target frequency: %d, actual frequency: %d", max_hz, source_clk / 2 / div);
ESP_LOGD(TAG, "target frequency: %" PRIu32 ", actual frequency: %d", max_hz, source_clk / 2 / div);
}
void spi_dma_tx_finish_callback(unsigned int param) {
spi_data->tx_in_progress = false;
xSemaphoreGive(spi_data->dma_tx_semaphore);
spi_dma_tx_enable(0);
spi_dma_tx_enable(false);
}
void BekenSPILEDStripLightOutput::setup() {
@@ -161,7 +161,7 @@ void BekenSPILEDStripLightOutput::setup() {
return;
}
spi_data = (spi_data_t *) calloc(1, sizeof(spi_data_t));
spi_data = (SpiData *) calloc(1, sizeof(SpiData)); // NOLINT(cppcoreguidelines-no-malloc)
if (spi_data == nullptr) {
ESP_LOGE(TAG, "Cannot allocate spi_data!");
this->mark_failed();
@@ -177,20 +177,20 @@ void BekenSPILEDStripLightOutput::setup() {
spi_data->first_run = true;
set_spi_ctrl_register(MSTEN, 0);
set_spi_ctrl_register(BIT_WDTH, 0);
set_spi_ctrl_register(MSTEN, false);
set_spi_ctrl_register(BIT_WDTH, false);
spi_set_clock(this->spi_frequency_);
set_spi_ctrl_register(CKPOL, 0);
set_spi_ctrl_register(CKPHA, 0);
set_spi_ctrl_register(MSTEN, 1);
set_spi_ctrl_register(SPIEN, 1);
set_spi_ctrl_register(CKPOL, false);
set_spi_ctrl_register(CKPHA, false);
set_spi_ctrl_register(MSTEN, true);
set_spi_ctrl_register(SPIEN, true);
set_spi_ctrl_register(TXINT_EN, 0);
set_spi_ctrl_register(RXINT_EN, 0);
set_spi_config_register(SPI_TX_FINISH_EN, 1);
set_spi_config_register(SPI_RX_FINISH_EN, 1);
set_spi_ctrl_register(RXOVR_EN, 0);
set_spi_ctrl_register(TXOVR_EN, 0);
set_spi_ctrl_register(TXINT_EN, false);
set_spi_ctrl_register(RXINT_EN, false);
set_spi_config_register(SPI_TX_FINISH_EN, true);
set_spi_config_register(SPI_RX_FINISH_EN, true);
set_spi_ctrl_register(RXOVR_EN, false);
set_spi_ctrl_register(TXOVR_EN, false);
value = REG_READ(SPI_CTRL);
value &= ~CTRL_NSSMD_3;
@@ -199,7 +199,7 @@ void BekenSPILEDStripLightOutput::setup() {
value = GFUNC_MODE_SPI_DMA;
sddev_control(GPIO_DEV_NAME, CMD_GPIO_ENABLE_SECOND, &value);
set_spi_ctrl_register(SPI_S_CS_UP_INT_EN, 0);
set_spi_ctrl_register(SPI_S_CS_UP_INT_EN, false);
GDMA_CFG_ST en_cfg;
GDMACFG_TPYES_ST init_cfg;
@@ -210,7 +210,7 @@ void BekenSPILEDStripLightOutput::setup() {
init_cfg.dstptr_incr = 0;
init_cfg.srcptr_incr = 1;
init_cfg.src_start_addr = this->dma_buf_;
init_cfg.dst_start_addr = (void *) SPI_DAT; // SPI_DMA_REG4_TXFIFO
init_cfg.dst_start_addr = (void *) SPI_DAT; // NOLINT(performance-no-int-to-ptr) SPI_DMA_REG4_TXFIFO
init_cfg.channel = SPI_TX_DMA_CHANNEL;
init_cfg.prio = 0; // 10
init_cfg.u.type4.src_loop_start_addr = this->dma_buf_;
@@ -230,7 +230,7 @@ void BekenSPILEDStripLightOutput::setup() {
en_cfg.param = 0;
sddev_control(GDMA_DEV_NAME, CMD_GDMA_CFG_SRCADDR_LOOP, &en_cfg);
spi_dma_tx_enable(0);
spi_dma_tx_enable(false);
value = REG_READ(SPI_CONFIG);
value &= ~(0xFFF << 8);
@@ -247,7 +247,8 @@ void BekenSPILEDStripLightOutput::set_led_params(uint8_t bit0, uint8_t bit1, uin
void BekenSPILEDStripLightOutput::write_state(light::LightState *state) {
// protect from refreshing too often
uint32_t now = micros();
if (*this->max_refresh_rate_ != 0 && (now - this->last_refresh_) < *this->max_refresh_rate_) {
if (this->max_refresh_rate_.has_value() && *this->max_refresh_rate_ != 0 &&
(now - this->last_refresh_) < *this->max_refresh_rate_) {
// try again next loop iteration, so that this change won't get lost
this->schedule_show();
return;
@@ -293,7 +294,7 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) {
}
spi_data->first_run = false;
spi_dma_tx_enable(1);
spi_dma_tx_enable(true);
this->status_clear_warning();
}
@@ -376,7 +377,7 @@ void BekenSPILEDStripLightOutput::dump_config() {
" RGB Order: %s\n"
" Max refresh rate: %" PRIu32 "\n"
" Number of LEDs: %u",
rgb_order, *this->max_refresh_rate_, this->num_leds_);
rgb_order, this->max_refresh_rate_.value_or(0), this->num_leds_);
}
float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
+3 -1
View File
@@ -448,7 +448,9 @@ _BINARY_SENSOR_SCHEMA = (
cv.Exclusive(
CONF_TRIGGER_ON_INITIAL_STATE, CONF_TRIGGER_ON_INITIAL_STATE
): cv.boolean,
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
cv.Optional(CONF_FILTERS): validate_filters,
cv.Optional(CONF_ON_PRESS): automation.validate_automation({}),
cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}),
@@ -245,7 +245,9 @@ void BluetoothConnection::send_service_for_discovery_() {
service_resp.characteristics.init(total_char_count);
uint16_t char_offset = 0;
esp_gattc_char_elem_t char_result;
while (true) { // characteristics
// Bound by total_char_count: the vector is sized for it, and a malicious peripheral
// can make enumeration return more entries than the count query reported
while (char_offset < total_char_count) { // characteristics
uint16_t char_count = 1;
esp_gatt_status_t char_status =
esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle,
@@ -287,7 +289,7 @@ void BluetoothConnection::send_service_for_discovery_() {
characteristic_resp.descriptors.init(total_desc_count);
uint16_t desc_offset = 0;
esp_gattc_descr_elem_t desc_result;
while (true) { // descriptors
while (desc_offset < total_desc_count) { // descriptors
uint16_t desc_count = 1;
esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr(
this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset);
+3 -1
View File
@@ -50,7 +50,9 @@ _BUTTON_SCHEMA = (
.extend(
{
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTButtonComponent),
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
cv.Optional(CONF_ON_PRESS): automation.validate_automation({}),
}
)
+77
View File
@@ -21,6 +21,7 @@ MULTI_CONF = True
ns = cg.esphome_ns.namespace("cc1101")
CC1101Component = ns.class_("CC1101Component", cg.Component, spi.SPIDevice)
CC1101Listener = ns.class_("CC1101Listener")
# Config keys
CONF_RX_ATTENUATION = "rx_attenuation"
@@ -48,6 +49,15 @@ CONF_FILTER_LENGTH_FSK_MSK = "filter_length_fsk_msk"
CONF_FILTER_LENGTH_ASK_OOK = "filter_length_ask_ook"
CONF_FREEZE = "freeze"
CONF_HYST_LEVEL = "hyst_level"
CONF_FOC_BS_CS_GATE = "foc_bs_cs_gate"
CONF_FOC_LIMIT = "foc_limit"
CONF_FOC_PRE_K = "foc_pre_k"
CONF_FOC_POST_K = "foc_post_k"
CONF_BS_LIMIT = "bs_limit"
CONF_BS_PRE_KI = "bs_pre_ki"
CONF_BS_PRE_KP = "bs_pre_kp"
CONF_BS_POST_KI = "bs_post_ki"
CONF_BS_POST_KP = "bs_post_kp"
# Packet mode config keys
CONF_PACKET_MODE = "packet_mode"
@@ -161,6 +171,64 @@ HYST_LEVEL = {
"High": HystLevel.HYST_LEVEL_HIGH,
}
FocLimit = ns.enum("FocLimit", True)
FOC_LIMIT = {
"Disabled": FocLimit.FOC_LIMIT_DISABLED,
"BW/8": FocLimit.FOC_LIMIT_BW_8,
"BW/4": FocLimit.FOC_LIMIT_BW_4,
"BW/2": FocLimit.FOC_LIMIT_BW_2,
}
FocPreK = ns.enum("FocPreK", True)
FOC_PRE_K = {
"K": FocPreK.FOC_PRE_K_K,
"2K": FocPreK.FOC_PRE_K_2K,
"3K": FocPreK.FOC_PRE_K_3K,
"4K": FocPreK.FOC_PRE_K_4K,
}
FocPostK = ns.enum("FocPostK", True)
FOC_POST_K = {
"Same": FocPostK.FOC_POST_K_SAME,
"K/2": FocPostK.FOC_POST_K_K_2,
}
BsLimit = ns.enum("BsLimit", True)
BS_LIMIT = {
"Disabled": BsLimit.BS_LIMIT_DISABLED,
"3.125%": BsLimit.BS_LIMIT_3P125_PERCENT,
"6.25%": BsLimit.BS_LIMIT_6P25_PERCENT,
"12.5%": BsLimit.BS_LIMIT_12P5_PERCENT,
}
BsPreKi = ns.enum("BsPreKi", True)
BS_PRE_KI = {
"KI": BsPreKi.BS_PRE_KI_KI,
"2KI": BsPreKi.BS_PRE_KI_2KI,
"3KI": BsPreKi.BS_PRE_KI_3KI,
"4KI": BsPreKi.BS_PRE_KI_4KI,
}
BsPreKp = ns.enum("BsPreKp", True)
BS_PRE_KP = {
"KP": BsPreKp.BS_PRE_KP_KP,
"2KP": BsPreKp.BS_PRE_KP_2KP,
"3KP": BsPreKp.BS_PRE_KP_3KP,
"4KP": BsPreKp.BS_PRE_KP_4KP,
}
BsPostKi = ns.enum("BsPostKi", True)
BS_POST_KI = {
"Same": BsPostKi.BS_POST_KI_SAME,
"KI/2": BsPostKi.BS_POST_KI_KI_2,
}
BsPostKp = ns.enum("BsPostKp", True)
BS_POST_KP = {
"Same": BsPostKp.BS_POST_KP_SAME,
"KP": BsPostKp.BS_POST_KP_KP,
}
# Optional settings to generate setter calls for
CONFIG_MAP = {
cv.Optional(CONF_OUTPUT_POWER, default=10): cv.float_range(min=-30.0, max=11.0),
@@ -214,6 +282,15 @@ CONFIG_MAP = {
cv.Optional(CONF_FREEZE): cv.enum(FREEZE, upper=False),
cv.Optional(CONF_WAIT_TIME, default="32"): cv.enum(WAIT_TIME, upper=False),
cv.Optional(CONF_HYST_LEVEL): cv.enum(HYST_LEVEL, upper=False),
cv.Optional(CONF_FOC_BS_CS_GATE): cv.boolean,
cv.Optional(CONF_FOC_LIMIT): cv.enum(FOC_LIMIT, upper=False),
cv.Optional(CONF_FOC_PRE_K): cv.enum(FOC_PRE_K, upper=False),
cv.Optional(CONF_FOC_POST_K): cv.enum(FOC_POST_K, upper=False),
cv.Optional(CONF_BS_LIMIT): cv.enum(BS_LIMIT, upper=False),
cv.Optional(CONF_BS_PRE_KI): cv.enum(BS_PRE_KI, upper=False),
cv.Optional(CONF_BS_PRE_KP): cv.enum(BS_PRE_KP, upper=False),
cv.Optional(CONF_BS_POST_KI): cv.enum(BS_POST_KI, upper=False),
cv.Optional(CONF_BS_POST_KP): cv.enum(BS_POST_KP, upper=False),
cv.Optional(CONF_PACKET_MODE, default=False): cv.boolean,
cv.Optional(CONF_PACKET_LENGTH): cv.uint8_t,
cv.Optional(CONF_CRC_ENABLE, default=False): cv.boolean,
+63
View File
@@ -672,6 +672,69 @@ void CC1101Component::set_hyst_level(HystLevel value) {
}
}
void CC1101Component::set_foc_bs_cs_gate(bool value) {
this->state_.FOC_BS_CS_GATE = value ? 1 : 0;
if (this->initialized_) {
this->write_(Register::FOCCFG);
}
}
void CC1101Component::set_foc_limit(FocLimit value) {
this->state_.FOC_LIMIT = static_cast<uint8_t>(value);
if (this->initialized_) {
this->write_(Register::FOCCFG);
}
}
void CC1101Component::set_foc_pre_k(FocPreK value) {
this->state_.FOC_PRE_K = static_cast<uint8_t>(value);
if (this->initialized_) {
this->write_(Register::FOCCFG);
}
}
void CC1101Component::set_foc_post_k(FocPostK value) {
this->state_.FOC_POST_K = static_cast<uint8_t>(value);
if (this->initialized_) {
this->write_(Register::FOCCFG);
}
}
void CC1101Component::set_bs_limit(BsLimit value) {
this->state_.BS_LIMIT = static_cast<uint8_t>(value);
if (this->initialized_) {
this->write_(Register::BSCFG);
}
}
void CC1101Component::set_bs_pre_ki(BsPreKi value) {
this->state_.BS_PRE_KI = static_cast<uint8_t>(value);
if (this->initialized_) {
this->write_(Register::BSCFG);
}
}
void CC1101Component::set_bs_pre_kp(BsPreKp value) {
this->state_.BS_PRE_KP = static_cast<uint8_t>(value);
if (this->initialized_) {
this->write_(Register::BSCFG);
}
}
void CC1101Component::set_bs_post_ki(BsPostKi value) {
this->state_.BS_POST_KI = static_cast<uint8_t>(value);
if (this->initialized_) {
this->write_(Register::BSCFG);
}
}
void CC1101Component::set_bs_post_kp(BsPostKp value) {
this->state_.BS_POST_KP = static_cast<uint8_t>(value);
if (this->initialized_) {
this->write_(Register::BSCFG);
}
}
void CC1101Component::set_packet_mode(bool value) {
this->state_.PKT_FORMAT =
static_cast<uint8_t>(value ? PacketFormat::PACKET_FORMAT_FIFO : PacketFormat::PACKET_FORMAT_ASYNC_SERIAL);
+11
View File
@@ -71,6 +71,17 @@ class CC1101Component final : public Component,
void set_wait_time(WaitTime value);
void set_hyst_level(HystLevel value);
// Frequency offset compensation and bit synchronization settings
void set_foc_bs_cs_gate(bool value);
void set_foc_limit(FocLimit value);
void set_foc_pre_k(FocPreK value);
void set_foc_post_k(FocPostK value);
void set_bs_limit(BsLimit value);
void set_bs_pre_ki(BsPreKi value);
void set_bs_pre_kp(BsPreKp value);
void set_bs_post_ki(BsPostKi value);
void set_bs_post_kp(BsPostKp value);
// Packet mode settings
void set_packet_mode(bool value);
void set_packet_length(uint8_t value);
+50
View File
@@ -231,6 +231,56 @@ enum class HystLevel : uint8_t {
HYST_LEVEL_HIGH,
};
enum class FocLimit : uint8_t {
FOC_LIMIT_DISABLED,
FOC_LIMIT_BW_8,
FOC_LIMIT_BW_4,
FOC_LIMIT_BW_2,
};
enum class FocPreK : uint8_t {
FOC_PRE_K_K,
FOC_PRE_K_2K,
FOC_PRE_K_3K,
FOC_PRE_K_4K,
};
enum class FocPostK : uint8_t {
FOC_POST_K_SAME,
FOC_POST_K_K_2,
};
enum class BsLimit : uint8_t {
BS_LIMIT_DISABLED,
BS_LIMIT_3P125_PERCENT,
BS_LIMIT_6P25_PERCENT,
BS_LIMIT_12P5_PERCENT,
};
enum class BsPreKi : uint8_t {
BS_PRE_KI_KI,
BS_PRE_KI_2KI,
BS_PRE_KI_3KI,
BS_PRE_KI_4KI,
};
enum class BsPreKp : uint8_t {
BS_PRE_KP_KP,
BS_PRE_KP_2KP,
BS_PRE_KP_3KP,
BS_PRE_KP_4KP,
};
enum class BsPostKi : uint8_t {
BS_POST_KI_SAME,
BS_POST_KI_KI_2,
};
enum class BsPostKp : uint8_t {
BS_POST_KP_SAME,
BS_POST_KP_KP,
};
enum class PacketFormat : uint8_t {
PACKET_FORMAT_FIFO,
PACKET_FORMAT_SYNC_SERIAL,
+3 -1
View File
@@ -131,7 +131,9 @@ _COVER_SCHEMA = (
cv.Optional(CONF_MQTT_JSON_STATE_PAYLOAD): cv.All(
cv.requires_component("mqtt"), cv.boolean
),
cv.Optional(CONF_DEVICE_CLASS): cv.one_of(*DEVICE_CLASSES, lower=True),
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): cv.one_of(*DEVICE_CLASSES, lower=True),
cv.Optional(CONF_POSITION_COMMAND_TOPIC): cv.All(
cv.requires_component("mqtt"), cv.subscribe_topic
),
+2 -2
View File
@@ -28,7 +28,7 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
ESP_LOGD(TAG,
"LibreTiny debug info:\n"
" Version: %s\n"
" Chip: %s (%04x) @ %u MHz\n"
" Chip: %s (%04x) @ %" PRIu32 " MHz\n"
" Chip ID: 0x%06" PRIX32 "\n"
" Board: %s\n"
" Flash: %" PRIu32 " KiB\n"
@@ -38,7 +38,7 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
lt_get_board_code(), flash_kib, ram_kib, reset_reason);
pos = buf_append_str(buf, size, pos, "|Version: ");
pos = buf_append_str(buf, size, pos, LT_BANNER_STR + 10);
pos = buf_append_str(buf, size, pos, &LT_BANNER_STR[10]);
pos = buf_append_str(buf, size, pos, "|Reset Reason: ");
pos = buf_append_str(buf, size, pos, reset_reason);
pos = buf_append_str(buf, size, pos, "|Chip Name: ");
+1 -1
View File
@@ -74,7 +74,7 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
constexpr size_t size = DEVICE_INFO_BUFFER_SIZE;
char *buf = buffer.data();
uint32_t cpu_freq = ::rp2040.f_cpu();
uint32_t cpu_freq = RP2040::f_cpu();
ESP_LOGD(TAG, "CPU Frequency: %" PRIu32, cpu_freq);
pos = buf_append_printf(buf, size, pos, "|CPU Frequency: %" PRIu32, cpu_freq);
+50 -8
View File
@@ -30,6 +30,7 @@ from esphome.const import (
CONF_SECOND,
CONF_SLEEP_DURATION,
CONF_TIME_ID,
CONF_TRIGGER_ID,
CONF_WAKEUP_PIN,
PLATFORM_BK72XX,
PLATFORM_ESP32,
@@ -234,6 +235,15 @@ EXT1_WAKEUP_MODES = {
}
WakeupCauseToRunDuration = deep_sleep_ns.struct("WakeupCauseToRunDuration")
WakeupCause = deep_sleep_ns.enum("WakeupCause")
WakeTrigger = deep_sleep_ns.class_(
"WakeTrigger", automation.Trigger.template(WakeupCause), cg.Component
)
Ext1WakeTrigger = deep_sleep_ns.class_(
"Ext1WakeTrigger", automation.Trigger.template(), cg.Component
)
CONF_ON_WAKE = "on_wake"
CONF_WAKEUP_PIN_MODE = "wakeup_pin_mode"
CONF_ESP32_EXT1_WAKEUP = "esp32_ext1_wakeup"
CONF_TOUCH_WAKEUP = "touch_wakeup"
@@ -256,6 +266,22 @@ WAKEUP_PIN_SCHEMA = cv.Schema(
}
)
EXT1_WAKEUP_PIN_SCHEMA = cv.Schema(
{
cv.Required(CONF_PIN): cv.All(
pins.internal_gpio_input_pin_schema, validate_pin_number_esp32
),
cv.Optional(CONF_ON_WAKE): automation.validate_automation(
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(Ext1WakeTrigger)}
),
}
)
# Entries that are not in the {pin: ..., on_wake: ...} form are treated as a
# bare pin config (the original syntax, e.g. a plain "GPIO5" or {number: 5}).
validate_ext1_wakeup_pin = cv.maybe_simple_value(EXT1_WAKEUP_PIN_SCHEMA, key=CONF_PIN)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
@@ -282,8 +308,7 @@ CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.Required(CONF_PINS): cv.ensure_list(
pins.internal_gpio_input_pin_schema,
validate_pin_number_esp32,
validate_ext1_wakeup_pin,
),
cv.Required(CONF_MODE): cv.All(
cv.enum(EXT1_WAKEUP_MODES, upper=True),
@@ -292,6 +317,12 @@ CONFIG_SCHEMA = cv.All(
}
),
),
cv.Optional(CONF_ON_WAKE): cv.All(
cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX]),
automation.validate_automation(
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(WakeTrigger)}
),
),
cv.Optional(CONF_TOUCH_WAKEUP): cv.All(
cv.only_on_esp32,
esp32.only_on_variant(
@@ -323,7 +354,7 @@ async def to_code(config):
if CONF_WAKEUP_PIN in config:
pins_as_list = config.get(CONF_WAKEUP_PIN, [])
if CORE.is_bk72xx:
cg.add(var.init_wakeup_pins_(len(pins_as_list)))
cg.add(var.init_wakeup_pins(len(pins_as_list)))
for item in pins_as_list:
cg.add(
var.add_wakeup_pin(
@@ -362,16 +393,27 @@ async def to_code(config):
)
cg.add(var.set_run_duration(wakeup_cause_to_run_duration))
if CONF_ESP32_EXT1_WAKEUP in config:
conf = config[CONF_ESP32_EXT1_WAKEUP]
if (ext1_conf := config.get(CONF_ESP32_EXT1_WAKEUP)) is not None:
mask = 0
for pin in conf[CONF_PINS]:
mask |= 1 << pin[CONF_NUMBER]
for pin_conf in ext1_conf[CONF_PINS]:
number = pin_conf[CONF_PIN][CONF_NUMBER]
mask |= 1 << number
for wake_conf in pin_conf.get(CONF_ON_WAKE, []):
trigger = cg.new_Pvariable(wake_conf[CONF_TRIGGER_ID], number)
await cg.register_component(trigger, wake_conf)
await automation.build_automation(trigger, [], wake_conf)
cg.add_define("USE_DEEP_SLEEP_ON_WAKE")
struct = cg.StructInitializer(
Ext1Wakeup, ("mask", mask), ("wakeup_mode", conf[CONF_MODE])
Ext1Wakeup, ("mask", mask), ("wakeup_mode", ext1_conf[CONF_MODE])
)
cg.add(var.set_ext1_wakeup(struct))
for wake_conf in config.get(CONF_ON_WAKE, []):
trigger = cg.new_Pvariable(wake_conf[CONF_TRIGGER_ID])
await cg.register_component(trigger, wake_conf)
await automation.build_automation(trigger, [(WakeupCause, "cause")], wake_conf)
cg.add_define("USE_DEEP_SLEEP_ON_WAKE")
if CONF_TOUCH_WAKEUP in config:
cg.add(var.set_touch_wakeup(config[CONF_TOUCH_WAKEUP]))
if CORE.using_zephyr and "zigbee" not in CORE.loaded_integrations:
@@ -7,6 +7,21 @@ namespace esphome::deep_sleep {
static const char *const TAG = "deep_sleep.bk72xx";
#ifdef USE_DEEP_SLEEP_ON_WAKE
WakeupCause get_wakeup_cause() {
switch (lt_get_reboot_reason()) {
case REBOOT_REASON_SLEEP_GPIO:
return WAKEUP_CAUSE_GPIO;
case REBOOT_REASON_SLEEP_RTC:
return WAKEUP_CAUSE_TIMER;
case REBOOT_REASON_SLEEP_USB:
return WAKEUP_CAUSE_UNKNOWN;
default:
return WAKEUP_CAUSE_NONE;
}
}
#endif // USE_DEEP_SLEEP_ON_WAKE
optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; }
void DeepSleepComponent::dump_config_platform_() {
@@ -15,15 +30,15 @@ void DeepSleepComponent::dump_config_platform_() {
}
}
bool DeepSleepComponent::pin_prevents_sleep_(WakeUpPinItem &pinItem) const {
return (pinItem.wakeup_pin_mode == WAKEUP_PIN_MODE_KEEP_AWAKE && pinItem.wakeup_pin != nullptr &&
!this->sleep_duration_.has_value() && (pinItem.wakeup_level == get_real_pin_state_(*pinItem.wakeup_pin)));
bool DeepSleepComponent::pin_prevents_sleep_(WakeUpPinItem &pin_item) const {
return (pin_item.wakeup_pin_mode == WAKEUP_PIN_MODE_KEEP_AWAKE && pin_item.wakeup_pin != nullptr &&
!this->sleep_duration_.has_value() && (pin_item.wakeup_level == get_real_pin_state_(*pin_item.wakeup_pin)));
}
bool DeepSleepComponent::prepare_to_sleep_() {
if (wakeup_pins_.size() > 0) {
if (!this->wakeup_pins_.empty()) {
for (WakeUpPinItem &item : this->wakeup_pins_) {
if (pin_prevents_sleep_(item)) {
if (this->pin_prevents_sleep_(item)) {
// Defer deep sleep until inactive
if (!this->next_enter_deep_sleep_) {
this->status_set_warning();
@@ -44,7 +59,7 @@ void DeepSleepComponent::deep_sleep_() {
item.wakeup_level = !item.wakeup_level;
}
}
ESP_LOGI(TAG, "Wake-up on P%u %s (%d)", item.wakeup_pin->get_pin(), item.wakeup_level ? "HIGH" : "LOW",
ESP_LOGI(TAG, "Wake-up on P%u %s (%" PRId32 ")", item.wakeup_pin->get_pin(), item.wakeup_level ? "HIGH" : "LOW",
static_cast<int32_t>(item.wakeup_pin_mode));
}
@@ -60,6 +60,65 @@ struct WakeupCauseToRunDuration {
#endif // USE_ESP32
#ifdef USE_DEEP_SLEEP_ON_WAKE
/// Why the device woke from deep sleep. Passed to on_wake automations.
enum WakeupCause : uint8_t {
/// The device did not wake from deep sleep (for example a cold boot, reset or OTA restart).
WAKEUP_CAUSE_NONE = 0,
/// The device woke from deep sleep, but the source could not be identified.
WAKEUP_CAUSE_UNKNOWN,
/// The device was woken by the sleep timer.
WAKEUP_CAUSE_TIMER,
/// The device was woken by a GPIO pin (wakeup_pin or esp32_ext1_wakeup).
WAKEUP_CAUSE_GPIO,
/// The device was woken by a touch pad.
WAKEUP_CAUSE_TOUCH,
};
/// Return why the device woke from deep sleep. Implemented per platform.
WakeupCause get_wakeup_cause();
/** Setup priority of on_wake triggers.
*
* Between restoring global variables (setup_priority::HARDWARE, 800) and on_boot automations at
* their default priority (600), so on_wake automations can update state (e.g. globals) that
* on_boot automations then use.
*/
inline constexpr float ON_WAKE_TRIGGER_SETUP_PRIORITY = 700.0f;
/// Fires once on boot when the device woke from deep sleep, with the wakeup cause.
class WakeTrigger : public Trigger<WakeupCause>, public Component {
public:
void setup() override {
const WakeupCause cause = get_wakeup_cause();
if (cause != WAKEUP_CAUSE_NONE) {
this->trigger(cause);
}
}
float get_setup_priority() const override { return ON_WAKE_TRIGGER_SETUP_PRIORITY; }
};
#if defined(USE_ESP32) && !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3)
/// Fires once on boot when the device was woken from deep sleep by the given ext1 pin.
class Ext1WakeTrigger : public Trigger<>, public Component {
public:
explicit Ext1WakeTrigger(uint8_t pin) : pin_(pin) {}
void setup() override {
if (esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_EXT1 &&
(esp_sleep_get_ext1_wakeup_status() & (1ULL << this->pin_))) {
this->trigger();
}
}
float get_setup_priority() const override { return ON_WAKE_TRIGGER_SETUP_PRIORITY; }
protected:
uint8_t pin_;
};
#endif
#endif // USE_DEEP_SLEEP_ON_WAKE
template<typename... Ts> class EnterDeepSleepAction;
template<typename... Ts> class PreventDeepSleepAction;
@@ -84,7 +143,7 @@ class DeepSleepComponent final : public Component {
#endif // USE_ESP32
#if defined(USE_BK72XX)
void init_wakeup_pins_(size_t capacity) { this->wakeup_pins_.init(capacity); }
void init_wakeup_pins(size_t capacity) { this->wakeup_pins_.init(capacity); }
void add_wakeup_pin(InternalGPIOPin *wakeup_pin, WakeupPinMode wakeup_pin_mode) {
this->wakeup_pins_.emplace_back(WakeUpPinItem{wakeup_pin, wakeup_pin_mode, !wakeup_pin->is_inverted()});
}
@@ -132,7 +191,7 @@ class DeepSleepComponent final : public Component {
bool should_teardown_();
#ifdef USE_BK72XX
bool pin_prevents_sleep_(WakeUpPinItem &pinItem) const;
bool pin_prevents_sleep_(WakeUpPinItem &pin_item) const;
bool get_real_pin_state_(InternalGPIOPin &pin) const { return (pin.digital_read() ^ pin.is_inverted()); }
#endif // USE_BK72XX
@@ -30,6 +30,25 @@ namespace esphome::deep_sleep {
static const char *const TAG = "deep_sleep";
#ifdef USE_DEEP_SLEEP_ON_WAKE
WakeupCause get_wakeup_cause() {
switch (esp_sleep_get_wakeup_cause()) {
case ESP_SLEEP_WAKEUP_EXT0:
case ESP_SLEEP_WAKEUP_EXT1:
case ESP_SLEEP_WAKEUP_GPIO:
return WAKEUP_CAUSE_GPIO;
case ESP_SLEEP_WAKEUP_TIMER:
return WAKEUP_CAUSE_TIMER;
case ESP_SLEEP_WAKEUP_TOUCHPAD:
return WAKEUP_CAUSE_TOUCH;
case ESP_SLEEP_WAKEUP_UNDEFINED:
return WAKEUP_CAUSE_NONE;
default:
return WAKEUP_CAUSE_UNKNOWN;
}
}
#endif // USE_DEEP_SLEEP_ON_WAKE
optional<uint32_t> DeepSleepComponent::get_run_duration_() const {
if (this->wakeup_cause_to_run_duration_.has_value()) {
esp_sleep_wakeup_cause_t wakeup_cause = esp_sleep_get_wakeup_cause();
@@ -3,10 +3,27 @@
#include <Esp.h>
#ifdef USE_DEEP_SLEEP_ON_WAKE
extern "C" {
#include <user_interface.h>
}
#endif
namespace esphome::deep_sleep {
static const char *const TAG = "deep_sleep";
#ifdef USE_DEEP_SLEEP_ON_WAKE
WakeupCause get_wakeup_cause() {
// The ESP8266 can only wake from deep sleep through the RTC timer (via GPIO16 -> RST).
// NOLINTNEXTLINE(readability-static-accessed-through-instance)
if (ESP.getResetInfoPtr()->reason == REASON_DEEP_SLEEP_AWAKE) {
return WAKEUP_CAUSE_TIMER;
}
return WAKEUP_CAUSE_NONE;
}
#endif // USE_DEEP_SLEEP_ON_WAKE
optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; }
void DeepSleepComponent::dump_config_platform_() {}
@@ -1,13 +1,36 @@
#include "deep_sleep_component.h"
#ifdef USE_ZEPHYR
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "esphome/core/wake.h"
#include <zephyr/sys/poweroff.h>
#include <algorithm>
namespace esphome::deep_sleep {
static const char *const TAG = "deep_sleep";
// The Zephyr watchdog has a short window (2s, or 10s with Zigbee) and
// WDT_OPT_PAUSE_IN_SLEEP only pauses it during true hardware sleep — not while a
// radio thread (e.g. the Zigbee stack) keeps the CPU busy in k_sem_take(). Feed
// it at least this often while waiting so it does not reset the device.
static const uint32_t WDT_FEED_INTERVAL_MS = 1000;
static bool wakeable_delay_feed_wdt(uint32_t ms) {
while (ms > 0) {
const uint32_t step = std::min(ms, WDT_FEED_INTERVAL_MS);
esphome::internal::wakeable_delay(step);
esphome::arch_feed_wdt();
if (esphome::wake_request_take()) {
return true;
}
if (ms != UINT32_MAX) {
ms -= step;
}
}
return false;
}
optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; }
void DeepSleepComponent::dump_config_platform_() {}
@@ -15,8 +38,9 @@ void DeepSleepComponent::dump_config_platform_() {}
bool DeepSleepComponent::prepare_to_sleep_() { return true; }
void DeepSleepComponent::deep_sleep_() {
bool woke = false;
if (this->sleep_duration_.has_value()) {
esphome::internal::wakeable_delay(static_cast<uint32_t>(*this->sleep_duration_ / 1000));
woke = wakeable_delay_feed_wdt(static_cast<uint32_t>(*this->sleep_duration_ / 1000));
} else {
#ifndef USE_ZIGBEE
// the device can be woken up through one of the following signals:
@@ -29,10 +53,9 @@ void DeepSleepComponent::deep_sleep_() {
// The system is reset when it wakes up from System OFF mode.
sys_poweroff();
#else
esphome::internal::wakeable_delay(UINT32_MAX);
woke = wakeable_delay_feed_wdt(UINT32_MAX);
#endif
}
const bool woke = esphome::wake_request_take();
if (woke) {
ESP_LOGD(TAG, "Woken up by another thread");
} else {
+112
View File
@@ -0,0 +1,112 @@
from esphome import pins
import esphome.codegen as cg
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_SLEEP_PIN, CONF_TYPE
CODEOWNERS = ["@tomwellnitz"]
MULTI_CONF = True
DEPENDENCIES = ["i2c"]
CONF_DS248X_ID = "ds248x_id"
CONF_BUS_SLEEP = "bus_sleep"
CONF_HUB_SLEEP = "hub_sleep"
CONF_ACTIVE_PULLUP = "active_pullup"
CONF_RESET_LOW_TIME = "reset_low_time"
CONF_MASTER_SAMPLE_TIME = "master_sample_time"
CONF_WRITE_0_LOW_TIME = "write_0_low_time"
CONF_RECOVERY_TIME = "recovery_time"
CONF_ACTIVE_PULLUP_RESISTANCE = "active_pullup_resistance"
TYPE_DS2482_100 = "ds2482-100"
TYPE_DS2482_101 = "ds2482-101"
TYPE_DS2482_800 = "ds2482-800"
TYPE_DS2484 = "ds2484"
CHANNEL_COUNTS = {
TYPE_DS2482_100: 1,
TYPE_DS2482_101: 1,
TYPE_DS2482_800: 8,
TYPE_DS2484: 1,
}
ds248x_ns = cg.esphome_ns.namespace("ds248x")
DS248xComponent = ds248x_ns.class_("DS248xComponent", cg.Component, i2c.I2CDevice)
def _component_schema(*extras):
schema = cv.Schema(
{
cv.GenerateID(): cv.declare_id(DS248xComponent),
cv.Optional(CONF_ACTIVE_PULLUP, default=False): cv.boolean,
}
)
for extra in extras:
schema = schema.extend(extra)
return schema.extend(cv.COMPONENT_SCHEMA).extend(i2c.i2c_device_schema(0x18))
SLEEP_SCHEMA = {
cv.Optional(CONF_SLEEP_PIN): pins.internal_gpio_output_pin_schema,
cv.Optional(CONF_BUS_SLEEP, default=False): cv.boolean,
cv.Optional(CONF_HUB_SLEEP, default=False): cv.boolean,
}
DS2484_SCHEMA = {
cv.Optional(CONF_RESET_LOW_TIME): cv.int_range(min=0, max=15),
cv.Optional(CONF_MASTER_SAMPLE_TIME): cv.int_range(min=0, max=15),
cv.Optional(CONF_WRITE_0_LOW_TIME): cv.int_range(min=0, max=15),
cv.Optional(CONF_RECOVERY_TIME): cv.int_range(min=0, max=15),
cv.Optional(CONF_ACTIVE_PULLUP_RESISTANCE): cv.enum(
{
# DS2484 Table 7: value codes 0-5 map to 500 ohm, 6-15 map to 1000 ohm.
"500ohm": 0,
"1000ohm": 6,
}
),
}
CONFIG_SCHEMA = cv.typed_schema(
{
TYPE_DS2482_100: _component_schema(),
TYPE_DS2482_101: _component_schema(SLEEP_SCHEMA),
TYPE_DS2482_800: _component_schema(),
TYPE_DS2484: _component_schema(SLEEP_SCHEMA, DS2484_SCHEMA),
},
key=CONF_TYPE,
lower=True,
)
def get_channel_count(config):
return CHANNEL_COUNTS[config[CONF_TYPE]]
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
cg.add(var.set_active_pullup(config[CONF_ACTIVE_PULLUP]))
cg.add(var.set_channel_count(get_channel_count(config)))
if CONF_BUS_SLEEP in config:
cg.add(var.set_bus_sleep(config[CONF_BUS_SLEEP]))
if CONF_HUB_SLEEP in config:
cg.add(var.set_hub_sleep(config[CONF_HUB_SLEEP]))
if CONF_RESET_LOW_TIME in config:
cg.add(var.set_val_trstl(config[CONF_RESET_LOW_TIME]))
if CONF_MASTER_SAMPLE_TIME in config:
cg.add(var.set_val_tmsp(config[CONF_MASTER_SAMPLE_TIME]))
if CONF_WRITE_0_LOW_TIME in config:
cg.add(var.set_val_tw0l(config[CONF_WRITE_0_LOW_TIME]))
if CONF_RECOVERY_TIME in config:
cg.add(var.set_val_trec0(config[CONF_RECOVERY_TIME]))
if CONF_ACTIVE_PULLUP_RESISTANCE in config:
cg.add(var.set_val_rwpu(config[CONF_ACTIVE_PULLUP_RESISTANCE]))
if CONF_SLEEP_PIN in config:
pin = await cg.gpio_pin_expression(config[CONF_SLEEP_PIN])
cg.add(var.set_sleep_pin(pin))
+320
View File
@@ -0,0 +1,320 @@
#include "ds248x.h"
#include "esphome/core/log.h"
#include "esphome/core/helpers.h"
namespace esphome::ds248x {
static const char *const TAG = "ds248x";
void DS248xComponent::setup() {
ESP_LOGCONFIG(TAG, "Setting up DS248x...");
// Wake up device if sleep pin is configured
if (this->sleep_pin_) {
this->sleep_pin_->setup();
this->sleep_pin_->pin_mode(esphome::gpio::FLAG_OUTPUT);
this->sleep_pin_->digital_write(true); // Wake up
delay(1); // DS2482-101 Datasheet: tOSCWUP = 100μs (using 10x margin)
}
// Probe device
ESP_LOGD(TAG, "Probing DS248x...");
uint8_t status = 0;
if (this->read(&status, 1) == i2c::ERROR_OK) {
ESP_LOGD(TAG, "Device responded! Status: 0x%02x", status);
} else {
ESP_LOGW(TAG, "Device did not respond. Trying reset anyway...");
}
if (!this->device_reset_()) {
ESP_LOGW(TAG, "DS248x reset failed during setup!");
}
// Configure device
if (!this->device_configure_()) {
ESP_LOGE(TAG, "DS248x configuration failed!");
this->mark_failed();
return;
}
// Reset to Channel 0
this->select_channel(0);
ESP_LOGI(TAG, "DS248x initialized successfully.");
}
void DS248xComponent::on_shutdown() {
if (this->sleep_pin_ && (this->hub_sleep_ || this->bus_sleep_)) {
this->sleep_pin_->digital_write(false); // Sleep
}
}
void DS248xComponent::dump_config() {
ESP_LOGCONFIG(TAG, "DS248x:");
LOG_I2C_DEVICE(this);
ESP_LOGCONFIG(TAG, " Channel Count: %d", this->channel_count_);
ESP_LOGCONFIG(TAG, " Active Pullup: %s", YESNO(this->active_pullup_));
if (this->ds2484_mode_) {
ESP_LOGCONFIG(TAG, " DS2484 Mode: enabled");
}
}
// --- Internal Helpers ---
// Datasheet command durations are sub-2ms; allow a little margin before forcing recovery.
static constexpr uint32_t BUSY_TIMEOUT_MS = 5;
bool DS248xComponent::set_read_pointer_(uint8_t ptr) { return this->write_byte(DS248X_COMMAND_SETREADPTR, ptr); }
bool DS248xComponent::wait_busy_() {
uint32_t start = millis();
do {
uint8_t status;
if (this->read(&status, 1) == i2c::ERROR_OK && !(status & DS248X_STATUS_BUSY))
return true;
delayMicroseconds(100);
} while (millis() - start < BUSY_TIMEOUT_MS);
ESP_LOGW(TAG, "DS248x busy timeout");
bool recovered = this->device_reset_() && this->device_configure_();
this->current_channel_ = -1;
if (!recovered) {
ESP_LOGE(TAG, "DS248x recovery failed after busy timeout");
this->mark_failed();
}
return false;
}
bool DS248xComponent::device_reset_() {
ESP_LOGD(TAG, "Resetting device...");
uint8_t cmd = DS248X_COMMAND_RESET;
if (this->write(&cmd, 1) != i2c::ERROR_OK)
return false;
uint8_t status;
if (this->read(&status, 1) != i2c::ERROR_OK)
return false;
if (!(status & DS248X_STATUS_RST)) {
ESP_LOGW(TAG, "Device reset failed (RST bit not set)");
return false;
}
this->current_channel_ = -1;
return true;
}
bool DS248xComponent::device_configure_() {
ESP_LOGD(TAG, "Configuring device...");
if (!this->write_config_()) {
ESP_LOGW(TAG, "Config write/verify failed");
return false;
}
ESP_LOGD(TAG, "Configured successfully");
// DS2484 Configuration
if (this->ds2484_mode_) {
if (this->ds2484_trstl_ != DS2484_PARAM_UNSET &&
!this->configure_ds2484_port_(DS2484_PORT_PARAM_TRSTL, this->ds2484_trstl_))
return false;
if (this->ds2484_tmsp_ != DS2484_PARAM_UNSET &&
!this->configure_ds2484_port_(DS2484_PORT_PARAM_TMSP, this->ds2484_tmsp_))
return false;
if (this->ds2484_tw0l_ != DS2484_PARAM_UNSET &&
!this->configure_ds2484_port_(DS2484_PORT_PARAM_TW0L, this->ds2484_tw0l_))
return false;
if (this->ds2484_trec0_ != DS2484_PARAM_UNSET &&
!this->configure_ds2484_port_(DS2484_PORT_PARAM_TREC0, this->ds2484_trec0_))
return false;
if (this->ds2484_rwpu_ != DS2484_PARAM_UNSET &&
!this->configure_ds2484_port_(DS2484_PORT_PARAM_RWPU, this->ds2484_rwpu_))
return false;
}
return true;
}
bool DS248xComponent::configure_ds2484_port_(uint8_t param, uint8_t val) {
uint8_t cmd = DS2484_COMMAND_ADJUSTPORT;
// Control Byte format (DS2484 Table 6): P[2:0] in bits 7:5, OD in bit 4, VAL[3:0] in bits 3:0
uint8_t data = ((param & 0x07) << 5) | (val & 0x0F);
// The DS2484 always acknowledges the Adjust 1-Wire Port control byte (datasheet "Adjust
// 1-Wire Port"), so a successful write confirms the update. We deliberately do not read
// back to verify: a single read of the Port Configuration register always returns the
// fixed 8-byte report starting at Byte 1 (tRSTL standard speed), not the parameter that
// was just written, so a per-parameter readback comparison would spuriously fail for
// tMSP/tW0L/tREC0/RWPU.
if (!this->write_byte(cmd, data)) {
ESP_LOGW(TAG, "DS2484 port config failed (param %d)", param);
return false;
}
return this->set_read_pointer_(DS248X_POINTER_STATUS);
}
bool DS248xComponent::write_config_() {
uint8_t config = 0;
if (this->active_pullup_)
config |= DS248X_CONFIG_ACTIVE_PULLUP;
// The DS248x only accepts the config byte if the upper nibble is the one's-complement of the lower nibble.
uint8_t config_byte = (config & 0x0F) | ((~config & 0x0F) << 4);
if (!this->write_byte(DS248X_COMMAND_WRITECONFIG, config_byte)) {
ESP_LOGW(TAG, "Failed to write config byte");
return false;
}
if (!this->set_read_pointer_(DS248X_POINTER_CONFIG)) {
return false;
}
uint8_t read_config;
if (this->read(&read_config, 1) != i2c::ERROR_OK) {
ESP_LOGW(TAG, "Failed to read back config byte");
return false;
}
if ((read_config & 0x0F) != (config_byte & 0x0F)) {
ESP_LOGW(TAG, "Config mismatch! Wrote 0x%02x, Read 0x%02x", config_byte, read_config);
return false;
}
return this->set_read_pointer_(DS248X_POINTER_STATUS);
}
// --- Channel Selection ---
// Channel select codes: write code -> expected read code
static constexpr uint8_t CHANNEL_WRITE_CODES[8] = {0xF0, 0xE1, 0xD2, 0xC3, 0xB4, 0xA5, 0x96, 0x87};
static constexpr uint8_t CHANNEL_READ_CODES[8] = {0xB8, 0xB1, 0xAA, 0xA3, 0x9C, 0x95, 0x8E, 0x87};
bool DS248xComponent::select_channel(uint8_t channel) {
if (this->channel_count_ <= 1)
return true;
if (channel >= this->channel_count_)
return false;
if (this->current_channel_ == channel)
return true;
if (!this->write_byte(DS248X_COMMAND_CHANNELSELECT, CHANNEL_WRITE_CODES[channel])) {
this->current_channel_ = -1;
return false;
}
uint8_t read_code;
if (this->read(&read_code, 1) != i2c::ERROR_OK) {
this->current_channel_ = -1;
return false;
}
if (read_code != CHANNEL_READ_CODES[channel]) {
ESP_LOGW(TAG, "Channel select failed! Expected 0x%02x, got 0x%02x", CHANNEL_READ_CODES[channel], read_code);
this->current_channel_ = -1;
return false;
}
if (!this->set_read_pointer_(DS248X_POINTER_STATUS))
return false;
this->current_channel_ = channel;
return true;
}
// --- 1-Wire Bus Operations ---
bool DS248xComponent::ow_reset(bool &presence) {
if (!this->set_read_pointer_(DS248X_POINTER_STATUS))
return false;
uint8_t cmd = DS248X_COMMAND_RESETWIRE;
if (this->write(&cmd, 1) != i2c::ERROR_OK)
return false;
if (!this->wait_busy_()) {
ESP_LOGW(TAG, "ow_reset: wait busy failed");
return false;
}
uint8_t status;
if (this->read(&status, 1) != i2c::ERROR_OK) {
ESP_LOGW(TAG, "ow_reset: read status failed");
return false;
}
if (status & DS248X_STATUS_SD) {
ESP_LOGW(TAG, "Short detected on 1-Wire bus!");
return false;
}
presence = (status & DS248X_STATUS_PPD);
return true;
}
bool DS248xComponent::ow_write_byte(uint8_t byte) {
if (!this->set_read_pointer_(DS248X_POINTER_STATUS))
return false;
if (!this->wait_busy_()) {
ESP_LOGW(TAG, "Device busy before writing byte 0x%02x", byte);
return false;
}
uint8_t cmd[2] = {DS248X_COMMAND_WRITEBYTE, byte};
if (this->write(cmd, 2) != i2c::ERROR_OK) {
ESP_LOGW(TAG, "I2C write failed for byte 0x%02x", byte);
return false;
}
if (!this->wait_busy_()) {
ESP_LOGW(TAG, "Timeout waiting for write byte to complete!");
return false;
}
return true;
}
bool DS248xComponent::ow_read_byte(uint8_t &byte) {
if (!this->set_read_pointer_(DS248X_POINTER_STATUS))
return false;
uint8_t cmd = DS248X_COMMAND_READBYTE;
if (this->write(&cmd, 1) != i2c::ERROR_OK)
return false;
if (!this->wait_busy_())
return false;
if (!this->set_read_pointer_(DS248X_POINTER_DATA))
return false;
if (this->read(&byte, 1) != i2c::ERROR_OK)
return false;
return true;
}
bool DS248xComponent::search_triplet(bool search_direction, uint8_t &status) {
if (!this->set_read_pointer_(DS248X_POINTER_STATUS))
return false;
// DS248x Datasheet: 1-Wire Triplet command requires 2 bytes:
// Byte 1: Command code 0x78
// Byte 2: Direction byte (bit 7 = V, search direction if discrepancy)
uint8_t buffer[2] = {DS248X_COMMAND_TRIPLET, static_cast<uint8_t>(search_direction ? 0x80 : 0x00)};
if (this->write(buffer, 2) != i2c::ERROR_OK)
return false;
if (!this->wait_busy_())
return false;
if (this->read(&status, 1) != i2c::ERROR_OK)
return false;
return true;
}
} // namespace esphome::ds248x
+133
View File
@@ -0,0 +1,133 @@
#pragma once
// DS248x I2C-to-1-Wire Bridge Family
// Datasheet: https://www.analog.com/media/en/technical-documentation/data-sheets/ds2482-100.pdf
// Datasheet: https://www.analog.com/media/en/technical-documentation/data-sheets/ds2482-800.pdf
// Datasheet: https://www.analog.com/media/en/technical-documentation/data-sheets/ds2484.pdf
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/components/i2c/i2c.h"
namespace esphome::ds248x {
// DS248x I2C Commands
static constexpr uint8_t DS248X_COMMAND_RESET = 0xF0;
static constexpr uint8_t DS248X_COMMAND_SETREADPTR = 0xE1;
static constexpr uint8_t DS248X_COMMAND_WRITECONFIG = 0xD2;
static constexpr uint8_t DS248X_COMMAND_CHANNELSELECT = 0xC3;
static constexpr uint8_t DS248X_COMMAND_RESETWIRE = 0xB4;
static constexpr uint8_t DS248X_COMMAND_WRITEBYTE = 0xA5;
static constexpr uint8_t DS248X_COMMAND_READBYTE = 0x96;
static constexpr uint8_t DS248X_COMMAND_TRIPLET = 0x78;
static constexpr uint8_t DS2484_COMMAND_ADJUSTPORT = 0xC3;
// DS2484 "Adjust 1-Wire Port" parameter codes (datasheet Table 6, control byte P[2:0])
static constexpr uint8_t DS2484_PORT_PARAM_TRSTL = 0x0;
static constexpr uint8_t DS2484_PORT_PARAM_TMSP = 0x1;
static constexpr uint8_t DS2484_PORT_PARAM_TW0L = 0x2;
static constexpr uint8_t DS2484_PORT_PARAM_TREC0 = 0x3;
static constexpr uint8_t DS2484_PORT_PARAM_RWPU = 0x4;
// DS248x Status Register Bits
static constexpr uint8_t DS248X_STATUS_BUSY = 0x01;
static constexpr uint8_t DS248X_STATUS_PPD = 0x02;
static constexpr uint8_t DS248X_STATUS_SD = 0x04;
static constexpr uint8_t DS248X_STATUS_RST = 0x10;
static constexpr uint8_t DS248X_STATUS_SBR = 0x20;
static constexpr uint8_t DS248X_STATUS_TSB = 0x40;
static constexpr uint8_t DS248X_STATUS_DIR = 0x80;
// DS248x Register Pointers
static constexpr uint8_t DS248X_POINTER_STATUS = 0xF0;
static constexpr uint8_t DS248X_POINTER_DATA = 0xE1;
static constexpr uint8_t DS248X_POINTER_CONFIG = 0xC3;
// DS248x Configuration Bits
static constexpr uint8_t DS248X_CONFIG_ACTIVE_PULLUP = 0x01;
/**
* @brief DS248x I2C-to-1-Wire Bridge Component.
*
* This component manages the DS248x chip (DS2482-100, DS2482-800, DS2484).
* It provides low-level 1-Wire bus operations via I2C.
*
* Usage: Configure DS248xOneWireBus instances for each channel.
* These buses implement the one_wire::OneWireBus interface for compatibility
* with all existing 1-Wire device components (dallas_temp, etc.).
*/
class DS248xComponent : public Component, public i2c::I2CDevice {
public:
void setup() override;
void dump_config() override;
void on_shutdown() override;
float get_setup_priority() const override { return setup_priority::BUS; }
void set_sleep_pin(InternalGPIOPin *pin) { this->sleep_pin_ = pin; }
void set_bus_sleep(bool enabled) { this->bus_sleep_ = enabled; }
void set_hub_sleep(bool enabled) { this->hub_sleep_ = enabled; }
void set_channel_count(uint8_t count) { this->channel_count_ = count; }
void set_active_pullup(bool enabled) { this->active_pullup_ = enabled; }
// DS2484 Timing Parameters
void set_val_trstl(uint8_t val) {
this->ds2484_trstl_ = val;
this->ds2484_mode_ = true;
}
void set_val_tmsp(uint8_t val) {
this->ds2484_tmsp_ = val;
this->ds2484_mode_ = true;
}
void set_val_tw0l(uint8_t val) {
this->ds2484_tw0l_ = val;
this->ds2484_mode_ = true;
}
void set_val_trec0(uint8_t val) {
this->ds2484_trec0_ = val;
this->ds2484_mode_ = true;
}
void set_val_rwpu(uint8_t val) {
this->ds2484_rwpu_ = val;
this->ds2484_mode_ = true;
}
/// Get the channel count (1 for DS2482-100/DS2484, 8 for DS2482-800)
uint8_t get_channel_count() const { return this->channel_count_; }
// --- Core 1-Wire API (used by DS248xOneWireBus) ---
bool select_channel(uint8_t channel);
bool ow_reset(bool &presence);
bool ow_write_byte(uint8_t byte);
bool ow_read_byte(uint8_t &byte);
// --- Search support (used by DS248xOneWireBus) ---
bool search_triplet(bool search_direction, uint8_t &status);
protected:
InternalGPIOPin *sleep_pin_{nullptr};
uint8_t channel_count_ = 1;
bool bus_sleep_{false};
bool hub_sleep_{false};
bool active_pullup_ = false;
// DS2484 Config
bool ds2484_mode_ = false;
static constexpr uint8_t DS2484_PARAM_UNSET = 0xFF;
uint8_t ds2484_trstl_{DS2484_PARAM_UNSET};
uint8_t ds2484_tmsp_{DS2484_PARAM_UNSET};
uint8_t ds2484_tw0l_{DS2484_PARAM_UNSET};
uint8_t ds2484_trec0_{DS2484_PARAM_UNSET};
uint8_t ds2484_rwpu_{DS2484_PARAM_UNSET};
int8_t current_channel_{-1};
// Internal helpers
bool set_read_pointer_(uint8_t ptr);
bool wait_busy_();
bool device_reset_();
bool device_configure_();
bool configure_ds2484_port_(uint8_t param, uint8_t val);
bool write_config_();
};
} // namespace esphome::ds248x
@@ -0,0 +1,171 @@
#include "ds248x_one_wire_bus.h"
#include "ds248x.h"
#include "esphome/core/log.h"
namespace esphome::ds248x {
static const char *const TAG = "ds248x.one_wire";
void DS248xOneWireBus::setup() {
ESP_LOGCONFIG(TAG, "Setting up DS248x 1-Wire Bus (Channel %d)...", this->channel_);
// Parent setup happens in DS248xComponent::setup()
// We just need to scan for devices on this channel
if (!this->ensure_channel_()) {
ESP_LOGE(TAG, "Failed to select channel %d during setup", this->channel_);
this->mark_failed();
return;
}
// Perform device search on this channel
this->search();
ESP_LOGCONFIG(TAG, "Found %zu devices on channel %d", this->devices_.size(), this->channel_);
}
void DS248xOneWireBus::dump_config() {
ESP_LOGCONFIG(TAG, "DS248x 1-Wire Bus (Channel %d):", this->channel_);
this->dump_devices_(TAG);
}
bool DS248xOneWireBus::ensure_channel_() {
if (this->parent_ == nullptr) {
ESP_LOGE(TAG, "Parent not set!");
return false;
}
return this->parent_->select_channel(this->channel_);
}
int DS248xOneWireBus::reset_int() {
if (!this->ensure_channel_()) {
return -1;
}
bool presence = false;
if (!this->parent_->ow_reset(presence)) {
return -1;
}
return presence ? 1 : 0;
}
void DS248xOneWireBus::write8(uint8_t val) {
if (!this->ensure_channel_()) {
return;
}
if (!this->parent_->ow_write_byte(val)) {
ESP_LOGE(TAG, "Failed to write byte 0x%02X on channel %d", val, this->channel_);
}
}
void DS248xOneWireBus::write64(uint64_t val) {
if (!this->ensure_channel_()) {
return;
}
for (uint8_t i = 0; i < 8; i++) {
uint8_t byte = static_cast<uint8_t>(val >> (i * 8));
if (!this->parent_->ow_write_byte(byte)) {
ESP_LOGE(TAG, "Failed to write byte %d/8 (0x%02X) on channel %d - aborting write64", i + 1, byte, this->channel_);
return; // Stop writing to prevent sending corrupted data
}
}
}
uint8_t DS248xOneWireBus::read8() {
if (!this->ensure_channel_()) {
return 0;
}
uint8_t value = 0;
if (!this->parent_->ow_read_byte(value)) {
ESP_LOGE(TAG, "Failed to read byte on channel %d", this->channel_);
}
return value;
}
uint64_t DS248xOneWireBus::read64() {
if (!this->ensure_channel_()) {
return 0;
}
uint64_t value = 0;
for (uint8_t i = 0; i < 8; i++) {
uint8_t byte = 0;
if (!this->parent_->ow_read_byte(byte)) {
ESP_LOGE(TAG, "Failed to read byte %d/8 on channel %d - returning partial data", i + 1, this->channel_);
return value; // Return partial data to avoid blocking, caller should validate
}
value |= (static_cast<uint64_t>(byte) << (i * 8));
}
return value;
}
void DS248xOneWireBus::reset_search() {
this->search_last_discrepancy_ = 0;
this->search_last_device_flag_ = false;
this->search_address_ = 0;
}
uint64_t DS248xOneWireBus::search_int() {
if (!this->ensure_channel_()) {
return 0;
}
if (this->search_last_device_flag_) {
return 0;
}
uint8_t last_zero = 0;
uint64_t address = this->search_address_;
// Iterate through all 64 bits
for (uint8_t bit_number = 1; bit_number <= 64; bit_number++) {
uint64_t bit_mask = 1ULL << (bit_number - 1);
// Determine search direction
bool search_direction;
if (bit_number < this->search_last_discrepancy_) {
search_direction = (address & bit_mask) != 0;
} else {
search_direction = (bit_number == this->search_last_discrepancy_);
}
// Perform triplet operation
uint8_t status = 0;
if (!this->parent_->search_triplet(search_direction, status)) {
ESP_LOGW(TAG, "1-Wire triplet failed at bit %d on channel %d - aborting search", bit_number, this->channel_);
this->reset_search();
return 0;
}
bool id_bit = (status & DS248X_STATUS_SBR) != 0;
bool cmp_id_bit = (status & DS248X_STATUS_TSB) != 0;
bool dir_taken = (status & DS248X_STATUS_DIR) != 0;
if (id_bit && cmp_id_bit) {
// No devices participating
this->reset_search();
return 0;
}
if (!id_bit && !cmp_id_bit && !dir_taken) {
// Discrepancy, went 0 - record position
last_zero = bit_number;
}
// Update address based on direction taken
if (dir_taken) {
address |= bit_mask;
} else {
address &= ~bit_mask;
}
}
// Search successful
this->search_last_discrepancy_ = last_zero;
if (last_zero == 0) {
this->search_last_device_flag_ = true;
}
this->search_address_ = address;
return address;
}
} // namespace esphome::ds248x
@@ -0,0 +1,57 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/one_wire/one_wire_bus.h"
namespace esphome::ds248x {
class DS248xComponent;
/**
* @brief OneWireBus implementation for DS248x I2C-to-1-Wire bridges.
*
* This class wraps the DS248xComponent to provide the one_wire::OneWireBus interface,
* enabling compatibility with all existing 1-Wire device components (dallas_temp, etc.).
*
* For DS2482-800, multiple instances of this class can be created (one per channel).
* For DS2482-100/DS2484, a single instance is used.
*/
class DS248xOneWireBus : public one_wire::OneWireBus, public Component {
public:
void setup() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::BUS - 1.0f; }
/// Set the parent DS248x component
void set_parent(DS248xComponent *parent) { this->parent_ = parent; }
/// Set the 1-Wire channel (0-7, only relevant for DS2482-800)
void set_channel(uint8_t channel) { this->channel_ = channel; }
/// Get the channel number
uint8_t get_channel() const { return this->channel_; }
// OneWireBus interface implementation
int reset_int() override;
void write8(uint8_t val) override;
void write64(uint64_t val) override;
uint8_t read8() override;
uint64_t read64() override;
protected:
void reset_search() override;
uint64_t search_int() override;
/// Select the channel on the DS248x before any 1-Wire operation
bool ensure_channel_();
DS248xComponent *parent_{nullptr};
uint8_t channel_{0};
// Search state
uint64_t search_address_{0};
uint8_t search_last_discrepancy_{0};
bool search_last_device_flag_{false};
};
} // namespace esphome::ds248x
+56
View File
@@ -0,0 +1,56 @@
"""DS248x 1-Wire Bus Platform.
This platform creates one_wire bus instances backed by a DS248x I2C-to-1-Wire bridge.
It supports DS2482-100/101 (single channel), DS2482-800 (8 channels), and DS2484 (single channel).
For multi-channel devices (DS2482-800), create one platform entry per channel.
Each entry becomes a separate one_wire bus that can be used by dallas_temp and other 1-Wire devices.
"""
from esphome import final_validate as fv
import esphome.codegen as cg
from esphome.components.one_wire import OneWireBus
import esphome.config_validation as cv
from esphome.const import CONF_CHANNEL, CONF_ID
from . import CONF_DS248X_ID, DS248xComponent, ds248x_ns, get_channel_count
CODEOWNERS = ["@tomwellnitz"]
DEPENDENCIES = ["ds248x"]
DS248xOneWireBus = ds248x_ns.class_("DS248xOneWireBus", OneWireBus, cg.Component)
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(DS248xOneWireBus),
cv.GenerateID(CONF_DS248X_ID): cv.use_id(DS248xComponent),
cv.Optional(CONF_CHANNEL, default=0): cv.int_range(min=0, max=7),
}
).extend(cv.COMPONENT_SCHEMA)
def _final_validate(config):
"""Validate that the channel is within the parent's channel count."""
fconf = fv.full_config.get()
path = fconf.get_path_for_id(config[CONF_DS248X_ID])[:-1]
parent_config = fconf.get_config_for_path(path)
channel_count = get_channel_count(parent_config)
channel = config[CONF_CHANNEL]
if channel >= channel_count:
raise cv.Invalid(
f"Channel {channel} is invalid for DS248x with {channel_count} channel(s). "
f"Valid range: 0-{channel_count - 1}"
)
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
parent = await cg.get_variable(config[CONF_DS248X_ID])
cg.add(var.set_parent(parent))
cg.add(var.set_channel(config[CONF_CHANNEL]))
+3 -3
View File
@@ -145,9 +145,9 @@ float Emc2101Component::get_external_temperature() {
return NAN;
}
// join msb and lsb (5 least significant bits are not used)
uint16_t raw = (msb << 8 | lsb) >> 5;
return raw * 0.125;
// join msb and lsb (5 least significant bits are not used); msb is signed, so read as int16_t
int16_t raw = static_cast<int16_t>((msb << 8) | lsb) >> 5;
return raw * 0.125f;
}
float Emc2101Component::get_speed() {
@@ -14,10 +14,9 @@ void EPaperMono::refresh_screen(bool partial) {
}
void EPaperMono::deep_sleep() {
ESP_LOGV(TAG, "Deep sleep");
if (this->is_using_partial_update_()) {
this->cmd_data(0x10, {0x00}); // sleep in power on mode
} else {
// Deep sleep loses RAM so cannot be used with partial update
if (!this->is_using_partial_update_()) {
ESP_LOGV(TAG, "Deep sleep");
this->cmd_data(0x10, {0x03}); // deep sleep
}
}
+170 -33
View File
@@ -109,7 +109,9 @@ CONF_ENGINEERING_SAMPLE = "engineering_sample"
CONF_INCLUDE_BUILTIN_IDF_COMPONENTS = "include_builtin_idf_components"
CONF_ENABLE_LWIP_ASSERT = "enable_lwip_assert"
CONF_EXECUTE_FROM_PSRAM = "execute_from_psram"
CONF_KEY_ID = "key_id"
CONF_MINIMUM_CHIP_REVISION = "minimum_chip_revision"
CONF_NVS_ENCRYPTION = "nvs_encryption"
CONF_RELEASE = "release"
CONF_SIGNED_OTA_VERIFICATION = "signed_ota_verification"
CONF_SIGNING_KEY = "signing_key"
@@ -167,6 +169,20 @@ SIGNED_OTA_V1_ECDSA_VARIANTS = {
VARIANT_ESP32,
}
# NVS encryption (HMAC peripheral scheme) is only available on variants that
# expose the HMAC peripheral (SOC_HMAC_SUPPORTED in soc_caps.h). The original
# ESP32 and ESP32-C2 do not have it. New variants with an HMAC peripheral
# should be added here.
NVS_ENCRYPTION_HMAC_VARIANTS = {
VARIANT_ESP32S2,
VARIANT_ESP32S3,
VARIANT_ESP32C3,
VARIANT_ESP32C5,
VARIANT_ESP32C6,
VARIANT_ESP32H2,
VARIANT_ESP32P4,
}
COMPILER_OPTIMIZATIONS = {
"DEBUG": "CONFIG_COMPILER_OPTIMIZATION_DEBUG",
"NONE": "CONFIG_COMPILER_OPTIMIZATION_NONE",
@@ -1144,6 +1160,74 @@ def _ota_downgrade_protection_errors(
return errs
_SIGNED_OTA_VERIFICATION_SCHEMA = cv.Schema(
{
cv.Optional(CONF_SIGNING_KEY): cv.file_,
cv.Optional(CONF_VERIFICATION_KEY): cv.file_,
cv.Optional(CONF_SIGNING_SCHEME, default="rsa3072"): cv.one_of(
*SIGNING_SCHEMES, lower=True
),
}
)
@schema_extractor("schema")
def _validate_signed_ota_verification(value):
if value is SCHEMA_EXTRACT:
# Expose the inner schema so the language-schema dumper can walk the
# signing_key / verification_key / signing_scheme options.
return _SIGNED_OTA_VERIFICATION_SCHEMA
if value is None:
# A bare `signed_ota_verification:` block is valid: the default V2
# scheme needs no keys (verify externally-signed binaries).
value = {}
return _validate_signed_ota_keys(_SIGNED_OTA_VERIFICATION_SCHEMA(value))
def _validate_signed_ota_keys(config: ConfigType) -> ConfigType:
"""Validate the signing/verification key combination for the selected scheme.
A verification key is only used by the Secure Boot V1 scheme (ecdsa_v1):
the public key is compiled into the app so it can verify externally-signed
images. ESP-IDF's CONFIG_SECURE_BOOT_VERIFICATION_KEY only takes effect
when the V1 ECDSA scheme is selected and binaries are not signed during
the build (see SECURE_BOOT_VERIFICATION_KEY in the bootloader Kconfig).
The V2 schemes (rsa3072, ecdsa256) embed the public key in the signature
block appended to each image, so verifying externally-signed binaries
needs no key in the config at all -- omitting both keys selects that
external-signing mode.
"""
has_signing_key = CONF_SIGNING_KEY in config
has_verification_key = CONF_VERIFICATION_KEY in config
scheme = config[CONF_SIGNING_SCHEME]
if has_signing_key and has_verification_key:
raise cv.Invalid(
f"Provide at most one of '{CONF_SIGNING_KEY}' and "
f"'{CONF_VERIFICATION_KEY}', not both.",
path=[CONF_VERIFICATION_KEY],
)
if scheme == "ecdsa_v1":
if not has_signing_key and not has_verification_key:
raise cv.Invalid(
f"Signing scheme 'ecdsa_v1' requires either '{CONF_SIGNING_KEY}' "
f"(to sign binaries during the build) or '{CONF_VERIFICATION_KEY}' "
f"(to verify binaries signed externally).",
path=[CONF_SIGNING_KEY],
)
elif has_verification_key:
raise cv.Invalid(
f"'{CONF_VERIFICATION_KEY}' is only used with signing scheme "
f"'ecdsa_v1'. With '{scheme}' the public key is embedded in each "
f"image's signature block, so no key file is needed to verify "
f"externally-signed binaries: remove '{CONF_VERIFICATION_KEY}', and "
f"set '{CONF_SIGNING_KEY}' only if binaries should be signed during "
f"the build.",
path=[CONF_VERIFICATION_KEY],
)
return config
def final_validate(config):
# Imported locally to avoid circular import issues
from esphome.components.psram import DOMAIN as PSRAM_DOMAIN
@@ -1345,10 +1429,33 @@ def final_validate(config):
)
else:
_LOGGER.info(
"Signed OTA verification is configured with a public verification key. "
"Signed OTA verification is enabled without a signing key. "
"Binaries will NOT be signed automatically during build. "
"You must sign them externally before flashing."
)
if (nvs_enc := advanced.get(CONF_NVS_ENCRYPTION)) is not None:
variant = config[CONF_VARIANT]
if variant in NVS_ENCRYPTION_HMAC_VARIANTS:
_LOGGER.warning(
"NVS encryption will burn an HMAC key into eFuse key block %d on the "
"first boot of each device. This is PERMANENT and IRREVERSIBLE: "
"the block cannot be erased or reused afterwards. Enabling (or "
"later disabling) encryption also wipes any previously saved "
"preferences once, because the older data can no longer be read.",
nvs_enc[CONF_KEY_ID],
)
else:
supported = ", ".join(
sorted(VARIANT_FRIENDLY[v] for v in NVS_ENCRYPTION_HMAC_VARIANTS)
)
errs.append(
cv.Invalid(
f"NVS encryption (HMAC scheme) is not supported on "
f"{VARIANT_FRIENDLY[variant]} (it has no HMAC peripheral). "
f"Supported variants: {supported}.",
path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_NVS_ENCRYPTION],
)
)
if advanced[CONF_ENABLE_OTA_DOWNGRADE_PROTECTION]:
project = full_config[CONF_ESPHOME].get(CONF_PROJECT)
errs.extend(
@@ -1539,16 +1646,20 @@ FRAMEWORK_SCHEMA = cv.Schema(
{
cv.Optional(CONF_TYPE): cv.one_of(FRAMEWORK_ESP_IDF, FRAMEWORK_ARDUINO),
cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict,
cv.Optional(CONF_RELEASE): cv.string_strict,
cv.Optional(CONF_SOURCE): cv.string_strict,
cv.Optional(CONF_PLATFORM_VERSION): _parse_pio_platform_version,
cv.Optional(CONF_SDKCONFIG_OPTIONS, default={}): {
cv.string_strict: cv.string_strict
},
cv.Optional(CONF_RELEASE, visibility=cv.Visibility.YAML_ONLY): cv.string_strict,
cv.Optional(CONF_SOURCE, visibility=cv.Visibility.YAML_ONLY): cv.string_strict,
cv.Optional(
CONF_PLATFORM_VERSION, visibility=cv.Visibility.YAML_ONLY
): _parse_pio_platform_version,
cv.Optional(
CONF_SDKCONFIG_OPTIONS, default={}, visibility=cv.Visibility.YAML_ONLY
): {cv.string_strict: cv.string_strict},
cv.Optional(CONF_LOG_LEVEL, default="ERROR"): cv.one_of(
*LOG_LEVELS_IDF, upper=True
),
cv.Optional(CONF_ADVANCED, default={}): cv.Schema(
cv.Optional(
CONF_ADVANCED, default={}, visibility=cv.Visibility.YAML_ONLY
): cv.Schema(
{
cv.Optional(CONF_ASSERTION_LEVEL): cv.one_of(
*ASSERTION_LEVELS, upper=True
@@ -1597,17 +1708,17 @@ FRAMEWORK_SCHEMA = cv.Schema(
cv.Optional(
CONF_ENABLE_OTA_DOWNGRADE_PROTECTION, default=False
): cv.boolean,
cv.Optional(CONF_SIGNED_OTA_VERIFICATION): cv.All(
cv.Schema(
{
cv.Optional(CONF_SIGNING_KEY): cv.file_,
cv.Optional(CONF_VERIFICATION_KEY): cv.file_,
cv.Optional(
CONF_SIGNING_SCHEME, default="rsa3072"
): cv.one_of(*SIGNING_SCHEMES, lower=True),
}
),
cv.has_exactly_one_key(CONF_SIGNING_KEY, CONF_VERIFICATION_KEY),
cv.Optional(
CONF_SIGNED_OTA_VERIFICATION
): _validate_signed_ota_verification,
cv.Optional(CONF_NVS_ENCRYPTION): cv.Schema(
{
# eFuse key block (0-5) that stores the HMAC key from
# which the NVS encryption keys are derived. The block is
# written on first boot if empty -- an irreversible
# operation -- so it must be chosen explicitly.
cv.Required(CONF_KEY_ID): cv.int_range(min=0, max=5),
}
),
cv.Optional(
CONF_USE_FULL_CERTIFICATE_BUNDLE, default=False
@@ -1629,7 +1740,9 @@ FRAMEWORK_SCHEMA = cv.Schema(
cv.Optional(CONF_DISABLE_FATFS, default=True): cv.boolean,
}
),
cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list(
cv.Optional(
CONF_COMPONENTS, default=[], visibility=cv.Visibility.YAML_ONLY
): cv.ensure_list(
cv.All(
cv.Any(
cv.All(cv.string_strict, _parse_idf_component),
@@ -1729,7 +1842,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_FLASH_FREQUENCY): cv.one_of(
*FLASH_FREQUENCIES, upper=True
),
cv.Optional(CONF_PARTITIONS): cv.Any(
cv.Optional(CONF_PARTITIONS, visibility=cv.Visibility.YAML_ONLY): cv.Any(
cv.file_,
cv.ensure_list(
cv.All(
@@ -1753,7 +1866,9 @@ CONFIG_SCHEMA = cv.All(
),
cv.Optional(CONF_VARIANT): cv.one_of(*VARIANTS, upper=True),
cv.Optional(CONF_FRAMEWORK): FRAMEWORK_SCHEMA,
cv.Optional(CONF_TOOLCHAIN): _validate_toolchain,
cv.Optional(
CONF_TOOLCHAIN, visibility=cv.Visibility.ADVANCED
): _validate_toolchain,
cv.Optional(CONF_WATCHDOG_TIMEOUT, default="5s"): cv.All(
cv.positive_time_period_seconds,
cv.Range(min=cv.TimePeriod(seconds=5), max=cv.TimePeriod(seconds=60)),
@@ -2442,15 +2557,33 @@ async def to_code(config):
signed_ota[CONF_SIGNING_KEY].resolve().as_posix(),
)
else:
# Public key mode — verification only, external signing required
# External signing mode — binaries must be signed after the build
add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", False)
add_idf_sdkconfig_option(
"CONFIG_SECURE_BOOT_VERIFICATION_KEY",
signed_ota[CONF_VERIFICATION_KEY].resolve().as_posix(),
)
if CONF_VERIFICATION_KEY in signed_ota:
# V1 ECDSA only: the public key is compiled into the app to
# verify externally-signed images. V2 schemes carry the public
# key in each image's signature block and need no key here.
add_idf_sdkconfig_option(
"CONFIG_SECURE_BOOT_VERIFICATION_KEY",
signed_ota[CONF_VERIFICATION_KEY].resolve().as_posix(),
)
cg.add_define("USE_OTA_SIGNED_VERIFICATION")
# Encrypt NVS using the HMAC peripheral scheme. The NVS encryption keys are
# derived at runtime from an HMAC key stored in the configured eFuse block
# (no flash encryption required). The HMAC key is generated and burned into
# the eFuse block on first boot if it is empty. With the scheme selected,
# nvs_sec_provider registers it at startup and the default nvs_flash_init()
# (used in esp32/preferences.cpp) transparently performs the secure init, so
# no C++ changes are needed.
if (nvs_enc := advanced.get(CONF_NVS_ENCRYPTION)) is not None:
add_idf_sdkconfig_option("CONFIG_NVS_ENCRYPTION", True)
add_idf_sdkconfig_option("CONFIG_NVS_SEC_KEY_PROTECT_USING_HMAC", True)
add_idf_sdkconfig_option(
"CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID", nvs_enc[CONF_KEY_ID]
)
cg.add_define("ESPHOME_LOOP_TASK_STACK_SIZE", advanced[CONF_LOOP_TASK_STACK_SIZE])
cg.add_define(
@@ -2925,19 +3058,23 @@ def copy_files():
def _decode_pc(config, addr):
# _decode_pc runs from the api log processor's asyncio callback, which
# only catches EsphomeError. Any other exception escaping here tears down
# the protocol and triggers an infinite reconnect/replay loop. Convert
# toolchain-resolution errors (e.g. missing build dir / cmake cache) into
# EsphomeError so the caller can disable decoding cleanly.
# Convert toolchain-resolution errors (e.g. missing build dir / cmake
# cache) into EsphomeError. The api log processor stops decoding on any
# exception, so this is about the message it reports rather than about
# catching it at all: EsphomeError carries an explanation worth showing
# the user, where a raw OSError repr does not.
if CORE.using_toolchain_esp_idf:
from esphome.espidf import toolchain as idf_toolchain
try:
addr2line_path = idf_toolchain.get_addr2line_path()
firmware_elf_path = idf_toolchain.get_elf_path()
except RuntimeError as err:
except (RuntimeError, OSError) as err:
# OSError covers a missing build directory or a cmake that isn't
# on PATH; both surface from the subprocess call, not as RuntimeError.
raise EsphomeError(f"ESP-IDF toolchain not available: {err}") from err
if not firmware_elf_path.is_file():
raise EsphomeError(f"Firmware ELF not found: {firmware_elf_path}")
else:
from esphome.platformio import toolchain
+47 -5
View File
@@ -9,6 +9,8 @@
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#include <esp_bt.h>
#else
#include "esphome/components/watchdog/watchdog.h"
#include <cinttypes>
extern "C" {
#include <esp_hosted.h>
#include <esp_hosted_misc.h>
@@ -33,6 +35,19 @@ namespace esphome::esp32_ble {
static const char *const TAG = "esp32_ble";
#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
// Bringing up the remote BT controller issues synchronous RPCs to the
// co-processor with 5 second response timeouts, and the default task watchdog
// is also 5 seconds. If the co-processor firmware does not answer (for example
// factory firmware without Bluetooth support), the watchdog would reboot the
// device before the RPC could return an error, causing a boot loop. Raise the
// watchdog for the duration of the bring-up so failures surface as error
// returns instead. 60 seconds covers the worst case: transport reconnect
// (up to ~20s), version preflight (1s), controller init/enable (5s each) and
// the bluedroid host bring-up over the hosted HCI transport.
static constexpr uint32_t HOSTED_BT_WDT_TIMEOUT_MS = 60000;
#endif
// GAP event groups for deduplication across gap_event_handler and dispatch_gap_event_
#define GAP_SCAN_COMPLETE_EVENTS \
case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT: \
@@ -164,6 +179,9 @@ void ESP32BLE::advertising_init_() {
bool ESP32BLE::ble_setup_() {
esp_err_t err;
#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
watchdog::WatchdogManager wdt(HOSTED_BT_WDT_TIMEOUT_MS);
#endif
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
if (esp_bt_controller_get_status() != ESP_BT_CONTROLLER_STATUS_ENABLED) {
// start bt controller
@@ -192,15 +210,35 @@ bool ESP32BLE::ble_setup_() {
esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT);
#else
esp_hosted_connect_to_slave(); // NOLINT
if (esp_hosted_connect_to_slave() != ESP_OK) { // NOLINT
ESP_LOGE(TAG, "Co-processor transport failed; BLE disabled");
return false;
}
// Fast preflight (1 second RPC timeout): verifies the co-processor answers
// RPCs at all before the 5 second timeout BT controller RPCs below, and
// before hosted_hci_bluedroid_open(), which aborts if the transport is down.
esp_hosted_coprocessor_fwver_t fw_ver{};
if (esp_hosted_get_coprocessor_fwversion(&fw_ver) != ESP_OK) {
ESP_LOGE(TAG, "Co-processor not responding; BLE disabled. Update its firmware with the esp32_hosted "
"update component");
return false;
}
ESP_LOGD(TAG, "Co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32, fw_ver.major1, fw_ver.minor1, fw_ver.patch1);
if (esp_hosted_bt_controller_init() != ESP_OK) {
ESP_LOGW(TAG, "esp_hosted_bt_controller_init failed");
ESP_LOGE(TAG,
"BT controller init failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32
" may lack BT support. Update it with the esp32_hosted update component; BLE disabled",
fw_ver.major1, fw_ver.minor1, fw_ver.patch1);
return false;
}
if (esp_hosted_bt_controller_enable() != ESP_OK) {
ESP_LOGW(TAG, "esp_hosted_bt_controller_enable failed");
ESP_LOGE(TAG,
"BT controller enable failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32
" may lack BT support. Update it with the esp32_hosted update component; BLE disabled",
fw_ver.major1, fw_ver.minor1, fw_ver.patch1);
return false;
}
@@ -332,6 +370,10 @@ bool ESP32BLE::ble_setup_() {
}
bool ESP32BLE::ble_dismantle_() {
#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
// Same 5 second RPCs as the bring-up path; see HOSTED_BT_WDT_TIMEOUT_MS
watchdog::WatchdogManager wdt(HOSTED_BT_WDT_TIMEOUT_MS);
#endif
esp_err_t err = esp_bluedroid_disable();
if (err != ESP_OK) {
// ESP_ERR_INVALID_STATE means Bluedroid is already disabled, which is fine
@@ -377,12 +419,12 @@ bool ESP32BLE::ble_dismantle_() {
}
#else
if (esp_hosted_bt_controller_disable() != ESP_OK) {
ESP_LOGW(TAG, "esp_hosted_bt_controller_disable failed");
ESP_LOGE(TAG, "esp_hosted_bt_controller_disable failed");
return false;
}
if (esp_hosted_bt_controller_deinit(false) != ESP_OK) {
ESP_LOGW(TAG, "esp_hosted_bt_controller_deinit failed");
ESP_LOGE(TAG, "esp_hosted_bt_controller_deinit failed");
return false;
}
@@ -181,12 +181,6 @@ const char *ESPBTUUID::to_str(std::span<char, UUID_STR_LEN> output) const {
return output.data();
}
}
std::string ESPBTUUID::to_string() const {
char buf[UUID_STR_LEN];
this->to_str(buf);
return std::string(buf);
}
} // namespace esphome::esp32_ble
#endif // USE_ESP32_BLE_UUID
-3
View File
@@ -46,9 +46,6 @@ class ESPBTUUID {
esp_bt_uuid_t get_uuid() const;
// Remove before 2026.8.0
ESPDEPRECATED("Use to_str() instead. Removed in 2026.8.0", "2026.2.0")
std::string to_string() const; // NOLINT
const char *to_str(std::span<char, UUID_STR_LEN> output) const;
protected:
@@ -77,6 +77,10 @@ void BLEDescriptor::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_
case ESP_GATTS_WRITE_EVT: {
if (this->handle_ != param->write.handle)
break;
if (param->write.len > this->value_.attr_max_len) {
ESP_LOGE(TAG, "Size %d too large, must be no bigger than %d", param->write.len, this->value_.attr_max_len);
break;
}
this->value_.attr_len = param->write.len;
memcpy(this->value_.attr_value, param->write.value, param->write.len);
if (this->on_write_callback_) {
@@ -18,6 +18,8 @@ from esphome.const import (
from esphome.cpp_generator import add_define
CODEOWNERS = ["@swoboda1337"]
# esp32_ble raises the task watchdog around the remote BT controller bring-up
AUTO_LOAD = ["watchdog"]
CONF_ACTIVE_HIGH = "active_high"
CONF_BUS_WIDTH = "bus_width"
@@ -7,6 +7,10 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifdef USE_PROVISIONING
#include "esphome/components/provisioning/provisioning.h"
#endif
#ifdef USE_ESP32
namespace esphome::esp32_improv {
@@ -41,6 +45,15 @@ void ESP32ImprovComponent::setup() {
#endif
global_ble_server->on_disconnect([this](uint16_t conn_id) { this->set_error_(improv::ERROR_NONE); });
#ifdef USE_PROVISIONING
if (provisioning::global_provisioning_manager != nullptr) {
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
ESP_LOGD(TAG, "Provisioning window closed; stopping Improv");
this->stop();
});
}
#endif
// Start with loop disabled - will be enabled by start() when needed
this->disable_loop();
}
@@ -282,6 +295,15 @@ void ESP32ImprovComponent::start() {
if (this->should_start_ || this->state_ != improv::STATE_STOPPED)
return;
#ifdef USE_PROVISIONING
// Don't (re)start advertising once the provisioning window has closed - e.g. when
// wifi tries to restart Improv after the window expired at runtime.
if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) {
ESP_LOGD(TAG, "Provisioning window closed; not starting Improv");
return;
}
#endif
ESP_LOGD(TAG, "Setting Improv to start");
this->should_start_ = true;
this->enable_loop();
@@ -338,6 +360,15 @@ void ESP32ImprovComponent::process_incoming_data_() {
this->incoming_data_.clear();
return;
}
#ifdef USE_PROVISIONING
if (provisioning::global_provisioning_manager != nullptr &&
provisioning::global_provisioning_manager->closed()) {
ESP_LOGW(TAG, "Provisioning window closed; refusing settings");
this->set_error_(improv::ERROR_NOT_AUTHORIZED);
this->incoming_data_.clear();
return;
}
#endif
if (wifi::global_wifi_component->is_disabled()) {
// Wi-Fi is disabled, so we can't provision. Respond immediately
// instead of letting the client wait out its provisioning timeout.
+6 -2
View File
@@ -202,8 +202,12 @@ ARDUINO_FRAMEWORK_SCHEMA = cv.All(
cv.Schema(
{
cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict,
cv.Optional(CONF_SOURCE): cv.string_strict,
cv.Optional(CONF_PLATFORM_VERSION): _parse_platform_version,
cv.Optional(
CONF_SOURCE, visibility=cv.Visibility.YAML_ONLY
): cv.string_strict,
cv.Optional(
CONF_PLATFORM_VERSION, visibility=cv.Visibility.YAML_ONLY
): _parse_platform_version,
}
),
_arduino_check_versions,
@@ -112,8 +112,10 @@ enum class EthernetComponentState : uint8_t {
// Platform-neutral duplex/speed types
#ifndef USE_ESP32
// NOLINTBEGIN(readability-identifier-naming)
enum eth_duplex_t { ETH_DUPLEX_HALF, ETH_DUPLEX_FULL };
enum eth_speed_t { ETH_SPEED_10M, ETH_SPEED_100M };
// NOLINTEND(readability-identifier-naming)
#endif
class EthernetComponent final : public Component {
@@ -187,17 +187,18 @@ void EthernetComponent::loop() {
}
void EthernetComponent::dump_config() {
const char *type_str = "Unknown";
#if defined(USE_ETHERNET_W5500)
type_str = "W5500";
const char *type_str = "W5500";
#elif defined(USE_ETHERNET_W5100)
type_str = "W5100";
const char *type_str = "W5100";
#elif defined(USE_ETHERNET_W6100)
type_str = "W6100";
const char *type_str = "W6100";
#elif defined(USE_ETHERNET_W6300)
type_str = "W6300";
const char *type_str = "W6300";
#elif defined(USE_ETHERNET_ENC28J60)
type_str = "ENC28J60";
const char *type_str = "ENC28J60";
#else
const char *type_str = "Unknown";
#endif
#if defined(USE_ETHERNET_W6300)
// W6300 uses PIO QSPI with hardcoded pins — SPI pin fields are not used
+3 -1
View File
@@ -50,7 +50,9 @@ _EVENT_SCHEMA = (
{
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTEventComponent),
cv.GenerateID(): cv.declare_id(Event),
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
cv.Optional(
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
): validate_device_class,
cv.Optional(CONF_ON_EVENT): automation.validate_automation({}),
}
)
@@ -1,4 +1,4 @@
#ifdef USE_ARDUINO
#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)
#include "fastled_light.h"
#include "esphome/core/log.h"
@@ -1,6 +1,6 @@
#pragma once
#ifdef USE_ARDUINO
#if defined(USE_ARDUINO) && !defined(USE_RP2) && !defined(USE_LIBRETINY)
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
+1
View File
@@ -0,0 +1 @@
CODEOWNERS = ["@esphome/core"]
+315
View File
@@ -0,0 +1,315 @@
from __future__ import annotations
import contextlib
import hashlib
import io
import logging
from pathlib import Path
import re
from PIL import Image, UnidentifiedImageError
from esphome import core, external_files
import esphome.codegen as cg
from esphome.components.const import CONF_BYTE_ORDER
from esphome.components.image import (
CONF_INVERT_ALPHA,
CONF_OPAQUE,
CONF_TRANSPARENCY,
DOMAIN,
IMAGE_TYPE,
Image_,
ImageEncoder,
add_metadata,
get_image_type_enum,
get_transparency_enum,
is_svg_file,
validate_settings,
validate_transparency,
validate_type,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_DITHER,
CONF_FILE,
CONF_ICON,
CONF_ID,
CONF_PATH,
CONF_RAW_DATA_ID,
CONF_RESIZE,
CONF_SOURCE,
CONF_TYPE,
CONF_URL,
)
from esphome.core import CORE, HexInt
from esphome.cpp_generator import MockObj, MockObjClass
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
_LOGGER = logging.getLogger(__name__)
# If the MDI file cannot be downloaded within this time, abort.
IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds
SOURCE_LOCAL = "local"
SOURCE_WEB = "web"
SOURCE_MDI = "mdi"
SOURCE_MDIL = "mdil"
SOURCE_MEMORY = "memory"
MDI_SOURCES = {
SOURCE_MDI: "https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/",
SOURCE_MDIL: "https://raw.githubusercontent.com/Pictogrammers/MaterialDesignLight/refs/heads/master/svg/",
SOURCE_MEMORY: "https://raw.githubusercontent.com/Pictogrammers/Memory/refs/heads/main/src/svg/",
}
def compute_local_image_path(value) -> Path:
url = value[CONF_URL] if isinstance(value, dict) else value
h = hashlib.new("sha256")
h.update(url.encode())
key = h.hexdigest()[:8]
# Downloaded files are cached under the shared `image` domain directory so
# the cache location is unaffected by which platform requested the file.
base_dir = external_files.compute_local_file_dir(DOMAIN)
return base_dir / key
def local_path(value):
value = value[CONF_PATH] if isinstance(value, dict) else value
return str(CORE.relative_config_path(value))
def download_file(url, path):
external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT)
return str(path)
def download_gh_svg(value, source):
mdi_id = value[CONF_ICON] if isinstance(value, dict) else value
base_dir = external_files.compute_local_file_dir(DOMAIN) / source
path = base_dir / f"{mdi_id}.svg"
url = MDI_SOURCES[source] + mdi_id + ".svg"
return download_file(url, path)
def download_image(value):
value = value[CONF_URL] if isinstance(value, dict) else value
return download_file(value, compute_local_image_path(value))
def validate_file_shorthand(value):
value = cv.string_strict(value)
parts = value.strip().split(":")
if len(parts) == 2 and parts[0] in MDI_SOURCES:
match = re.match(r"^[a-zA-Z0-9\-]+$", parts[1])
if match is None:
raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.")
return download_gh_svg(parts[1], parts[0])
if value.startswith(("http://", "https://")):
return download_image(value)
value = cv.file_(value)
return local_path(value)
LOCAL_SCHEMA = cv.All(
{
cv.Required(CONF_PATH): cv.file_,
},
local_path,
)
def mdi_schema(source):
def validate_mdi(value):
return download_gh_svg(value, source)
return cv.All(
cv.Schema(
{
cv.Required(CONF_ICON): cv.string,
}
),
validate_mdi,
)
WEB_SCHEMA = cv.All(
{
cv.Required(CONF_URL): cv.string,
},
download_image,
)
TYPED_FILE_SCHEMA = cv.typed_schema(
{
SOURCE_LOCAL: LOCAL_SCHEMA,
SOURCE_WEB: WEB_SCHEMA,
}
| {source: mdi_schema(source) for source in MDI_SOURCES},
key=CONF_SOURCE,
)
OPTIONS_SCHEMA = {
cv.Optional(CONF_RESIZE): cv.dimensions,
cv.Optional(CONF_DITHER, default="NONE"): cv.one_of(
"NONE", "FLOYDSTEINBERG", upper=True
),
cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean,
cv.Optional(CONF_BYTE_ORDER): cv.one_of("BIG_ENDIAN", "LITTLE_ENDIAN", upper=True),
cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(),
}
def image_schema(class_: MockObjClass = Image_) -> cv.Schema:
"""Build the validation schema for a single file-backed image entry.
Shared by the built-in ``file`` image platform and the ``animation``
platform (which extends it). Platforms that source their pixels elsewhere
(e.g. ``online_image``) provide their own schema instead.
:param class_: The declared C++ class for the generated image instance.
"""
return cv.Schema(
{
cv.Required(CONF_ID): cv.declare_id(class_),
cv.Required(CONF_FILE): cv.Any(validate_file_shorthand, TYPED_FILE_SCHEMA),
cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
**OPTIONS_SCHEMA,
cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE),
}
)
def validate_image_final(config: ConfigType) -> ConfigType:
"""Per-entry final validation, shared by file-backed image platforms.
For LVGL 9 the default byte order for RGB565 images is little-endian, so
fill in that default when the user did not specify a byte order and warn
when big-endian was explicitly requested.
"""
if byte_order := config.get(CONF_BYTE_ORDER):
if byte_order == "BIG_ENDIAN":
_LOGGER.warning(
"The image '%s' is configured with big-endian byte order, little-endian is expected",
config.get(CONF_FILE),
)
else:
config[CONF_BYTE_ORDER] = "LITTLE_ENDIAN"
return config
async def new_image(config: ConfigType) -> MockObj:
"""Generate a single file-backed ``image::Image`` instance.
Used by the built-in ``file`` platform; encodes the image data, registers
the C++ variable and records its metadata for other components to consume.
"""
prog_arr, width, height, image_type, trans_value, _ = await write_image(config)
var = cg.new_Pvariable(
config[CONF_ID], prog_arr, width, height, image_type, trans_value
)
add_metadata(
config[CONF_ID], width, height, config[CONF_TYPE], config[CONF_TRANSPARENCY]
)
return var
async def write_image(config, all_frames=False):
path = Path(config[CONF_FILE])
if not path.is_file():
raise core.EsphomeError(f"Could not load image file {path}")
resize = config.get(CONF_RESIZE)
try:
if is_svg_file(path):
import resvg_py
resize = resize or (None, None)
image_data = resvg_py.svg_to_bytes(
svg_path=str(path), width=resize[0], height=resize[1], dpi=100
)
# Convert bytes to Pillow Image
image = Image.open(io.BytesIO(image_data))
width, height = image.size
else:
image = Image.open(path)
width, height = image.size
if resize:
# Preserve aspect ratio
new_width_max = min(width, resize[0])
new_height_max = min(height, resize[1])
ratio = min(new_width_max / width, new_height_max / height)
width, height = int(width * ratio), int(height * ratio)
except (OSError, UnidentifiedImageError, ValueError) as exc:
raise core.EsphomeError(f"Could not read image file {path}: {exc}") from exc
if not resize and (width > 500 or height > 500):
_LOGGER.warning(
'The image "%s" you requested is very big. Please consider'
" using the resize parameter.",
path,
)
dither = (
Image.Dither.NONE
if config[CONF_DITHER] == "NONE"
else Image.Dither.FLOYDSTEINBERG
)
type = config[CONF_TYPE]
transparency = config.get(CONF_TRANSPARENCY, CONF_OPAQUE)
invert_alpha = config[CONF_INVERT_ALPHA]
frame_count = 1
if all_frames:
with contextlib.suppress(AttributeError):
frame_count = image.n_frames
if frame_count <= 1:
_LOGGER.warning("Image file %s has no animation frames", path)
# Encode each frame with its own encoder and concatenate. This keeps every
# frame self-contained on disk (e.g. RGB565+alpha emits [RGB plane | alpha plane]
# per frame) so animation frame stepping in image.cpp / animation.cpp stays
# correct without needing to know the total frame count.
byte_order = config.get(CONF_BYTE_ORDER)
combined_data: list[int] = []
encoder: ImageEncoder | None = None
for frame_index in range(frame_count):
image.seek(frame_index)
encoder = IMAGE_TYPE[type](width, height, transparency, dither, invert_alpha)
if byte_order is not None:
# Check for valid type has already been done in validate_settings
encoder.set_big_endian(byte_order == "BIG_ENDIAN")
pixels = encoder.convert(image.resize((width, height)), path).getdata()
for row in range(height):
for col in range(width):
encoder.encode(pixels[row * width + col])
encoder.end_row()
encoder.end_image()
combined_data.extend(encoder.data)
rhs = [HexInt(x) for x in combined_data]
prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs)
image_type = get_image_type_enum(type)
trans_value = get_transparency_enum(encoder.transparency)
return prog_arr, width, height, image_type, trans_value, frame_count
# The built-in static-image platform: pixels embedded at compile time from a
# local file, a downloaded web image, or a Material Design Icon.
CONFIG_SCHEMA = cv.All(image_schema(Image_), validate_settings)
FINAL_VALIDATE_SCHEMA = validate_image_final
async def to_code(config: ConfigType) -> None:
await new_image(config)
+1
View File
@@ -0,0 +1 @@
CODEOWNERS = ["@clydebarrow"]
@@ -0,0 +1,167 @@
#include "gsl3670_touchscreen.h"
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
namespace esphome::gsl3670 {
static const char *const TAG = "gsl3670.touchscreen";
static const size_t MAX_TOUCHES = 3;
// ---------------------------------------------------------------------------
// setup() mirrors esp_lcd_touch_gsl3670_init() in the Seeed BSP:
// clear_reg → reset → load_fw → startup_chip → reset → startup_chip
// ---------------------------------------------------------------------------
void GSL3670Touchscreen::setup() {
ESP_LOGCONFIG(TAG, "Setting up GSL3670 touchscreen...");
if (this->reset_pin_ != nullptr) {
this->reset_pin_->setup();
this->reset_pin_->digital_write(true);
}
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
}
if (this->x_raw_max_ == this->x_raw_min_) {
this->x_raw_max_ = this->display_->get_native_width();
}
if (this->y_raw_max_ == this->y_raw_min_) {
this->y_raw_max_ = this->display_->get_native_height();
}
this->clear_reg_();
this->reset_();
this->load_firmware_();
this->startup_chip_();
this->reset_();
this->startup_chip_();
ESP_LOGCONFIG(TAG, "GSL3670 initialised OK");
}
void GSL3670Touchscreen::dump_config() {
ESP_LOGCONFIG(TAG,
"GSL3670 Touchscreen:\n"
" X-raw-max: %d\n"
" Y-raw-max: %d\n",
this->x_raw_max_, this->y_raw_max_);
LOG_I2C_DEVICE(this);
LOG_PIN(" Reset Pin: ", this->reset_pin_);
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
ESP_LOGCONFIG(TAG, " Firmware records: %zu", this->firmware_len_);
}
// ---------------------------------------------------------------------------
// update_touches() mirrors esp_lcd_touch_gsl3670_read_data() in Seeed BSP
// ---------------------------------------------------------------------------
void GSL3670Touchscreen::update_touches() {
uint8_t buf[44] = {};
auto err = this->read_register(0x80, buf, sizeof(buf));
if (err != i2c::ERROR_OK) {
ESP_LOGW(TAG, "I2C read failed (%d)", err);
return;
}
uint8_t finger_num = clamp_at_most(buf[0], MAX_TOUCHES);
// Build gsl_touch_info exactly as the Seeed driver does
for (uint8_t j = 0; j != finger_num; j++) {
// buf[(j+1)*4 + 0..3]: byte0=y_lo, byte1=y_hi, byte2=x_lo, byte3=id|x_hi
auto x = (uint16_t) (((buf[(j + 1) * 4 + 3] & 0x0f) << 8) | buf[(j + 1) * 4 + 2]);
auto y = (uint16_t) ((buf[(j + 1) * 4 + 1] << 8) | buf[(j + 1) * 4 + 0]);
auto id = (buf[(j + 1) * 4 + 3] >> 4) & 0x0f;
ESP_LOGV(TAG, "Touch id=%u, x=%u y=%u", id, x, y);
if (x <= 8192 && y <= 8192)
this->add_raw_touch_position_(id, x, y);
}
}
// ---------------------------------------------------------------------------
// clear_reg_() mirrors esp_lcd_touch_gsl3670_clear_reg()
// GPIO reset → write 0x01 to 0x88 → write 0x04 to 0xe4 → write 0x00 to 0xe0
// ---------------------------------------------------------------------------
void GSL3670Touchscreen::clear_reg_() {
ESP_LOGD(TAG, "clear_reg");
// GPIO reset pulse
if (this->reset_pin_ != nullptr) {
this->reset_pin_->digital_write(false);
delay(1);
this->reset_pin_->digital_write(true);
delay(5);
}
this->write_reg8_(0x88, 0x01);
// delay(5);
this->write_reg8_(0xe4, 0x04);
// delay(5);
this->write_reg8_(0xe0, 0x00);
// delay(5);
}
// ---------------------------------------------------------------------------
// reset_() mirrors touch_gsl3670_reset()
// GPIO reset → write 0x04 to 0xe4 → write 4×0x00 to 0xbc
// ---------------------------------------------------------------------------
void GSL3670Touchscreen::reset_() {
ESP_LOGD(TAG, "reset");
if (this->reset_pin_ != nullptr) {
this->reset_pin_->digital_write(false);
delay(1);
this->reset_pin_->digital_write(true);
delay(5);
}
this->write_reg8_(0xe4, 0x04);
uint8_t zeros[4] = {0, 0, 0, 0};
this->write_reg_(0xbc, zeros, 4);
}
void GSL3670Touchscreen::load_firmware_() {
if (firmware_ == nullptr || firmware_len_ == 0) {
ESP_LOGW(TAG, "No firmware supplied skipping");
return;
}
ESP_LOGD(TAG, "Loading firmware (%zu blocks)...", firmware_len_);
static constexpr size_t FW_BLK_SIZE = 128 + 4;
for (size_t i = 0; i != this->firmware_len_; i++) {
auto offset = i * FW_BLK_SIZE;
uint8_t val = this->firmware_[offset + 0];
ESP_LOGV(TAG, "Firmware address 0x%02X", val);
this->write_reg_(0xf0, &val, 1);
this->write_reg_(0, this->firmware_ + offset + 4, 128);
}
ESP_LOGD(TAG, "Firmware load complete");
}
// ---------------------------------------------------------------------------
// startup_chip_() mirrors esp_lcd_touch_gsl3670_startup_chip()
// write 0x00 to 0xe0
// ---------------------------------------------------------------------------
void GSL3670Touchscreen::startup_chip_() {
ESP_LOGD(TAG, "startup_chip");
this->write_reg8_(0xe0, 0x00);
delay(5);
}
// ---------------------------------------------------------------------------
// I2C helpers
// ---------------------------------------------------------------------------
bool GSL3670Touchscreen::write_reg_(uint8_t reg, const uint8_t *data, size_t len) {
auto err = this->write_register(reg, data, len);
if (err != i2c::ERROR_OK) {
ESP_LOGW(TAG, "I2C write reg 0x%02X len %zu failed (%d)", reg, len, err);
return false;
}
return true;
}
bool GSL3670Touchscreen::write_reg8_(uint8_t reg, uint8_t val) { return write_reg_(reg, &val, 1); }
} // namespace esphome::gsl3670
@@ -0,0 +1,50 @@
#pragma once
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/touchscreen/touchscreen.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome::gsl3670 {
// ---------------------------------------------------------------------------
// GSL3670 touchscreen ESPHome component
// ---------------------------------------------------------------------------
class GSL3670Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice {
public:
/// Supply the firmware table (generated by codegen from the YAML)
void set_firmware(const uint8_t *fw, size_t len) {
this->firmware_ = fw;
this->firmware_len_ = len;
}
void set_interrupt_pin(InternalGPIOPin *pin) { interrupt_pin_ = pin; }
void set_reset_pin(GPIOPin *pin) { reset_pin_ = pin; }
// touchscreen::Touchscreen / Component interface
void setup() override;
void dump_config() override;
protected:
void update_touches() override;
private:
// ---------- init steps (mirrors esp_lcd_touch_gsl3670_init) ----------
void clear_reg_(); // GPIO reset + 0x88/0xe4/0xe0 sequence
void reset_(); // GPIO reset + 0xe4/0xbc sequence
void load_firmware_(); // write GSLX670_FW table
void startup_chip_(); // 0x00→0xe0 + gsl_DataInit
// ---------- I2C helpers ----------
bool write_reg_(uint8_t reg, const uint8_t *data, size_t len);
bool write_reg8_(uint8_t reg, uint8_t val);
InternalGPIOPin *interrupt_pin_{nullptr};
GPIOPin *reset_pin_{nullptr};
const uint8_t *firmware_{nullptr};
size_t firmware_len_{0};
};
} // namespace esphome::gsl3670
+209
View File
@@ -0,0 +1,209 @@
"""ESPHome codegen for the gsl3670 touchscreen sub-platform."""
import hashlib
import logging
from pathlib import Path
from esphome import external_files, pins
import esphome.codegen as cg
from esphome.components import i2c, touchscreen
from esphome.components.const import CONF_SHA256
from esphome.components.touchscreen import (
CONF_X_MAX,
CONF_X_MIN,
CONF_Y_MAX,
CONF_Y_MIN,
option_with_default,
touchscreen_schema,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_FILE,
CONF_ID,
CONF_INTERRUPT_PIN,
CONF_MIRROR_X,
CONF_MIRROR_Y,
CONF_MODEL,
CONF_RESET_PIN,
CONF_SWAP_XY,
CONF_URL,
)
from esphome.core import ID
DEPENDENCIES = ["i2c"]
AUTO_LOAD = ["touchscreen"]
LOGGER = logging.getLogger(__name__)
DOMAIN = "gsl3670"
gsl3670_ns = cg.esphome_ns.namespace("gsl3670")
GSL3670Touchscreen = gsl3670_ns.class_(
"GSL3670Touchscreen",
touchscreen.Touchscreen,
i2c.I2CDevice,
)
CONF_FIRMWARE = "firmware"
# Firmware blobs are published as release assets of the companion repository
# rather than vendored into the ESPHome source tree. The default URL/SHA-256
# for each model point at a pinned release artifact; users may override them
# (or supply a local file via `firmware: { file: ... }`).
FIRMWARE_RELEASE = "v1.0.0"
FIRMWARE_BASE_URL = f"https://github.com/esphome-libs/gsl3670-firmware/releases/download/{FIRMWARE_RELEASE}"
MODELS = {
"SEEED-RETERMINAL-D1001": {
CONF_SWAP_XY: True,
CONF_MIRROR_X: True,
CONF_MIRROR_Y: True,
CONF_X_MIN: 20,
CONF_Y_MIN: 20,
CONF_X_MAX: 872,
CONF_Y_MAX: 1644,
CONF_RESET_PIN: {"xl9535": None, "number": 14},
CONF_INTERRUPT_PIN: 16,
CONF_FIRMWARE: {
CONF_URL: f"{FIRMWARE_BASE_URL}/seeed-d1001-fw.bin",
CONF_SHA256: "2e50501ad83656fb6fa3d92591f9f31add4d442c8e8a79f29f5c4d335bd127a4",
},
},
"CUSTOM": {},
}
_FW_BLK_SIZE = 128 + 4
def _validate_firmware_data(data: bytes, source: str) -> None:
"""Validate the structure of a decoded GSL3670 firmware blob."""
blk_cnt = len(data) // _FW_BLK_SIZE
if blk_cnt == 0 or blk_cnt * _FW_BLK_SIZE != len(data):
raise cv.Invalid(f"Firmware file length is incorrect: {source}")
for i in range(0, len(data), _FW_BLK_SIZE):
if data[i] > 0xEF or data[i + 1] != 1 or data[i + 2] != 2 or data[i + 3] != 3:
raise cv.Invalid(
f"Corrupted firmware at block {i // _FW_BLK_SIZE} in: {source}"
)
def _cache_path(url: str) -> Path:
"""Cache path for a downloaded firmware blob, keyed by URL."""
key = hashlib.sha256(url.encode()).hexdigest()[:8]
return external_files.compute_local_file_dir(DOMAIN) / key
def firmware_path(firmware: dict) -> Path:
"""Return the path the firmware bytes will be read from at codegen time."""
if path := firmware.get(CONF_FILE):
return path
return _cache_path(firmware[CONF_URL])
def _validate_firmware(firmware: dict) -> dict:
"""Require a single source, download (with caching), verify and validate."""
if (CONF_FILE in firmware) == (CONF_URL in firmware):
raise cv.Invalid(
f"Exactly one of '{CONF_URL}' or '{CONF_FILE}' must be provided"
)
if path := firmware.get(CONF_FILE):
_validate_firmware_data(path.read_bytes(), str(path.absolute()))
return firmware
url = firmware[CONF_URL]
data = external_files.download_content(url, _cache_path(url))
if expected := firmware.get(CONF_SHA256):
actual = hashlib.sha256(data).hexdigest()
if actual.lower() != expected.lower():
raise cv.Invalid(
f"Firmware SHA-256 mismatch for {url}: "
f"expected {expected.lower()}, got {actual}",
[CONF_SHA256],
)
else:
LOGGER.warning(
"No SHA256 provided for gsl3670 firmware - firmware integrity can not be checked"
)
_validate_firmware_data(data, url)
return firmware
FIRMWARE_SCHEMA = cv.All(
cv.Schema(
{
cv.Optional(CONF_URL): cv.url,
cv.Optional(CONF_SHA256): cv.string_strict,
cv.Optional(CONF_FILE): cv.file_,
}
),
_validate_firmware,
)
def _config_schema(config):
model_option = {
cv.Optional(CONF_MODEL, default="CUSTOM"): cv.one_of(*MODELS, upper=True)
}
config = cv.Schema(model_option, extra=True)(config)
defaults = MODELS[config[CONF_MODEL]]
schema = (
touchscreen_schema(cv.UNDEFINED, False, defaults)
.extend(
{
cv.GenerateID(): cv.declare_id(GSL3670Touchscreen),
option_with_default(
CONF_INTERRUPT_PIN, defaults
): pins.internal_gpio_input_pin_schema,
option_with_default(
CONF_RESET_PIN, defaults
): pins.gpio_output_pin_schema,
**model_option,
option_with_default(
CONF_FIRMWARE, defaults, required=True
): FIRMWARE_SCHEMA,
}
)
.extend(i2c.i2c_device_schema(0x40))
.extend(cv.COMPONENT_SCHEMA)
)
return schema(config)
CONFIG_SCHEMA = _config_schema
def _read_firmware(config) -> bytes:
path = firmware_path(config[CONF_FIRMWARE])
data = path.read_bytes()
LOGGER.info(
"Read gsl3670 touchscreen firmware file %s: %d bytes, %d blocks",
path.absolute(),
len(data),
len(data) // _FW_BLK_SIZE,
)
return data
# ---------------------------------------------------------------------------
# Code generation
# ---------------------------------------------------------------------------
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await touchscreen.register_touchscreen(var, config)
await i2c.register_i2c_device(var, config)
if CONF_INTERRUPT_PIN in config:
pin = await cg.gpio_pin_expression(config[CONF_INTERRUPT_PIN])
cg.add(var.set_interrupt_pin(pin))
if CONF_RESET_PIN in config:
pin = await cg.gpio_pin_expression(config[CONF_RESET_PIN])
cg.add(var.set_reset_pin(pin))
# Firmware table
data = _read_firmware(config)
fw_array = cg.progmem_array(
ID(config[CONF_ID].id + "_fw", type=cg.uint8), list(data)
)
cg.add(var.set_firmware(fw_array, len(data) // _FW_BLK_SIZE))
+2 -2
View File
@@ -132,7 +132,7 @@ void HaierClimateBase::save_settings() {
}
bool HaierClimateBase::get_display_state() const {
return (this->display_status_ == SwitchState::ON) || (this->display_status_ == SwitchState::PENDING_ON);
return (this->display_status_ == SwitchState::SWITCH_ON) || (this->display_status_ == SwitchState::PENDING_ON);
}
void HaierClimateBase::set_display_state(bool state) {
@@ -144,7 +144,7 @@ void HaierClimateBase::set_display_state(bool state) {
}
bool HaierClimateBase::get_health_mode() const {
return (this->health_mode_ == SwitchState::ON) || (this->health_mode_ == SwitchState::PENDING_ON);
return (this->health_mode_ == SwitchState::SWITCH_ON) || (this->health_mode_ == SwitchState::PENDING_ON);
}
void HaierClimateBase::set_health_mode(bool state) {
+4 -4
View File
@@ -147,8 +147,8 @@ class HaierClimateBase : public esphome::Component,
esphome::optional<haier_protocol::HaierMessage> message;
};
enum class SwitchState {
OFF = 0b00,
ON = 0b01,
SWITCH_OFF = 0b00,
SWITCH_ON = 0b01,
PENDING_OFF = 0b10,
PENDING_ON = 0b11,
};
@@ -157,8 +157,8 @@ class HaierClimateBase : public esphome::Component,
esphome::optional<PendingAction> action_request_;
uint8_t fan_mode_speed_;
uint8_t other_modes_fan_speed_;
SwitchState display_status_{SwitchState::ON};
SwitchState health_mode_{SwitchState::OFF};
SwitchState display_status_{SwitchState::SWITCH_ON};
SwitchState health_mode_{SwitchState::SWITCH_OFF};
bool force_send_control_;
bool forced_request_status_;
bool reset_protocol_request_;
+7 -7
View File
@@ -50,7 +50,7 @@ void HonClimate::set_quiet_mode_state(bool state) {
this->quiet_mode_state_ = state ? SwitchState::PENDING_ON : SwitchState::PENDING_OFF;
this->force_send_control_ = true;
} else {
this->quiet_mode_state_ = state ? SwitchState::ON : SwitchState::OFF;
this->quiet_mode_state_ = state ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF;
}
this->settings_.quiet_mode_state = state;
#ifdef USE_SWITCH
@@ -63,7 +63,7 @@ void HonClimate::set_quiet_mode_state(bool state) {
}
bool HonClimate::get_quiet_mode_state() const {
return (this->quiet_mode_state_ == SwitchState::ON) || (this->quiet_mode_state_ == SwitchState::PENDING_ON);
return (this->quiet_mode_state_ == SwitchState::SWITCH_ON) || (this->quiet_mode_state_ == SwitchState::PENDING_ON);
}
esphome::optional<hon_protocol::VerticalSwingMode> HonClimate::get_vertical_airflow() const {
@@ -513,7 +513,7 @@ void HonClimate::initialization() {
}
this->current_vertical_swing_ = this->settings_.last_vertiacal_swing;
this->current_horizontal_swing_ = this->settings_.last_horizontal_swing;
this->quiet_mode_state_ = this->settings_.quiet_mode_state ? SwitchState::ON : SwitchState::OFF;
this->quiet_mode_state_ = this->settings_.quiet_mode_state ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF;
}
haier_protocol::HaierMessage HonClimate::get_control_message() {
@@ -825,7 +825,7 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t *
#ifdef USE_SENSOR
this->update_sub_sensor_(SubSensorType::INDOOR_COIL_TEMPERATURE, bd_packet->indoor_coil_temperature / 2.0 - 20);
this->update_sub_sensor_(SubSensorType::OUTDOOR_COIL_TEMPERATURE, bd_packet->outdoor_coil_temperature - 64);
this->update_sub_sensor_(SubSensorType::OUTDOOR_DEFROST_TEMPERATURE, bd_packet->outdoor_coil_temperature - 64);
this->update_sub_sensor_(SubSensorType::OUTDOOR_DEFROST_TEMPERATURE, bd_packet->outdoor_defrost_temperature - 64);
this->update_sub_sensor_(SubSensorType::OUTDOOR_IN_AIR_TEMPERATURE, bd_packet->outdoor_in_air_temperature - 64);
this->update_sub_sensor_(SubSensorType::OUTDOOR_OUT_AIR_TEMPERATURE, bd_packet->outdoor_out_air_temperature - 64);
this->update_sub_sensor_(SubSensorType::POWER, encode_uint16(bd_packet->power[0], bd_packet->power[1]));
@@ -939,14 +939,14 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t *
// AC just turned on from remote need to turn off display
this->force_send_control_ = true;
} else if ((((uint8_t) this->display_status_) & 0b10) == 0) {
this->display_status_ = disp_status ? SwitchState::ON : SwitchState::OFF;
this->display_status_ = disp_status ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF;
}
}
}
// Health mode
if ((((uint8_t) this->health_mode_) & 0b10) == 0) {
bool old_health_mode = this->get_health_mode();
this->health_mode_ = packet.control.health_mode == 1 ? SwitchState::ON : SwitchState::OFF;
this->health_mode_ = packet.control.health_mode == 1 ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF;
should_publish = should_publish || (old_health_mode != this->get_health_mode());
}
{
@@ -1008,7 +1008,7 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t *
// In proper mode and not in pending state
bool new_quiet_mode = packet.control.quiet_mode != 0;
if (new_quiet_mode != this->get_quiet_mode_state()) {
this->quiet_mode_state_ = new_quiet_mode ? SwitchState::ON : SwitchState::OFF;
this->quiet_mode_state_ = new_quiet_mode ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF;
this->settings_.quiet_mode_state = new_quiet_mode;
#ifdef USE_SWITCH
if (this->quiet_mode_switch_ != nullptr) {
+1 -1
View File
@@ -197,7 +197,7 @@ class HonClimate final : public HaierClimateBase {
esphome::optional<hon_protocol::HorizontalSwingMode> current_horizontal_swing_{};
HonSettings settings_{};
ESPPreferenceObject hon_rtc_;
SwitchState quiet_mode_state_{SwitchState::OFF};
SwitchState quiet_mode_state_{SwitchState::SWITCH_OFF};
};
} // namespace esphome::haier
@@ -464,14 +464,14 @@ haier_protocol::HandlerError Smartair2Climate::process_status_message_(const uin
// AC just turned on from remote need to turn off display
this->force_send_control_ = true;
} else if ((((uint8_t) this->health_mode_) & 0b10) == 0) {
this->display_status_ = disp_status ? SwitchState::ON : SwitchState::OFF;
this->display_status_ = disp_status ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF;
}
}
}
// Health mode
if ((((uint8_t) this->health_mode_) & 0b10) == 0) {
bool old_health_mode = this->get_health_mode();
this->health_mode_ = packet.control.health_mode == 1 ? SwitchState::ON : SwitchState::OFF;
this->health_mode_ = packet.control.health_mode == 1 ? SwitchState::SWITCH_ON : SwitchState::SWITCH_OFF;
should_publish = should_publish || (old_health_mode != this->get_health_mode());
}
{
+1 -1
View File
@@ -242,7 +242,7 @@ void HlkFm22xComponent::handle_reply_(const uint8_t *data, size_t length) {
return;
}
if (data[1] != HlkFm22xResult::SUCCESS) {
if (data[1] != HlkFm22xResult::SUCCEEDED) {
ESP_LOGE(TAG, "Command <0x%.2X> failed. Error: 0x%.2X", data[0], data[1]);
switch (expected) {
case HlkFm22xCommand::ENROLL:
+1 -1
View File
@@ -41,7 +41,7 @@ enum HlkFm22xNoteType {
};
enum HlkFm22xResult {
SUCCESS = 0x00,
SUCCEEDED = 0x00,
REJECTED = 0x01,
ABORTED = 0x02,
FAILED4_CAMERA = 0x04,

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