mirror of
https://github.com/esphome/esphome.git
synced 2026-08-23 06:36:23 +00:00
Compare commits
97
Commits
2026.7.0b1
...
2026.7.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b832e135cb | ||
|
|
66615a24a7 | ||
|
|
52ace9c448 | ||
|
|
e1cedaba87 | ||
|
|
f7bf4e9727 | ||
|
|
e46bdf9e18 | ||
|
|
a08070a836 | ||
|
|
18f93c0e41 | ||
|
|
bbc5669efa | ||
|
|
718b04c15a | ||
|
|
24a8634a4b | ||
|
|
fc6664d737 | ||
|
|
388e411469 | ||
|
|
a8ebdcb8f2 | ||
|
|
f5f1c48f51 | ||
|
|
7c55de311f | ||
|
|
307faa6c38 | ||
|
|
5f2adcf9b3 | ||
|
|
7738464f0b | ||
|
|
5a86e26f68 | ||
|
|
786b47d8c2 | ||
|
|
83092ea05c | ||
|
|
629afd38f6 | ||
|
|
3ffc3a9610 | ||
|
|
9de7bd7461 | ||
|
|
5df922e0df | ||
|
|
b571d2a5ab | ||
|
|
231a2897c0 | ||
|
|
2223b14794 | ||
|
|
3d340f4d90 | ||
|
|
2f99466f3a | ||
|
|
f05e15522c | ||
|
|
f37dad683b | ||
|
|
a0fb14bf54 | ||
|
|
d4443be0c1 | ||
|
|
0dfb573e18 | ||
|
|
572fc033cd | ||
|
|
4062f0a323 | ||
|
|
cd40fb1c68 | ||
|
|
7afe7750cd | ||
|
|
e08bdf8cca | ||
|
|
b3172ecae8 | ||
|
|
2b4d9c0af9 | ||
|
|
3a10d2c187 | ||
|
|
05e2c6b133 | ||
|
|
2797349c75 | ||
|
|
95a01ac2ab | ||
|
|
ea01c909b7 | ||
|
|
d3655597ea | ||
|
|
920a8b761b | ||
|
|
5d2372937e | ||
|
|
6c401d406f | ||
|
|
78180a40c4 | ||
|
|
63fae2c36a | ||
|
|
878d8a2f6a | ||
|
|
e056e99fd4 | ||
|
|
3b915cf16b | ||
|
|
d1150d148d | ||
|
|
b5b426492a | ||
|
|
724df7b11e | ||
|
|
26db22546d | ||
|
|
00d2d55e09 | ||
|
|
a833685a73 | ||
|
|
0a1065da75 | ||
|
|
6fadf353b1 | ||
|
|
8da377ab43 | ||
|
|
1da8900ffc | ||
|
|
519ce38b79 | ||
|
|
989797be53 | ||
|
|
583adc9e69 | ||
|
|
af9a0404d9 | ||
|
|
f38e7f2de2 | ||
|
|
3e75020007 | ||
|
|
feb53a209b | ||
|
|
c607f64288 | ||
|
|
7ee7a26cad | ||
|
|
2a67e5c599 | ||
|
|
a9591d7aac | ||
|
|
bcac3ebe2b | ||
|
|
27b598c5aa | ||
|
|
9ba2cbbfdd | ||
|
|
a4650a2345 | ||
|
|
1e5cfe6b0f | ||
|
|
665e788cc9 | ||
|
|
d89b4c0b59 | ||
|
|
5afe418a8e | ||
|
|
312f6f2049 | ||
|
|
eb0848d538 | ||
|
|
1a573919d1 | ||
|
|
692cf7abd1 | ||
|
|
f0afd9e660 | ||
|
|
262ee421f6 | ||
|
|
050a006459 | ||
|
|
8518d0633b | ||
|
|
284fe85271 | ||
|
|
020a6a8fd1 | ||
|
|
196b979df8 |
@@ -35,6 +35,9 @@ runs:
|
||||
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.
|
||||
|
||||
@@ -32,6 +32,9 @@ jobs:
|
||||
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.
|
||||
|
||||
@@ -52,6 +52,9 @@ jobs:
|
||||
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.
|
||||
@@ -174,6 +177,9 @@ jobs:
|
||||
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.
|
||||
@@ -375,6 +381,9 @@ jobs:
|
||||
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.
|
||||
@@ -828,11 +837,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 +1016,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 +1061,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()
|
||||
|
||||
|
||||
@@ -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.0b1
|
||||
PROJECT_NUMBER = 2026.7.2
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.3.1
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.6.9
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
+17
-2
@@ -776,6 +776,13 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
|
||||
check_placeholder_credentials(config)
|
||||
|
||||
# Keep this here, NOT in codegen: config-hash and --only-generate must keep
|
||||
# working on machines that cannot run the toolchain.
|
||||
if CORE.is_esp8266:
|
||||
from esphome.components.esp8266 import check_rosetta
|
||||
|
||||
check_rosetta()
|
||||
|
||||
# NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py
|
||||
# If you change this format, update the regex in that script as well
|
||||
_LOGGER.info("Compiling app... Build path: %s", CORE.build_path)
|
||||
@@ -1510,10 +1517,18 @@ def _redact_with_legacy_fallback(output: str) -> str:
|
||||
m = _LEGACY_REDACTION_RE.search(line)
|
||||
if m is None:
|
||||
continue
|
||||
key = m.group("key")
|
||||
if not in_substitutions:
|
||||
unmarked.add(m.group("key"))
|
||||
# Public keys (e.g. wireguard's peer_public_key) are not secret;
|
||||
# redacting them and telling maintainers to mark them cv.sensitive
|
||||
# would be wrong on both counts. Substitution keys are user-named
|
||||
# with no schema behind them, so anything secret-shaped there
|
||||
# (public or not) stays conservatively redacted.
|
||||
if "public" in key.split("_"):
|
||||
continue
|
||||
unmarked.add(key)
|
||||
lines[i] = (
|
||||
f"{line[: m.start()]}{m.group('key')}: "
|
||||
f"{line[: m.start()]}{key}: "
|
||||
f"\\033[8m{m.group('val')}\\033[28m{line[m.end() :]}"
|
||||
)
|
||||
output = "\n".join(lines)
|
||||
|
||||
+135
-3
@@ -7,12 +7,12 @@ 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
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
|
||||
import re
|
||||
import shutil
|
||||
import tarfile
|
||||
@@ -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
|
||||
@@ -49,6 +51,7 @@ class ManifestKey(StrEnum):
|
||||
MANIFEST_VERSION = "manifest_version"
|
||||
ESPHOME_VERSION = "esphome_version"
|
||||
CONFIG_FILENAME = "config_filename"
|
||||
CONFIG_DIR = "config_dir"
|
||||
FILES = "files"
|
||||
HAS_SECRETS = "has_secrets"
|
||||
|
||||
@@ -120,6 +123,126 @@ 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)
|
||||
# Original config dir parsed from an extracted bundle's manifest.json,
|
||||
# kept in the path flavor of the machine the bundle was created on.
|
||||
# The checked flag makes the manifest lookup happen at most once per run;
|
||||
# CORE.data is cleared between runs.
|
||||
original_config_dir: PurePath | None = None
|
||||
original_config_dir_checked: bool = False
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
# Windows paths start with a drive letter or contain backslashes; POSIX
|
||||
# paths do neither in practice, so this is how the flavor of a recorded
|
||||
# path string is recognized on any host.
|
||||
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:")
|
||||
|
||||
|
||||
def _path_flavor(value: str) -> type[PurePath]:
|
||||
"""Pick the pure path class matching the flavor ``value`` was written in."""
|
||||
if "\\" in value or _WINDOWS_DRIVE_RE.match(value):
|
||||
return PureWindowsPath
|
||||
return PurePosixPath
|
||||
|
||||
|
||||
def _load_original_config_dir() -> PurePath | None:
|
||||
"""Read the original config dir from an extracted bundle's manifest.
|
||||
|
||||
Returns None when the current config dir is not an extracted bundle or
|
||||
the manifest does not record the original config dir.
|
||||
"""
|
||||
manifest_path = CORE.config_dir / MANIFEST_FILENAME
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
# The common case: this config dir is not an extracted bundle.
|
||||
return None
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as err:
|
||||
# A manifest.json is present but unreadable or malformed. Say so
|
||||
# instead of letting it look identical to "not a bundle".
|
||||
_LOGGER.warning("Bundle: ignoring unreadable %s: %s", manifest_path, err)
|
||||
return None
|
||||
if not isinstance(manifest, dict):
|
||||
return None
|
||||
# A manifest.json in the config dir does not have to be ours. Only trust
|
||||
# one that looks like a bundle manifest for exactly this config file.
|
||||
version = manifest.get(ManifestKey.MANIFEST_VERSION)
|
||||
if not isinstance(version, int) or version < 1:
|
||||
return None
|
||||
if manifest.get(ManifestKey.CONFIG_FILENAME) != CORE.config_path.name:
|
||||
return None
|
||||
config_dir = manifest.get(ManifestKey.CONFIG_DIR)
|
||||
if not isinstance(config_dir, str) or not config_dir:
|
||||
return None
|
||||
return _path_flavor(config_dir)(config_dir)
|
||||
|
||||
|
||||
def remap_bundle_path(value: str) -> Path | None:
|
||||
"""Remap an absolute path from the machine a bundle was created on.
|
||||
|
||||
A bundled config may reference files by absolute path. The referenced
|
||||
files ship inside the bundle at their config-relative locations, but the
|
||||
YAML text is copied verbatim, so after extraction on another machine the
|
||||
absolute reference points at a path that only existed on the creating
|
||||
machine. The bundle manifest records that machine's config dir; when
|
||||
``value`` names a path that lived under it, return the corresponding
|
||||
file next to the extracted config.
|
||||
|
||||
``value`` is the raw path string from the config. It is parsed with the
|
||||
original machine's path flavor, so a bundle created on Windows remaps on
|
||||
a POSIX build server and vice versa.
|
||||
|
||||
Returns None when not compiling an extracted bundle, when ``value`` was
|
||||
not under the original config dir, or when the bundle does not contain
|
||||
the file.
|
||||
"""
|
||||
data = _get_data()
|
||||
if not data.original_config_dir_checked:
|
||||
data.original_config_dir_checked = True
|
||||
data.original_config_dir = _load_original_config_dir()
|
||||
original_dir = data.original_config_dir
|
||||
if original_dir is None:
|
||||
return None
|
||||
path = type(original_dir)(value)
|
||||
if not path.is_absolute():
|
||||
return None
|
||||
try:
|
||||
rel = path.relative_to(original_dir)
|
||||
except ValueError:
|
||||
return None
|
||||
# relative_to is lexical, so ".." segments survive it. Refuse them: the
|
||||
# remapped file must land strictly inside the extracted config tree.
|
||||
if ".." in rel.parts:
|
||||
return None
|
||||
remapped = CORE.relative_config_path(Path(*rel.parts))
|
||||
if not remapped.exists():
|
||||
return None
|
||||
return remapped
|
||||
|
||||
|
||||
@dataclass
|
||||
class BundleFile:
|
||||
"""A file to include in the bundle."""
|
||||
@@ -146,6 +269,7 @@ class BundleManifest:
|
||||
config_filename: str
|
||||
files: list[str]
|
||||
has_secrets: bool
|
||||
config_dir: str | None = None
|
||||
|
||||
|
||||
class ConfigBundleCreator:
|
||||
@@ -286,13 +410,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
|
||||
@@ -405,6 +534,7 @@ class ConfigBundleCreator:
|
||||
ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION,
|
||||
ManifestKey.ESPHOME_VERSION: const.__version__,
|
||||
ManifestKey.CONFIG_FILENAME: self._config_path.name,
|
||||
ManifestKey.CONFIG_DIR: str(self._config_dir),
|
||||
ManifestKey.FILES: [f.path for f in files],
|
||||
ManifestKey.HAS_SECRETS: has_secrets,
|
||||
}
|
||||
@@ -489,12 +619,14 @@ def read_bundle_manifest(bundle_path: Path) -> BundleManifest:
|
||||
except tarfile.TarError as err:
|
||||
raise EsphomeError(f"Failed to read bundle: {err}") from err
|
||||
|
||||
config_dir = manifest.get(ManifestKey.CONFIG_DIR)
|
||||
return BundleManifest(
|
||||
manifest_version=manifest[ManifestKey.MANIFEST_VERSION],
|
||||
esphome_version=manifest.get(ManifestKey.ESPHOME_VERSION, "unknown"),
|
||||
config_filename=manifest[ManifestKey.CONFIG_FILENAME],
|
||||
files=manifest.get(ManifestKey.FILES, []),
|
||||
has_secrets=manifest.get(ManifestKey.HAS_SECRETS, False),
|
||||
config_dir=config_dir if isinstance(config_dir, str) else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -488,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")
|
||||
|
||||
@@ -310,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 {
|
||||
|
||||
@@ -198,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:
|
||||
@@ -256,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 {
|
||||
@@ -1351,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) {
|
||||
@@ -1860,6 +1892,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;
|
||||
@@ -2037,10 +2075,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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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_();
|
||||
|
||||
@@ -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_; }
|
||||
|
||||
@@ -170,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;
|
||||
}
|
||||
@@ -232,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;
|
||||
}
|
||||
|
||||
@@ -533,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
|
||||
@@ -588,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;
|
||||
|
||||
@@ -982,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();
|
||||
}
|
||||
|
||||
@@ -426,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
|
||||
|
||||
@@ -152,6 +152,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()
|
||||
|
||||
@@ -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(®, 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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({}),
|
||||
|
||||
@@ -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({}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -814,14 +814,15 @@ def _is_framework_url(source: str) -> bool:
|
||||
# The default/recommended arduino framework version
|
||||
# - https://github.com/espressif/arduino-esp32/releases
|
||||
ARDUINO_FRAMEWORK_VERSION_LOOKUP = {
|
||||
"recommended": cv.Version(3, 3, 9),
|
||||
"latest": cv.Version(3, 3, 9),
|
||||
"dev": cv.Version(3, 3, 9),
|
||||
"recommended": cv.Version(3, 3, 10),
|
||||
"latest": cv.Version(3, 3, 10),
|
||||
"dev": cv.Version(3, 3, 10),
|
||||
}
|
||||
ARDUINO_PLATFORM_VERSION_LOOKUP = {
|
||||
cv.Version(
|
||||
4, 0, 0, "alpha1"
|
||||
): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6",
|
||||
cv.Version(3, 3, 10): cv.Version(55, 3, 39),
|
||||
cv.Version(3, 3, 9): cv.Version(55, 3, 39),
|
||||
cv.Version(3, 3, 8): cv.Version(55, 3, 38, "1"),
|
||||
cv.Version(3, 3, 7): cv.Version(55, 3, 37),
|
||||
@@ -844,6 +845,7 @@ ARDUINO_PLATFORM_VERSION_LOOKUP = {
|
||||
# See: https://github.com/pioarduino/esp-idf/releases
|
||||
ARDUINO_IDF_VERSION_LOOKUP = {
|
||||
cv.Version(4, 0, 0, "alpha1"): cv.Version(6, 0, 1),
|
||||
cv.Version(3, 3, 10): cv.Version(5, 5, 5),
|
||||
cv.Version(3, 3, 9): cv.Version(5, 5, 4),
|
||||
cv.Version(3, 3, 8): cv.Version(5, 5, 4),
|
||||
cv.Version(3, 3, 7): cv.Version(5, 5, 3, "1"),
|
||||
@@ -865,9 +867,9 @@ ARDUINO_IDF_VERSION_LOOKUP = {
|
||||
# The default/recommended esp-idf framework version
|
||||
# - https://github.com/espressif/esp-idf/releases
|
||||
ESP_IDF_FRAMEWORK_VERSION_LOOKUP = {
|
||||
"recommended": cv.Version(5, 5, 4),
|
||||
"latest": cv.Version(5, 5, 4),
|
||||
"dev": cv.Version(5, 5, 4),
|
||||
"recommended": cv.Version(5, 5, 5),
|
||||
"latest": cv.Version(5, 5, 5),
|
||||
"dev": cv.Version(5, 5, 5),
|
||||
}
|
||||
|
||||
ESP_IDF_PLATFORM_VERSION_LOOKUP = {
|
||||
@@ -877,6 +879,7 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = {
|
||||
cv.Version(
|
||||
6, 0, 0
|
||||
): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6",
|
||||
cv.Version(5, 5, 5): cv.Version(55, 3, 39),
|
||||
cv.Version(5, 5, 4): cv.Version(55, 3, 39),
|
||||
cv.Version(5, 5, 3, "1"): cv.Version(55, 3, 37),
|
||||
cv.Version(5, 5, 3): cv.Version(55, 3, 37),
|
||||
@@ -1160,6 +1163,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
|
||||
@@ -1361,7 +1432,7 @@ 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."
|
||||
)
|
||||
@@ -1640,18 +1711,9 @@ 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
|
||||
@@ -2498,12 +2560,16 @@ 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")
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
@@ -20,9 +21,15 @@ from esphome.const import (
|
||||
PLATFORM_ESP8266,
|
||||
ThreadModel,
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority
|
||||
from esphome.core import (
|
||||
CORE,
|
||||
CoroPriority,
|
||||
EsphomeError,
|
||||
Lambda,
|
||||
coroutine_with_priority,
|
||||
)
|
||||
from esphome.core.config import BOARD_MAX_LENGTH
|
||||
from esphome.helpers import copy_file_if_changed
|
||||
from esphome.helpers import IS_MACOS, copy_file_if_changed
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .boards import BOARDS, ESP8266_LD_SCRIPTS
|
||||
@@ -237,6 +244,32 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
def check_rosetta() -> None:
|
||||
"""Fail fast when the x86_64 ESP8266 toolchain cannot run on this Mac.
|
||||
|
||||
There is no native arm64 build of the xtensa-lx106 toolchain; on Apple
|
||||
Silicon it runs under Rosetta 2, which macOS updates can remove.
|
||||
"""
|
||||
if not IS_MACOS or platform.machine() != "arm64":
|
||||
return
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["/usr/bin/arch", "-x86_64", "/usr/bin/true"],
|
||||
capture_output=True,
|
||||
close_fds=False,
|
||||
check=False,
|
||||
)
|
||||
except OSError:
|
||||
return # arch(1) unavailable; let the build proceed
|
||||
if result.returncode != 0:
|
||||
raise EsphomeError(
|
||||
"ESP8266 builds on Apple Silicon Macs use an Intel (x86_64) "
|
||||
"compiler that requires Rosetta 2, which is not installed on "
|
||||
"this system. Install it with:\n"
|
||||
" softwareupdate --install-rosetta --agree-to-license"
|
||||
)
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.PLATFORM)
|
||||
async def to_code(config):
|
||||
cg.add(esp8266_ns.setup_preferences())
|
||||
|
||||
@@ -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({}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -488,10 +488,10 @@ template<typename... Ts> class HttpRequestSendAction final : public Action<Ts...
|
||||
body = this->body_.value(x...);
|
||||
}
|
||||
if (!this->json_.empty()) {
|
||||
body = json::build_json([this, x...](JsonObject root) { this->encode_json_(x..., root); });
|
||||
body = json::build_json([this, x...](JsonObject root) mutable { this->encode_json_(x..., root); });
|
||||
}
|
||||
if (this->json_func_ != nullptr) {
|
||||
body = json::build_json([this, x...](JsonObject root) { this->json_func_(x..., root); });
|
||||
body = json::build_json([this, x...](JsonObject root) mutable { this->json_func_(x..., root); });
|
||||
}
|
||||
std::vector<Header> request_headers;
|
||||
request_headers.reserve(this->request_headers_.size());
|
||||
|
||||
@@ -19,11 +19,6 @@ namespace esphome::http_request {
|
||||
static const char *const TAG = "http_request.idf";
|
||||
static constexpr uint32_t ERROR_DURATION_MS = 1000;
|
||||
|
||||
struct UserData {
|
||||
const std::vector<std::string> &lower_case_collect_headers;
|
||||
std::vector<Header> &response_headers;
|
||||
};
|
||||
|
||||
void HttpRequestIDF::dump_config() {
|
||||
HttpRequestComponent::dump_config();
|
||||
ESP_LOGCONFIG(TAG,
|
||||
@@ -34,15 +29,15 @@ void HttpRequestIDF::dump_config() {
|
||||
}
|
||||
|
||||
esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) {
|
||||
UserData *user_data = (UserData *) evt->user_data;
|
||||
auto *container = (HttpContainerIDF *) evt->user_data;
|
||||
|
||||
switch (evt->event_id) {
|
||||
case HTTP_EVENT_ON_HEADER: {
|
||||
const std::string header_name = str_lower_case(evt->header_key); // NOLINT
|
||||
if (should_collect_header(user_data->lower_case_collect_headers, header_name)) {
|
||||
if (should_collect_header(container->collect_headers_, header_name)) {
|
||||
const std::string header_value = evt->header_value;
|
||||
ESP_LOGD(TAG, "Received response header, name: %s, value: %s", header_name.c_str(), header_value.c_str());
|
||||
user_data->response_headers.push_back({header_name, header_value});
|
||||
container->response_headers_.push_back({header_name, header_value});
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -124,8 +119,8 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const std::string &url, c
|
||||
|
||||
container->set_secure(secure);
|
||||
|
||||
auto user_data = UserData{lower_case_collect_headers, container->response_headers_};
|
||||
esp_http_client_set_user_data(client, static_cast<void *>(&user_data));
|
||||
container->collect_headers_ = lower_case_collect_headers;
|
||||
esp_http_client_set_user_data(client, static_cast<void *>(container.get()));
|
||||
|
||||
for (const auto &header : request_headers) {
|
||||
esp_http_client_set_header(client, header.name.c_str(), header.value.c_str());
|
||||
|
||||
@@ -24,6 +24,8 @@ class HttpContainerIDF : public HttpContainer {
|
||||
protected:
|
||||
friend class HttpRequestIDF;
|
||||
esp_http_client_handle_t client_;
|
||||
// Owned copy (not a reference): must outlive perform() for the response-header event handler
|
||||
std::vector<std::string> collect_headers_;
|
||||
};
|
||||
|
||||
class HttpRequestIDF final : public HttpRequestComponent {
|
||||
|
||||
@@ -76,12 +76,27 @@ _BLE5_BK_SYS_CONFIG_OPTIONS = [
|
||||
"CFG_SUPPORT_BLE=0",
|
||||
]
|
||||
|
||||
# Board ids upstream LibreTiny renamed; configs written against the old id
|
||||
# keep validating and building against the new one (with a warning).
|
||||
# generic-ln882hki -> generic-ln882h: LibreTiny v1.13.0.
|
||||
_RENAMED_BOARDS = {
|
||||
"generic-ln882hki": "generic-ln882h",
|
||||
}
|
||||
|
||||
|
||||
def _detect_variant(value):
|
||||
if KEY_LIBRETINY not in CORE.data:
|
||||
raise cv.Invalid("Family component didn't populate core data properly!")
|
||||
component: LibreTinyComponent = CORE.data[KEY_LIBRETINY][KEY_COMPONENT_DATA]
|
||||
board = value[CONF_BOARD]
|
||||
if board not in component.boards and (renamed := _RENAMED_BOARDS.get(board)):
|
||||
_LOGGER.warning(
|
||||
"Board '%s' was renamed to '%s'; please update your configuration",
|
||||
board,
|
||||
renamed,
|
||||
)
|
||||
value = value.copy()
|
||||
value[CONF_BOARD] = board = renamed
|
||||
# read board-default family if not specified
|
||||
if board not in component.boards:
|
||||
if CONF_FAMILY not in value:
|
||||
|
||||
@@ -219,14 +219,11 @@ LightColorValues LightCall::validate_() {
|
||||
this->set_flag_(FLAG_HAS_STATE);
|
||||
}
|
||||
|
||||
// Make sure a turn-on makes the light visible: if the resulting brightness would be zero
|
||||
// (e.g. restored from a brightness=0 turn-off), reset it to full brightness.
|
||||
if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS)) {
|
||||
float brightness = this->has_brightness() ? this->brightness_ : this->parent_->remote_values.get_brightness();
|
||||
if (brightness == 0.0f) {
|
||||
this->brightness_ = 1.0f;
|
||||
this->set_flag_(FLAG_HAS_BRIGHTNESS);
|
||||
}
|
||||
// Make sure a simple (no specific brightness) turn-on makes the light visible
|
||||
if (this->has_state() && this->state_ && (color_mode & ColorCapability::BRIGHTNESS) && !this->has_brightness() &&
|
||||
this->parent_->remote_values.get_brightness() == 0.0f) {
|
||||
this->brightness_ = 1.0f;
|
||||
this->set_flag_(FLAG_HAS_BRIGHTNESS);
|
||||
}
|
||||
|
||||
// Set color brightness to 100% if currently zero and a color is set.
|
||||
|
||||
@@ -71,6 +71,14 @@ void LightState::setup() {
|
||||
break;
|
||||
}
|
||||
|
||||
// A light coming up on boot must never end up on-but-invisible: if the resolved restore
|
||||
// state is on but its brightness is zero (e.g. a stale/persisted value from before a
|
||||
// forced-on restore mode, or an inverted restore flipping a dim-to-0 off state to on),
|
||||
// reset it to full brightness.
|
||||
if (recovered.state && recovered.brightness == 0.0f) {
|
||||
recovered.brightness = 1.0f;
|
||||
}
|
||||
|
||||
call.set_color_mode_if_supported(recovered.color_mode);
|
||||
call.set_state(recovered.state);
|
||||
call.set_brightness_if_supported(recovered.brightness);
|
||||
|
||||
@@ -15,7 +15,7 @@ from esphome.const import (
|
||||
CONF_NAME,
|
||||
CONF_REPEAT,
|
||||
CONF_TYPE,
|
||||
DEVICE_CLASS_DISTANCE,
|
||||
DEVICE_CLASS_EMPTY,
|
||||
DEVICE_CLASS_ILLUMINANCE,
|
||||
ICON_BRIGHTNESS_5,
|
||||
ICON_BRIGHTNESS_6,
|
||||
@@ -159,7 +159,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
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,
|
||||
),
|
||||
key=CONF_NAME,
|
||||
@@ -169,7 +169,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
unit_of_measurement=UNIT_COUNTS,
|
||||
icon=ICON_BRIGHTNESS_7,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_ILLUMINANCE,
|
||||
device_class=DEVICE_CLASS_EMPTY,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
key=CONF_NAME,
|
||||
@@ -179,7 +179,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
unit_of_measurement=UNIT_COUNTS,
|
||||
icon=ICON_PROXIMITY,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_DISTANCE,
|
||||
device_class=DEVICE_CLASS_EMPTY,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
key=CONF_NAME,
|
||||
@@ -188,7 +188,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
sensor.sensor_schema(
|
||||
icon=ICON_GAIN,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_ILLUMINANCE,
|
||||
device_class=DEVICE_CLASS_EMPTY,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
key=CONF_NAME,
|
||||
|
||||
@@ -19,6 +19,10 @@ void MCP23017::setup() {
|
||||
this->read_reg(mcp23x17_base::MCP23X17_OLATA, &this->olat_a_);
|
||||
this->read_reg(mcp23x17_base::MCP23X17_OLATB, &this->olat_b_);
|
||||
|
||||
// Reset IPOL to 0x00: ESPHome handles 'inverted' in software.
|
||||
this->write_reg(mcp23x17_base::MCP23X17_IPOLA, 0x00);
|
||||
this->write_reg(mcp23x17_base::MCP23X17_IPOLB, 0x00);
|
||||
|
||||
uint8_t iocon_flags = 0;
|
||||
if (this->open_drain_ints_) {
|
||||
iocon_flags |= IOCON_ODR;
|
||||
|
||||
@@ -342,7 +342,7 @@ bool Mcp4461Component::increase_wiper_(Mcp4461WiperIdx wiper) {
|
||||
ESP_LOGV(TAG, "Increasing wiper %u", wiper_idx);
|
||||
uint8_t addr = this->get_wiper_address_(wiper_idx);
|
||||
uint8_t reg = addr | static_cast<uint8_t>(Mcp4461Commands::INCREMENT);
|
||||
auto err = this->write(&this->address_, reg);
|
||||
auto err = this->write(®, 1);
|
||||
if (err != i2c::ERROR_OK) {
|
||||
this->error_code_ = MCP4461_STATUS_I2C_ERROR;
|
||||
this->status_set_warning();
|
||||
@@ -373,7 +373,7 @@ bool Mcp4461Component::decrease_wiper_(Mcp4461WiperIdx wiper) {
|
||||
ESP_LOGV(TAG, "Decreasing wiper %u", wiper_idx);
|
||||
uint8_t addr = this->get_wiper_address_(wiper_idx);
|
||||
uint8_t reg = addr | static_cast<uint8_t>(Mcp4461Commands::DECREMENT);
|
||||
auto err = this->write(&this->address_, reg);
|
||||
auto err = this->write(®, 1);
|
||||
if (err != i2c::ERROR_OK) {
|
||||
this->error_code_ = MCP4461_STATUS_I2C_ERROR;
|
||||
this->status_set_warning();
|
||||
|
||||
@@ -47,7 +47,7 @@ void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_regi
|
||||
auto &services = services_storage;
|
||||
#endif
|
||||
|
||||
#ifdef USE_API
|
||||
#ifdef USE_MDNS_DEVICE_INFO_TXT
|
||||
#ifdef USE_MDNS_STORE_SERVICES
|
||||
get_mac_address_into_buffer(this->mac_address_);
|
||||
char *mac_ptr = this->mac_address_;
|
||||
@@ -70,17 +70,20 @@ void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_regi
|
||||
platform_register(this, services);
|
||||
}
|
||||
|
||||
void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services, char *mac_address_buf,
|
||||
char *config_hash_buf) {
|
||||
void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services,
|
||||
const char *mac_address_buf, const char *config_hash_buf) {
|
||||
// IMPORTANT: The #ifdef blocks below must match COMPONENTS_WITH_MDNS_SERVICES
|
||||
// in mdns/__init__.py. If you add a new service here, update both locations.
|
||||
|
||||
#ifdef USE_MDNS_DEVICE_INFO_TXT
|
||||
MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version");
|
||||
MDNS_STATIC_CONST_CHAR(TXT_MAC, "mac");
|
||||
MDNS_STATIC_CONST_CHAR(TXT_CONFIG_HASH, "config_hash");
|
||||
#endif
|
||||
|
||||
#ifdef USE_API
|
||||
MDNS_STATIC_CONST_CHAR(SERVICE_ESPHOMELIB, "_esphomelib");
|
||||
MDNS_STATIC_CONST_CHAR(TXT_FRIENDLY_NAME, "friendly_name");
|
||||
MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version");
|
||||
MDNS_STATIC_CONST_CHAR(TXT_CONFIG_HASH, "config_hash");
|
||||
MDNS_STATIC_CONST_CHAR(TXT_MAC, "mac");
|
||||
MDNS_STATIC_CONST_CHAR(TXT_PLATFORM, "platform");
|
||||
MDNS_STATIC_CONST_CHAR(TXT_BOARD, "board");
|
||||
MDNS_STATIC_CONST_CHAR(TXT_NETWORK, "network");
|
||||
@@ -107,7 +110,13 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
|
||||
txt_count++; // network
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
const bool api_has_psk = api::global_api_server->get_noise_ctx().has_psk();
|
||||
txt_count++; // api_encryption or api_encryption_supported
|
||||
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
||||
if (!api_has_psk) {
|
||||
txt_count++; // api_provisioning
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
#ifdef ESPHOME_PROJECT_NAME
|
||||
txt_count += 2; // project_name and project_version
|
||||
@@ -163,9 +172,18 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
|
||||
MDNS_STATIC_CONST_CHAR(TXT_API_ENCRYPTION, "api_encryption");
|
||||
MDNS_STATIC_CONST_CHAR(TXT_API_ENCRYPTION_SUPPORTED, "api_encryption_supported");
|
||||
MDNS_STATIC_CONST_CHAR(NOISE_ENCRYPTION, "Noise_NNpsk0_25519_ChaChaPoly_SHA256");
|
||||
bool has_psk = api::global_api_server->get_noise_ctx().has_psk();
|
||||
const char *encryption_key = has_psk ? TXT_API_ENCRYPTION : TXT_API_ENCRYPTION_SUPPORTED;
|
||||
const char *encryption_key = api_has_psk ? TXT_API_ENCRYPTION : TXT_API_ENCRYPTION_SUPPORTED;
|
||||
txt_records.push_back({MDNS_STR(encryption_key), MDNS_STR(NOISE_ENCRYPTION)});
|
||||
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
||||
if (!api_has_psk) {
|
||||
// Unprovisioned device without a YAML key: advertise that the encryption
|
||||
// key can be provisioned over a zero-PSK Noise connection. Gated on the
|
||||
// YAML define so this survives the plaintext removal in 2027.2.0.
|
||||
MDNS_STATIC_CONST_CHAR(TXT_API_PROVISIONING, "api_provisioning");
|
||||
MDNS_STATIC_CONST_CHAR(VALUE_ZERO_PSK, "zero-psk");
|
||||
txt_records.push_back({MDNS_STR(TXT_API_PROVISIONING), MDNS_STR(VALUE_ZERO_PSK)});
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#ifdef ESPHOME_PROJECT_NAME
|
||||
@@ -212,12 +230,18 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
|
||||
web_service.service_type = MDNS_STR(SERVICE_HTTP);
|
||||
web_service.proto = MDNS_STR(SERVICE_TCP);
|
||||
web_service.port = []() -> uint16_t { return USE_WEBSERVER_PORT; };
|
||||
#ifndef USE_API
|
||||
// Without the native API there is no _esphomelib service, so publish the
|
||||
// device info here for the device builder to discover.
|
||||
web_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)},
|
||||
{MDNS_STR(TXT_MAC), MDNS_STR(mac_address_buf)},
|
||||
{MDNS_STR(TXT_CONFIG_HASH), MDNS_STR(config_hash_buf)}};
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if !defined(USE_API) && !defined(USE_PROMETHEUS) && !defined(USE_SENDSPIN) && !defined(USE_WEBSERVER) && \
|
||||
!defined(USE_MDNS_EXTRA_SERVICES)
|
||||
MDNS_STATIC_CONST_CHAR(SERVICE_HTTP, "_http");
|
||||
MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version");
|
||||
|
||||
// Publish "http" service if not using native API or any other services
|
||||
// This is just to have *some* mDNS service so that .local resolution works
|
||||
@@ -225,7 +249,9 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
|
||||
fallback_service.service_type = MDNS_STR(SERVICE_HTTP);
|
||||
fallback_service.proto = MDNS_STR(SERVICE_TCP);
|
||||
fallback_service.port = []() -> uint16_t { return USE_WEBSERVER_PORT; };
|
||||
fallback_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}};
|
||||
fallback_service.txt_records = {{MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)},
|
||||
{MDNS_STR(TXT_MAC), MDNS_STR(mac_address_buf)},
|
||||
{MDNS_STR(TXT_CONFIG_HASH), MDNS_STR(config_hash_buf)}};
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,15 @@
|
||||
#endif
|
||||
#endif
|
||||
|
||||
// Device info TXT records (version, mac, config_hash) are published on the _esphomelib service
|
||||
// when the native API is enabled, otherwise on the _http service (web_server's or the fallback one).
|
||||
// When neither applies (only prometheus, sendspin or user-defined services are configured), no
|
||||
// device info records are published and the buffers below are not needed.
|
||||
#if defined(USE_API) || defined(USE_WEBSERVER) || \
|
||||
(!defined(USE_PROMETHEUS) && !defined(USE_SENDSPIN) && !defined(USE_MDNS_EXTRA_SERVICES))
|
||||
#define USE_MDNS_DEVICE_INFO_TXT
|
||||
#endif
|
||||
|
||||
namespace esphome::mdns {
|
||||
|
||||
// Helper struct that identifies strings that may be stored in flash storage (similar to LogString)
|
||||
@@ -136,7 +145,7 @@ class MDNSComponent final : public Component
|
||||
StaticVector<std::string, MDNS_DYNAMIC_TXT_COUNT> dynamic_txt_values_;
|
||||
#endif
|
||||
|
||||
#if defined(USE_API) && defined(USE_MDNS_STORE_SERVICES)
|
||||
#if defined(USE_MDNS_DEVICE_INFO_TXT) && defined(USE_MDNS_STORE_SERVICES)
|
||||
/// Fixed buffer for MAC address (only needed when services are stored)
|
||||
char mac_address_[MAC_ADDRESS_BUFFER_SIZE];
|
||||
/// Fixed buffer for config hash hex string (only needed when services are stored)
|
||||
@@ -149,8 +158,8 @@ class MDNSComponent final : public Component
|
||||
// RP2040 defers MDNS.begin() until the first IP-up event; this tracks that.
|
||||
bool initialized_{false};
|
||||
#endif
|
||||
void compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services, char *mac_address_buf,
|
||||
char *config_hash_buf);
|
||||
void compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services, const char *mac_address_buf,
|
||||
const char *config_hash_buf);
|
||||
};
|
||||
|
||||
} // namespace esphome::mdns
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace esphome::mdns {
|
||||
|
||||
void MDNSComponent::setup() {
|
||||
#ifdef USE_MDNS_STORE_SERVICES
|
||||
#ifdef USE_API
|
||||
#ifdef USE_MDNS_DEVICE_INFO_TXT
|
||||
get_mac_address_into_buffer(this->mac_address_);
|
||||
char *mac_ptr = this->mac_address_;
|
||||
format_hex_to(this->config_hash_str_, App.get_config_hash());
|
||||
|
||||
@@ -2,12 +2,15 @@ import hashlib
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from esphome import automation, external_files, git
|
||||
from esphome.automation import register_action, register_condition
|
||||
from esphome.bundle import add_bundle_file
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32, microphone, ota, psram
|
||||
from esphome.components.http_request import validate_url
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_FILE,
|
||||
@@ -28,6 +31,7 @@ from esphome.const import (
|
||||
TYPE_LOCAL,
|
||||
)
|
||||
from esphome.core import CORE, HexInt
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -207,40 +211,62 @@ def _validate_manifest_version(manifest_data):
|
||||
raise cv.Invalid("Invalid manifest file, missing 'version' key")
|
||||
|
||||
|
||||
def _process_http_source(config):
|
||||
url = config[CONF_URL]
|
||||
path = _compute_local_file_path(config)
|
||||
HTTP_SCHEMA = cv.Schema(
|
||||
{
|
||||
# validate_url only accepts http(s); the shorthand validator relies
|
||||
# on this branch rejecting git shorthands ("github://...") so they
|
||||
# fall through to the git branch.
|
||||
cv.Required(CONF_URL): validate_url,
|
||||
}
|
||||
)
|
||||
|
||||
json_path = path / "manifest.json"
|
||||
|
||||
json_contents = external_files.download_content(url, json_path)
|
||||
def _register_local_model_file(config: ConfigType) -> ConfigType:
|
||||
"""Register the model file that the manifest points to, so bundles include it.
|
||||
|
||||
manifest_data = json.loads(json_contents)
|
||||
if not isinstance(manifest_data, dict):
|
||||
raise cv.Invalid("Manifest file must contain a JSON object")
|
||||
|
||||
model = manifest_data[CONF_MODEL]
|
||||
model_url = urljoin(url, model)
|
||||
|
||||
model_path = path / model
|
||||
|
||||
external_files.download_content(str(model_url), model_path)
|
||||
The manifest names its model file relative to itself, so that path never appears
|
||||
in the YAML and bundle discovery cannot find it on its own.
|
||||
|
||||
Problems with the manifest are logged and ignored here rather than raised. Loading
|
||||
the manifest later reports them with better messages, and raising would be
|
||||
swallowed by the shorthand validator, which then reports a confusing error about a
|
||||
missing file in a git repository. Logging keeps the skipped registration
|
||||
diagnosable if the manifest is only briefly unreadable, since the bundle would
|
||||
then be built without the model file.
|
||||
"""
|
||||
manifest_path: Path = config[CONF_PATH]
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
model = manifest[CONF_MODEL]
|
||||
except (OSError, ValueError, KeyError, TypeError) as err:
|
||||
_LOGGER.debug("Not registering a model file from %s: %s", manifest_path, err)
|
||||
return config
|
||||
if not isinstance(model, str):
|
||||
_LOGGER.debug(
|
||||
"Not registering a model file from %s: 'model' is %s, expected a string",
|
||||
manifest_path,
|
||||
type(model).__name__,
|
||||
)
|
||||
return config
|
||||
add_bundle_file(manifest_path.parent / model)
|
||||
return config
|
||||
|
||||
|
||||
HTTP_SCHEMA = cv.All(
|
||||
{
|
||||
cv.Required(CONF_URL): cv.url,
|
||||
},
|
||||
_process_http_source,
|
||||
LOCAL_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_PATH): cv.All(_validate_json_filename, cv.file_),
|
||||
}
|
||||
),
|
||||
_register_local_model_file,
|
||||
)
|
||||
|
||||
LOCAL_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_PATH): cv.All(_validate_json_filename, cv.file_),
|
||||
}
|
||||
)
|
||||
|
||||
# Bare model names in the official model repository ("okay_nabu"). Must not
|
||||
# overlap with local paths, http(s) urls, or git shorthands
|
||||
# ("github://user/repo/file.json@ref"), which the shorthand validator tries
|
||||
# next; anything containing "/", ":" or "@" is not a model name.
|
||||
_MODEL_NAME_RE = re.compile(r"[A-Za-z0-9_.-]+")
|
||||
|
||||
|
||||
def _validate_source_model_name(value):
|
||||
@@ -250,6 +276,9 @@ def _validate_source_model_name(value):
|
||||
if value.endswith(".json"):
|
||||
raise cv.Invalid("Model name must not end with .json")
|
||||
|
||||
if not _MODEL_NAME_RE.fullmatch(value):
|
||||
raise cv.Invalid("Model name may only contain letters, numbers, . _ -")
|
||||
|
||||
return MODEL_SOURCE_SCHEMA(
|
||||
{
|
||||
CONF_TYPE: TYPE_HTTP,
|
||||
@@ -339,6 +368,58 @@ def _maybe_empty_vad_schema(value):
|
||||
return VAD_MODEL_SCHEMA(value)
|
||||
|
||||
|
||||
def _download_http_models(config: ConfigType) -> ConfigType:
|
||||
"""Download every http-sourced manifest and model file in two concurrent
|
||||
batches (all manifests, then all model files).
|
||||
|
||||
The model file's URL only becomes known once its manifest has been
|
||||
fetched and parsed, so the two stages cannot be merged into one batch.
|
||||
"""
|
||||
model_parameters = [*config[CONF_MODELS]]
|
||||
if vad := config.get(CONF_VAD):
|
||||
model_parameters.append(vad)
|
||||
# Keyed by cache path so a URL referenced twice is fetched and parsed once
|
||||
http_models: dict[Path, str] = {
|
||||
_compute_local_file_path(model_config): model_config[CONF_URL]
|
||||
for parameters in model_parameters
|
||||
if (model_config := parameters.get(CONF_MODEL)) is not None
|
||||
and model_config.get(CONF_TYPE) == TYPE_HTTP
|
||||
}
|
||||
if not http_models:
|
||||
return config
|
||||
|
||||
external_files.download_content_many(
|
||||
((url, path / "manifest.json") for path, url in http_models.items()),
|
||||
description="wake word manifest(s)",
|
||||
)
|
||||
|
||||
model_files: list[tuple[str, Path]] = []
|
||||
errors: list[cv.Invalid] = []
|
||||
for path, url in http_models.items():
|
||||
try:
|
||||
manifest_data = json.loads((path / "manifest.json").read_bytes())
|
||||
except (OSError, ValueError) as e:
|
||||
errors.append(cv.Invalid(f"Invalid manifest file at {url}: {e}"))
|
||||
continue
|
||||
if not isinstance(manifest_data, dict):
|
||||
errors.append(
|
||||
cv.Invalid(f"Manifest file at {url} must contain a JSON object")
|
||||
)
|
||||
continue
|
||||
model = manifest_data.get(CONF_MODEL)
|
||||
if not isinstance(model, str):
|
||||
errors.append(
|
||||
cv.Invalid(f"Manifest file at {url} is missing the 'model' key")
|
||||
)
|
||||
continue
|
||||
model_files.append((urljoin(url, model), path / model))
|
||||
if errors:
|
||||
raise cv.MultipleInvalid(errors)
|
||||
|
||||
external_files.download_content_many(model_files, description="wake word model(s)")
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
@@ -372,6 +453,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
cv.only_on_esp32,
|
||||
_download_http_models,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -26,16 +26,22 @@ from esphome.const import (
|
||||
CONF_OFFSET_HEIGHT,
|
||||
CONF_OFFSET_WIDTH,
|
||||
CONF_PAGES,
|
||||
CONF_RESET_PIN,
|
||||
CONF_ROTATION,
|
||||
CONF_SWAP_XY,
|
||||
CONF_TRANSFORM,
|
||||
CONF_WIDTH,
|
||||
)
|
||||
from esphome.core import TimePeriod
|
||||
from esphome.core import CORE, TimePeriod
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
|
||||
LOGGER = cv.logging.getLogger(__name__)
|
||||
|
||||
CONF_TRANSFORMS = "transforms"
|
||||
|
||||
# All axis transforms a model may support, in the order they appear in the schema.
|
||||
ALL_TRANSFORMS = (CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY)
|
||||
|
||||
ColorOrder = display_ns.enum("ColorMode")
|
||||
|
||||
NOP = 0x00
|
||||
@@ -302,7 +308,8 @@ class DriverChip:
|
||||
"""
|
||||
A class representing a MIPI DBI driver chip model.
|
||||
The parameters supplied as defaults will be used to provide default values for the display configuration.
|
||||
Setting swap_xy to cv.UNDEFINED will indicate that the model does not support swapping X and Y axes.
|
||||
Pass a ``transforms`` set to restrict which axis transforms (mirror_x, mirror_y, swap_xy) the model
|
||||
supports; by default all three are available.
|
||||
"""
|
||||
|
||||
models: dict[str, Self] = {}
|
||||
@@ -387,11 +394,15 @@ class DriverChip:
|
||||
"""
|
||||
Return the available transforms for this model.
|
||||
"""
|
||||
if (transforms := self.get_default(CONF_TRANSFORMS, None)) is not None:
|
||||
return transforms
|
||||
if self.get_default("no_transform", False):
|
||||
return set()
|
||||
if self.get_default(CONF_SWAP_XY) != cv.UNDEFINED:
|
||||
return {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY}
|
||||
return {CONF_MIRROR_X, CONF_MIRROR_Y}
|
||||
raise ValueError(
|
||||
"Setting 'swap_xy' to 'cv.UNDEFINED' is no longer supported; set 'transforms' instead"
|
||||
)
|
||||
|
||||
def has_hardware_transform(self, config) -> bool:
|
||||
"""
|
||||
@@ -533,17 +544,31 @@ class DriverChip:
|
||||
transform[CONF_TRANSFORM] = self.rotation_as_transform(config)
|
||||
return transform
|
||||
|
||||
def swap_xy_schema(self):
|
||||
uses_swap = self.get_default(CONF_SWAP_XY, None) != cv.UNDEFINED
|
||||
def transform_schema(self):
|
||||
"""
|
||||
Build the schema for the ``transform`` config option of this model.
|
||||
|
||||
def validator(value):
|
||||
if value:
|
||||
raise cv.Invalid("Axis swapping not supported by this model")
|
||||
return cv.boolean(value)
|
||||
Each transform the model supports is a required boolean. A transform the model does not
|
||||
support may be omitted or set to ``false``; setting it to ``true`` reports a clear error
|
||||
naming the unsupported transform instead of a generic "extra keys not allowed".
|
||||
"""
|
||||
supported = self.transforms
|
||||
|
||||
if uses_swap:
|
||||
return {cv.Required(CONF_SWAP_XY): cv.boolean}
|
||||
return {cv.Optional(CONF_SWAP_XY, default=False): validator}
|
||||
def unsupported(name):
|
||||
def validator(value):
|
||||
if cv.boolean(value):
|
||||
raise cv.Invalid(f"'{name}' is not supported by this model")
|
||||
return False
|
||||
|
||||
return validator
|
||||
|
||||
schema = {}
|
||||
for name in ALL_TRANSFORMS:
|
||||
if name in supported:
|
||||
schema[cv.Required(name)] = cv.boolean
|
||||
else:
|
||||
schema[cv.Optional(name, default=False)] = unsupported(name)
|
||||
return cv.Any(cv.Schema(schema), cv.one_of(CONF_DISABLED, lower=True))
|
||||
|
||||
def get_madctl(self, transform: dict, config: dict) -> int:
|
||||
"""
|
||||
@@ -577,12 +602,15 @@ class DriverChip:
|
||||
"""
|
||||
return self.get_default(f"no_{command.lower()}", False)
|
||||
|
||||
def get_sequence(self, config, add_madctl=True) -> tuple[int, ...]:
|
||||
def get_sequence(self, config, add_madctl=True, add_reset=False) -> tuple[int, ...]:
|
||||
"""
|
||||
Create the init sequence for the display.
|
||||
Use the default sequence from the model, if any, and append any custom sequence provided in the config.
|
||||
Append SLPOUT (if not already in the sequence) and DISPON to the end of the sequence
|
||||
MADCTL will be set if add_madctl is True
|
||||
If add_reset is True, a reset is prepended: a software reset when no reset pin
|
||||
is configured (and the model doesn't skip it), followed by a settling delay that
|
||||
both a software and a hardware reset require.
|
||||
Returns the init sequence
|
||||
"""
|
||||
sequence = list(self.initsequence or ())
|
||||
@@ -591,6 +619,15 @@ class DriverChip:
|
||||
# Ensure each command is a tuple
|
||||
sequence = [x if isinstance(x, tuple) else (x,) for x in sequence]
|
||||
|
||||
if add_reset:
|
||||
reset: list = []
|
||||
# A software reset is only needed when there is no hardware reset pin.
|
||||
if CONF_RESET_PIN not in config and not self.skip_command("SWRESET"):
|
||||
reset.append((SWRESET,))
|
||||
# Both a software and a hardware reset need a settling delay before further commands.
|
||||
reset.append(delay(10))
|
||||
sequence = reset + sequence
|
||||
|
||||
# Set pixel format if not already in the custom sequence
|
||||
pixel_mode = config[CONF_PIXEL_MODE]
|
||||
if not isinstance(pixel_mode, int):
|
||||
@@ -611,13 +648,47 @@ class DriverChip:
|
||||
sequence.append((BRIGHTNESS, brightness))
|
||||
# Add a SLPOUT command if required.
|
||||
if not self.skip_command("SLPOUT"):
|
||||
# A zero delay will delay until 120ms after reset
|
||||
sequence.append(delay(0))
|
||||
sequence.append((SLPOUT,))
|
||||
sequence.append(delay(10))
|
||||
sequence.append((DISPON,))
|
||||
# Add a delay here because additional commands may be added after this at runtime.
|
||||
sequence.append(delay(10))
|
||||
|
||||
# Flatten the sequence into a list of bytes, with the length of each command
|
||||
# or the delay flag inserted where needed
|
||||
return flatten_sequence(sequence)
|
||||
|
||||
def check_requirements(self) -> None:
|
||||
"""
|
||||
Raise a friendly error if any component this model requires is not configured.
|
||||
|
||||
This runs during schema validation (before ID references are resolved) so that a
|
||||
model whose default pins live on a pin expander reports the missing expander clearly
|
||||
instead of a cryptic "Couldn't find ID" from the unresolved pin reference.
|
||||
|
||||
Also logs a warning if the model is deprecated.
|
||||
"""
|
||||
if deprecation_reason := self.get_default("deprecation_reason"):
|
||||
LOGGER.warning(
|
||||
"Display model %s is deprecated: %s", self.name, deprecation_reason
|
||||
)
|
||||
if requirements := self.get_default("requires", set()):
|
||||
# ``raw_config`` is populated before any component schema runs during a real
|
||||
# validation, so presence of a required component is simply a top-level key.
|
||||
# When it is absent (e.g. a unit test that invokes the schema directly) there
|
||||
# is no config to check against, so skip.
|
||||
global_config = CORE.raw_config
|
||||
if global_config is None:
|
||||
return
|
||||
missing = {x for x in requirements if x not in global_config}
|
||||
if missing:
|
||||
reqstr = ", ".join(f"'{x}'" for x in sorted(missing))
|
||||
raise cv.Invalid(
|
||||
f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured"
|
||||
)
|
||||
|
||||
|
||||
def requires_buffer(config) -> bool:
|
||||
"""
|
||||
|
||||
@@ -41,24 +41,21 @@ from esphome.const import (
|
||||
CONF_AUTO_CLEAR_ENABLED,
|
||||
CONF_COLOR_ORDER,
|
||||
CONF_DIMENSIONS,
|
||||
CONF_DISABLED,
|
||||
CONF_ENABLE_PIN,
|
||||
CONF_ID,
|
||||
CONF_INIT_SEQUENCE,
|
||||
CONF_INVERT_COLORS,
|
||||
CONF_LAMBDA,
|
||||
CONF_MIRROR_X,
|
||||
CONF_MIRROR_Y,
|
||||
CONF_MODEL,
|
||||
CONF_RESET_PIN,
|
||||
CONF_ROTATION,
|
||||
CONF_SWAP_XY,
|
||||
CONF_TRANSFORM,
|
||||
CONF_WIDTH,
|
||||
)
|
||||
from esphome.final_validate import full_config
|
||||
|
||||
from . import mipi_dsi_ns, models
|
||||
from .models import DsiDriverChip
|
||||
|
||||
# Currently only ESP32-P4 is supported, so esp_ldo and psram are required
|
||||
DEPENDENCIES = ["esp32", "esp_ldo", "psram"]
|
||||
@@ -73,7 +70,7 @@ ColorBitness = display.display_ns.enum("ColorBitness")
|
||||
CONF_LANE_BIT_RATE = "lane_bit_rate"
|
||||
CONF_LANES = "lanes"
|
||||
|
||||
DriverChip("CUSTOM")
|
||||
DsiDriverChip("CUSTOM")
|
||||
|
||||
# Import all models dynamically from the models package
|
||||
|
||||
@@ -90,19 +87,7 @@ COLOR_DEPTHS = {
|
||||
|
||||
def model_schema(config):
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
model.defaults[CONF_SWAP_XY] = cv.UNDEFINED
|
||||
transform = cv.Any(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_MIRROR_X): cv.boolean,
|
||||
cv.Required(CONF_MIRROR_Y): cv.boolean,
|
||||
cv.Optional(CONF_SWAP_XY): cv.invalid(
|
||||
"Axis swapping not supported by DSI displays"
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.one_of(CONF_DISABLED, lower=True),
|
||||
)
|
||||
transform = model.transform_schema()
|
||||
# CUSTOM model will need to provide a custom init sequence
|
||||
iseqconf = (
|
||||
cv.Required(CONF_INIT_SEQUENCE)
|
||||
@@ -172,6 +157,7 @@ def _config_schema(config):
|
||||
)(config)
|
||||
config = model_schema(config)(config)
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
model.check_requirements()
|
||||
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
|
||||
model.get_dimensions(config)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from esphome.components.mipi import DriverChip
|
||||
from esphome.const import CONF_SWAP_XY
|
||||
|
||||
|
||||
class DsiDriverChip(DriverChip):
|
||||
"""A driver chip for MIPI DSI displays."""
|
||||
|
||||
@property
|
||||
def transforms(self) -> set[str]:
|
||||
"""
|
||||
Return the set of transformations supported by this driver chip.
|
||||
DSI displays do not support axis swapping, so this method removes CONF_SWAP_XY
|
||||
"""
|
||||
return super().transforms - {CONF_SWAP_XY}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from esphome.components.mipi import DriverChip
|
||||
import esphome.config_validation as cv
|
||||
from . import DsiDriverChip
|
||||
|
||||
# fmt: off
|
||||
DriverChip(
|
||||
DsiDriverChip(
|
||||
"JC1060P470",
|
||||
width=1024,
|
||||
height=600,
|
||||
@@ -14,7 +13,6 @@ DriverChip(
|
||||
vsync_front_porch=12,
|
||||
pclk_frequency="54MHz",
|
||||
lane_bit_rate="750Mbps",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
initsequence=[
|
||||
(0x30, 0x00), (0xF7, 0x49, 0x61, 0x02, 0x00), (0x30, 0x01), (0x04, 0x0C), (0x05, 0x00), (0x06, 0x00),
|
||||
@@ -46,7 +44,7 @@ DriverChip(
|
||||
# * Horizontal Timing (hsync_pulse_width=12, hsync_back_porch=42, hsync_front_porch=42)
|
||||
# * Vertical Timing (vsync_pulse_width=2, vsync_back_porch=8, vsync_front_porch=166)
|
||||
# ----------------------------------------------------------------------------------------------------------------------
|
||||
DriverChip(
|
||||
DsiDriverChip(
|
||||
"JC4880P443",
|
||||
width=480,
|
||||
height=800,
|
||||
@@ -58,7 +56,6 @@ DriverChip(
|
||||
vsync_front_porch=166,
|
||||
pclk_frequency="34MHz",
|
||||
lane_bit_rate="500Mbps",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
reset_pin=5,
|
||||
initsequence=[
|
||||
@@ -111,7 +108,7 @@ DriverChip(
|
||||
# * Horizontal Timing (hsync_pulse_width=20, hsync_back_porch=20, hsync_front_porch=40)
|
||||
# * Vertical Timing (vsync_pulse_width=4, vsync_back_porch=8, vsync_front_porch=20)
|
||||
# ----------------------------------------------------------------------------------------------------------------------
|
||||
DriverChip(
|
||||
DsiDriverChip(
|
||||
"JC8012P4A1",
|
||||
width=800,
|
||||
height=1280,
|
||||
@@ -123,7 +120,6 @@ DriverChip(
|
||||
vsync_front_porch=20,
|
||||
pclk_frequency="60MHz",
|
||||
lane_bit_rate="1Gbps",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
reset_pin=27,
|
||||
initsequence=[
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from esphome.components.mipi import DriverChip
|
||||
import esphome.config_validation as cv
|
||||
from . import DsiDriverChip
|
||||
|
||||
# fmt: off
|
||||
DriverChip(
|
||||
DsiDriverChip(
|
||||
"M5STACK-TAB5",
|
||||
height=1280,
|
||||
width=720,
|
||||
@@ -14,7 +13,6 @@ DriverChip(
|
||||
vsync_front_porch=20,
|
||||
pclk_frequency="60MHz",
|
||||
lane_bit_rate="730Mbps",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
initsequence=[
|
||||
(0xFF, 0x98, 0x81, 0x01), # Select Page 1
|
||||
@@ -56,8 +54,8 @@ DriverChip(
|
||||
],
|
||||
)
|
||||
|
||||
DriverChip(
|
||||
"M5STACK-TAB5-V2",
|
||||
TAB5_ST7123 = DsiDriverChip(
|
||||
"M5STACK-TAB5-ST7123",
|
||||
height=1280,
|
||||
width=720,
|
||||
hsync_back_porch=40,
|
||||
@@ -68,7 +66,6 @@ DriverChip(
|
||||
vsync_front_porch=220,
|
||||
pclk_frequency="80MHz",
|
||||
lane_bit_rate="960Mbps",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
initsequence=[
|
||||
(0x01,),
|
||||
@@ -97,3 +94,58 @@ DriverChip(
|
||||
(0xC9, 0x00, 0x00, 0x10, 0x1F, 0x36, 0x00, 0x5D, 0x04, 0x9D, 0x05, 0x10, 0xF2, 0x06, 0x60, 0x03, 0x11, 0xAD, 0x00, 0xEF, 0x01, 0x22, 0x2E, 0x0E, 0x74, 0x08, 0x32, 0xDC, 0x09, 0x33, 0x0F, 0xF3, 0x77, 0x0D, 0xB0, 0xDC, 0x03, 0xFF),
|
||||
],
|
||||
)
|
||||
|
||||
TAB5_ST7123.extend(
|
||||
"M5STACK-TAB5-V2",
|
||||
deprecation_reason="Use 'M5STACK-TAB5-ST7123' or 'M5STACK-TAB5-ST7121' instead."
|
||||
)
|
||||
|
||||
# Some Tab5 "v2" units ship with an ST7121 controller instead of the ST7123.
|
||||
# The two are distinguishable at runtime by the touch controller firmware version (the M5
|
||||
# factory firmware branches on it), but ESPHome selects the panel at compile time, so ST7121
|
||||
# units must select this model explicitly. Values taken from M5's factory source
|
||||
# (m5stack/M5Tab5-UserDemo: m5stack_tab5.c is_st7121 path + esp_lcd_st7121.c default table).
|
||||
DsiDriverChip(
|
||||
"M5STACK-TAB5-ST7121",
|
||||
height=1280,
|
||||
width=720,
|
||||
hsync_back_porch=40,
|
||||
hsync_pulse_width=2,
|
||||
hsync_front_porch=40,
|
||||
vsync_back_porch=24,
|
||||
vsync_pulse_width=20,
|
||||
vsync_front_porch=200,
|
||||
pclk_frequency="70MHz",
|
||||
lane_bit_rate="965Mbps",
|
||||
color_order="RGB",
|
||||
initsequence=[
|
||||
(0x01,),
|
||||
(0x60, 0x71, 0x21, 0xA2),
|
||||
(0x60, 0x71, 0x21, 0xA3),
|
||||
(0x60, 0x71, 0x21, 0xA4),
|
||||
(0x78, 0x21),
|
||||
(0x79, 0xEF),
|
||||
(0xA4, 0x31),
|
||||
(0xB7, 0x00, 0x00, 0x5F, 0x5F, 0x44, 0x1A),
|
||||
(0xB0, 0x22, 0x6B, 0x11, 0x89, 0x25, 0x43, 0x43),
|
||||
(0xBF, 0xA7, 0xA7),
|
||||
(0xA5, 0xF0, 0x03),
|
||||
(0xD7, 0x10, 0x2C, 0x14, 0x2A, 0x80, 0x80),
|
||||
(0x90, 0x71, 0x23, 0x5A, 0x20, 0x24, 0x11, 0x21),
|
||||
(0xA3, 0x80, 0x01, 0x8C, 0xFF, 0x45, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x1E, 0x5C, 0x1E, 0x80, 0x10, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46, 0x00, 0x00, 0x1E, 0x5C, 0x1E, 0x80, 0x10, 0xEF, 0x58, 0x00, 0x00, 0x00, 0xFF),
|
||||
(0xA6, 0x0A, 0x00, 0x24, 0x71, 0x36, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x24, 0x71, 0x37, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x24, 0x71, 0x00, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x00, 0x2C, 0x71, 0x00, 0x01, 0x00, 0x00, 0x68, 0x68, 0xFF, 0xFF, 0x00, 0x08, 0x80, 0x08, 0x80, 0x06, 0x00, 0x00, 0x00, 0x00),
|
||||
(0xA7, 0x1A, 0x1A, 0xC0, 0x64, 0x40, 0x04, 0x15, 0x40, 0x00, 0x40, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0x26, 0x37, 0x40, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0x8C, 0x9D, 0x40, 0x00, 0x00, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x64, 0x40, 0xAE, 0xBF, 0x00, 0x00, 0x20, 0x00, 0x68, 0x68, 0x91, 0xFF, 0x08, 0x80, 0x79),
|
||||
(0xAC, 0x1D, 0x18, 0x19, 0x1D, 0x18, 0x19, 0x04, 0x1C, 0x1D, 0x08, 0x0A, 0x10, 0x12, 0x0C, 0x0E, 0x14, 0x16, 0x00, 0x1D, 0x1D, 0x1D, 0x1D, 0x1D, 0x18, 0x19, 0x1D, 0x18, 0x19, 0x06, 0x1C, 0x1D, 0x09, 0x0B, 0x11, 0x13, 0x0D, 0x0F, 0x15, 0x17, 0x02, 0x1D, 0x1D, 0x1D, 0x1D),
|
||||
(0xAD, 0x0C, 0x40, 0x46, 0x00, 0x07, 0x4B, 0x4B, 0xFF, 0xFF, 0xF0, 0x40, 0x0E, 0x01, 0x07, 0x42, 0x42, 0xFF, 0xFF, 0x01, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF),
|
||||
(0xAE, 0xF0, 0xFF, 0x03, 0xF0, 0xFF, 0x03, 0x00),
|
||||
(0xB2, 0x15, 0x19, 0x05, 0x23, 0x49, 0x2D, 0x03, 0x2E, 0x5C, 0xD2, 0xFF, 0x10, 0x60, 0xFD, 0x20, 0xC0, 0x00),
|
||||
(0xE8, 0x20, 0x60, 0x04, 0x8E, 0x8E, 0x3E, 0x04, 0xDC, 0xDC, 0x3E, 0x06, 0xFA, 0x26, 0x3E),
|
||||
(0x75, 0x03, 0x04),
|
||||
(0xE7, 0x4B, 0x00, 0x00, 0xBE, 0x4B, 0x8C, 0x20, 0x1A, 0xF0, 0x7D, 0x14, 0x7D, 0x14, 0x7D, 0x14, 0x7D, 0x14, 0xFF, 0x00, 0x32, 0x30, 0x73, 0x00, 0x00, 0xC8, 0x6A, 0xFF, 0x5A, 0x64, 0x38, 0x88, 0x15, 0xB1, 0x01, 0x01, 0x64, 0x01, 0x01, 0x7C, 0xFF, 0x1A, 0x51),
|
||||
(0xE1, 0x0C, 0x0C),
|
||||
(0xEA, 0x15, 0x00, 0x01),
|
||||
(0xC8, 0x00, 0x00, 0x04, 0x08, 0x10, 0x00, 0x1F, 0x01, 0x39, 0x3E, 0x00, 0x78, 0x06, 0xE2, 0x02, 0x11, 0x33, 0x01, 0x7A, 0x0D, 0x21, 0xC4, 0x0B, 0x19, 0x08, 0x32, 0xA0, 0x08, 0x1A, 0x0A, 0xF3, 0x7F, 0x0E, 0xC5, 0xE8, 0x03, 0xFF),
|
||||
(0xC9, 0x00, 0x00, 0x04, 0x08, 0x10, 0x00, 0x1F, 0x01, 0x39, 0x3E, 0x00, 0x78, 0x06, 0xE2, 0x02, 0x11, 0x33, 0x01, 0x7A, 0x0D, 0x21, 0xC4, 0x0B, 0x19, 0x08, 0x32, 0xA0, 0x08, 0x1A, 0x0A, 0xF3, 0x7F, 0x0E, 0xC5, 0xE8, 0x03, 0xFF),
|
||||
(0x60, 0x71, 0x21, 0x00),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
from esphome.components.mipi import DriverChip
|
||||
import esphome.config_validation as cv
|
||||
from . import DsiDriverChip
|
||||
|
||||
# Standalone display
|
||||
# Product page: https://www.seeedstudio.com/reTerminal-D1001-p-6729.html
|
||||
DriverChip(
|
||||
DsiDriverChip(
|
||||
"SEEED-RETERMINAL-D1001",
|
||||
height=1280,
|
||||
width=800,
|
||||
@@ -15,10 +14,10 @@ DriverChip(
|
||||
vsync_front_porch=30,
|
||||
pclk_frequency="80MHz",
|
||||
lane_bit_rate="1.5Gbps",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
enable_pin=[{"xl9535": None, "number": 0}, {"xl9535": None, "number": 7}],
|
||||
reset_pin={"xl9535": None, "number": 2},
|
||||
requires={"psram", "xl9535"},
|
||||
initsequence=(
|
||||
(0xE0, 0x00),
|
||||
(0xE1, 0x93),
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from esphome.components.mipi import DriverChip
|
||||
import esphome.config_validation as cv
|
||||
from . import DsiDriverChip
|
||||
|
||||
# fmt: off
|
||||
|
||||
# Source for parameters and initsequence:
|
||||
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365_10_1
|
||||
# Product page: https://www.waveshare.com/wiki/ESP32-P4-Nano-StartPage
|
||||
JD9365_10_1_DSI_TOUCH_A = DriverChip(
|
||||
JD9365_10_1_DSI_TOUCH_A = DsiDriverChip(
|
||||
"WAVESHARE-P4-NANO-10.1",
|
||||
height=1280,
|
||||
width=800,
|
||||
@@ -18,7 +17,6 @@ JD9365_10_1_DSI_TOUCH_A = DriverChip(
|
||||
vsync_front_porch=30,
|
||||
pclk_frequency="80MHz",
|
||||
lane_bit_rate="1.5Gbps",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
initsequence=[
|
||||
(0xE0, 0x00), # select userpage
|
||||
@@ -65,7 +63,7 @@ JD9365_10_1_DSI_TOUCH_A.extend(
|
||||
# Source for parameters and initsequence:
|
||||
# https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_st7703
|
||||
# Product page: https://www.waveshare.com/wiki/ESP32-P4-86-Panel-ETH-2RO
|
||||
DriverChip(
|
||||
DsiDriverChip(
|
||||
"WAVESHARE-P4-86-PANEL",
|
||||
height=720,
|
||||
width=720,
|
||||
@@ -77,7 +75,6 @@ DriverChip(
|
||||
vsync_front_porch=20,
|
||||
pclk_frequency="38MHz",
|
||||
lane_bit_rate="480Mbps",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
reset_pin=27,
|
||||
initsequence=[
|
||||
@@ -109,7 +106,7 @@ DriverChip(
|
||||
# Source for parameters and initsequence:
|
||||
# https://github.com/espressif/esp-iot-solution/tree/master/components/display/lcd/esp_lcd_ek79007
|
||||
# Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-7B
|
||||
DriverChip(
|
||||
DsiDriverChip(
|
||||
"WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-7B",
|
||||
height=600,
|
||||
width=1024,
|
||||
@@ -139,7 +136,7 @@ DriverChip(
|
||||
# Source for parameters and initsequence:
|
||||
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365
|
||||
# Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-3.4C
|
||||
JD9365_3_4_DSI_TOUCH_C = DriverChip(
|
||||
JD9365_3_4_DSI_TOUCH_C = DsiDriverChip(
|
||||
"WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-3.4C",
|
||||
height=800,
|
||||
width=800,
|
||||
@@ -151,7 +148,6 @@ JD9365_3_4_DSI_TOUCH_C = DriverChip(
|
||||
vsync_front_porch=24,
|
||||
pclk_frequency="80MHz",
|
||||
lane_bit_rate="1.5Gbps",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
initsequence=[
|
||||
(0xE0, 0x00), # select userpage
|
||||
@@ -197,7 +193,7 @@ JD9365_3_4_DSI_TOUCH_C.extend(
|
||||
# Source for parameters and initsequence:
|
||||
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365
|
||||
# Product page: https://www.waveshare.com/wiki/ESP32-P4-WIFI6-Touch-LCD-4C
|
||||
JD9365_4_DSI_TOUCH_C = DriverChip(
|
||||
JD9365_4_DSI_TOUCH_C = DsiDriverChip(
|
||||
"WAVESHARE-ESP32-P4-WIFI6-TOUCH-LCD-4C",
|
||||
height=720,
|
||||
width=720,
|
||||
@@ -209,7 +205,6 @@ JD9365_4_DSI_TOUCH_C = DriverChip(
|
||||
vsync_front_porch=24,
|
||||
pclk_frequency="80MHz",
|
||||
lane_bit_rate="1.5Gbps",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
initsequence=[
|
||||
(0xE0, 0x00), # select userpage
|
||||
@@ -255,7 +250,7 @@ JD9365_4_DSI_TOUCH_C.extend(
|
||||
# Source for parameters and initsequence:
|
||||
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_jd9365
|
||||
# Product page: https://www.waveshare.com/wiki/8-DSI-TOUCH-A
|
||||
DriverChip(
|
||||
DsiDriverChip(
|
||||
"WAVESHARE-8-DSI-TOUCH-A",
|
||||
height=1280,
|
||||
width=800,
|
||||
@@ -267,7 +262,6 @@ DriverChip(
|
||||
vsync_front_porch=30,
|
||||
pclk_frequency="80MHz",
|
||||
lane_bit_rate="1.5Gbps",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
initsequence=[
|
||||
(0xE0, 0x00), # select userpage
|
||||
@@ -304,7 +298,7 @@ DriverChip(
|
||||
# Source for parameters and initsequence:
|
||||
# https://github.com/waveshareteam/Waveshare-ESP32-components/tree/master/display/lcd/esp_lcd_ili9881c
|
||||
# Product page: https://www.waveshare.com/wiki/7-DSI-TOUCH-A
|
||||
DriverChip(
|
||||
DsiDriverChip(
|
||||
"WAVESHARE-7-DSI-TOUCH-A",
|
||||
height=1280,
|
||||
width=720,
|
||||
|
||||
@@ -18,6 +18,8 @@ from esphome.components.mipi import (
|
||||
CONF_HSYNC_BACK_PORCH,
|
||||
CONF_HSYNC_FRONT_PORCH,
|
||||
CONF_HSYNC_PULSE_WIDTH,
|
||||
CONF_PCLK_FREQUENCY,
|
||||
CONF_PCLK_INVERTED,
|
||||
CONF_PCLK_PIN,
|
||||
CONF_PIXEL_MODE,
|
||||
CONF_USE_AXIS_FLIPS,
|
||||
@@ -34,9 +36,11 @@ from esphome.components.mipi import (
|
||||
power_of_two,
|
||||
requires_buffer,
|
||||
)
|
||||
from esphome.components.rpi_dpi_rgb.display import (
|
||||
CONF_PCLK_FREQUENCY,
|
||||
CONF_PCLK_INVERTED,
|
||||
from esphome.components.spi import (
|
||||
CONF_SPI_MODE,
|
||||
SPI_DATA_RATE_SCHEMA,
|
||||
SPI_MODE_OPTIONS,
|
||||
SPIComponent,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -48,7 +52,6 @@ from esphome.const import (
|
||||
CONF_DATA_RATE,
|
||||
CONF_DC_PIN,
|
||||
CONF_DIMENSIONS,
|
||||
CONF_DISABLED,
|
||||
CONF_ENABLE_PIN,
|
||||
CONF_GREEN,
|
||||
CONF_HSYNC_PIN,
|
||||
@@ -57,8 +60,6 @@ from esphome.const import (
|
||||
CONF_INIT_SEQUENCE,
|
||||
CONF_INVERT_COLORS,
|
||||
CONF_LAMBDA,
|
||||
CONF_MIRROR_X,
|
||||
CONF_MIRROR_Y,
|
||||
CONF_MODEL,
|
||||
CONF_NUMBER,
|
||||
CONF_RED,
|
||||
@@ -72,10 +73,10 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.final_validate import full_config
|
||||
|
||||
from ..spi import CONF_SPI_MODE, SPI_DATA_RATE_SCHEMA, SPI_MODE_OPTIONS, SPIComponent
|
||||
from . import models
|
||||
from .models import RgbDriverChip
|
||||
|
||||
DEPENDENCIES = ["esp32", "psram"]
|
||||
DEPENDENCIES = ["esp32"]
|
||||
|
||||
mipi_rgb_ns = cg.esphome_ns.namespace("mipi_rgb")
|
||||
mipi_rgb = mipi_rgb_ns.class_("MipiRgb", display.Display, cg.Component)
|
||||
@@ -86,7 +87,7 @@ ColorOrder = display.display_ns.enum("ColorMode")
|
||||
|
||||
DATA_PIN_SCHEMA = pins.internal_gpio_output_pin_schema
|
||||
|
||||
DriverChip("CUSTOM")
|
||||
RgbDriverChip("CUSTOM")
|
||||
|
||||
# Import all models dynamically from the models package
|
||||
|
||||
@@ -120,16 +121,7 @@ def data_pin_set(length):
|
||||
|
||||
def model_schema(config):
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
transform = cv.Any(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_MIRROR_X): cv.boolean,
|
||||
cv.Required(CONF_MIRROR_Y): cv.boolean,
|
||||
**model.swap_xy_schema(),
|
||||
}
|
||||
),
|
||||
cv.one_of(CONF_DISABLED, lower=True),
|
||||
)
|
||||
transform = model.transform_schema()
|
||||
# RPI model does not use an init sequence, indicates with empty list
|
||||
if model.initsequence is None:
|
||||
# Custom model requires an init sequence
|
||||
@@ -235,6 +227,7 @@ def _config_schema(config):
|
||||
only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]),
|
||||
)(config)
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
model.check_requirements()
|
||||
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
|
||||
model.get_dimensions(config)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from esphome.components.mipi import DriverChip
|
||||
from esphome.const import CONF_SWAP_XY
|
||||
|
||||
|
||||
class RgbDriverChip(DriverChip):
|
||||
"""A driver chip for MIPI RGB displays."""
|
||||
|
||||
@property
|
||||
def transforms(self) -> set[str]:
|
||||
"""
|
||||
Return the set of transformations supported by this driver chip.
|
||||
RGB displays do not support axis swapping, so this method removes CONF_SWAP_XY
|
||||
"""
|
||||
return super().transforms - {CONF_SWAP_XY}
|
||||
@@ -5,6 +5,7 @@ st7701s.extend(
|
||||
width=480,
|
||||
height=480,
|
||||
data_rate="2MHz",
|
||||
requires={"psram"},
|
||||
cs_pin=39,
|
||||
de_pin=18,
|
||||
hsync_pin=16,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from esphome.config_validation import UNDEFINED
|
||||
|
||||
from .st7701s import ST7701S
|
||||
|
||||
# fmt: off
|
||||
@@ -8,10 +6,10 @@ ST7701S(
|
||||
width=480,
|
||||
height=480,
|
||||
invert_colors=False,
|
||||
swap_xy=UNDEFINED,
|
||||
spi_mode="MODE3",
|
||||
cs_pin={"xl9535": None, "number": 17},
|
||||
reset_pin={"xl9535": None, "number": 5},
|
||||
requires={"psram", "xl9535"},
|
||||
hsync_pin=39,
|
||||
vsync_pin=40,
|
||||
pclk_pin=41,
|
||||
@@ -57,9 +55,9 @@ t_rgb = ST7701S(
|
||||
height=480,
|
||||
pixel_mode="18bit",
|
||||
invert_colors=False,
|
||||
swap_xy=UNDEFINED,
|
||||
spi_mode="MODE3",
|
||||
cs_pin={"xl9535": None, "number": 3},
|
||||
requires={"psram", "xl9535"},
|
||||
de_pin=45,
|
||||
hsync_pin=47,
|
||||
vsync_pin=41,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
from esphome.components.mipi import DriverChip
|
||||
from esphome.config_validation import UNDEFINED
|
||||
from . import RgbDriverChip
|
||||
|
||||
# A driver chip for Raspberry Pi MIPI RGB displays. These require no init sequence
|
||||
DriverChip(
|
||||
RgbDriverChip(
|
||||
"RPI",
|
||||
swap_xy=UNDEFINED,
|
||||
initsequence=(),
|
||||
)
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
from esphome.components.mipi import (
|
||||
MADCTL,
|
||||
MADCTL_ML,
|
||||
MADCTL_XFLIP,
|
||||
MODE_BGR,
|
||||
DriverChip,
|
||||
)
|
||||
from esphome.config_validation import UNDEFINED
|
||||
from esphome.components.mipi import MADCTL, MADCTL_ML, MADCTL_XFLIP, MODE_BGR
|
||||
from esphome.const import CONF_COLOR_ORDER, CONF_HEIGHT, CONF_MIRROR_X, CONF_MIRROR_Y
|
||||
|
||||
from . import RgbDriverChip
|
||||
|
||||
SDIR_CMD = 0xC7
|
||||
|
||||
|
||||
class ST7701S(DriverChip):
|
||||
class ST7701S(RgbDriverChip):
|
||||
# The ST7701s does not use the standard MADCTL bits for x/y mirroring
|
||||
def add_madctl(self, sequence: list, config: dict):
|
||||
transform = self.get_transform(config)
|
||||
@@ -45,7 +40,6 @@ st7701s = ST7701S(
|
||||
"ST7701S",
|
||||
width=480,
|
||||
height=864,
|
||||
swap_xy=UNDEFINED,
|
||||
hsync_front_porch=20,
|
||||
hsync_back_porch=10,
|
||||
hsync_pulse_width=10,
|
||||
@@ -85,6 +79,7 @@ st7701s.extend(
|
||||
height=480,
|
||||
invert_colors=True,
|
||||
pixel_mode="18bit",
|
||||
requires={"psram"},
|
||||
cs_pin=1,
|
||||
de_pin={
|
||||
"number": 45,
|
||||
@@ -117,6 +112,7 @@ st7701s.extend(
|
||||
vsync_pulse_width=8,
|
||||
vsync_back_porch=20,
|
||||
cs_pin={"pca9554": None, "number": 4},
|
||||
requires={"psram", "pca9554"},
|
||||
de_pin=18,
|
||||
hsync_pin=16,
|
||||
vsync_pin=17,
|
||||
@@ -134,6 +130,7 @@ st7701s.extend(
|
||||
width=480,
|
||||
height=480,
|
||||
pixel_mode="18bit",
|
||||
requires={"psram"},
|
||||
cs_pin=18,
|
||||
reset_pin=8,
|
||||
de_pin=17,
|
||||
@@ -177,6 +174,7 @@ st7701s.extend(
|
||||
width=480,
|
||||
height=480,
|
||||
pixel_mode="18bit",
|
||||
requires={"psram"},
|
||||
cs_pin=21,
|
||||
de_pin=39,
|
||||
vsync_pin=48,
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
from esphome.components.mipi import DriverChip
|
||||
from esphome.config_validation import UNDEFINED
|
||||
from . import RgbDriverChip
|
||||
|
||||
# fmt: off
|
||||
sunton = DriverChip(
|
||||
sunton = RgbDriverChip(
|
||||
"ESP32-8048S070",
|
||||
swap_xy=UNDEFINED,
|
||||
initsequence=(),
|
||||
width=800,
|
||||
height=480,
|
||||
pclk_frequency="12.5MHz",
|
||||
requires={"psram"},
|
||||
de_pin=41,
|
||||
hsync_pin=39,
|
||||
vsync_pin=40,
|
||||
@@ -28,7 +27,6 @@ sunton = DriverChip(
|
||||
|
||||
sunton.extend(
|
||||
"ESP32-8048S050",
|
||||
swap_xy=UNDEFINED,
|
||||
initsequence=(),
|
||||
width=800,
|
||||
height=480,
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
from esphome.components.mipi import DriverChip, delay
|
||||
from esphome.config_validation import UNDEFINED
|
||||
from esphome.components.mipi import delay
|
||||
|
||||
from . import RgbDriverChip
|
||||
from .st7701s import st7701s
|
||||
|
||||
# fmt: off
|
||||
wave_4_3 = DriverChip(
|
||||
wave_4_3 = RgbDriverChip(
|
||||
"ESP32-S3-TOUCH-LCD-4.3",
|
||||
swap_xy=UNDEFINED,
|
||||
initsequence=(),
|
||||
width=800,
|
||||
height=480,
|
||||
pclk_frequency="16MHz",
|
||||
reset_pin={"ch422g": None, "number": 3},
|
||||
enable_pin={"ch422g": None, "number": 2},
|
||||
requires={"psram", "ch422g"},
|
||||
de_pin=5,
|
||||
hsync_pin={"number": 46, "ignore_strapping_warning": True},
|
||||
vsync_pin={"number": 3, "ignore_strapping_warning": True},
|
||||
@@ -69,6 +69,7 @@ st7701s.extend(
|
||||
pclk_pin=41,
|
||||
pclk_frequency="12MHz",
|
||||
pclk_inverted=False,
|
||||
requires={"psram"},
|
||||
data_pins={
|
||||
"red": [46, 3, 8, 18, 17],
|
||||
"green": [14, 13, 12, 11, 10, 9],
|
||||
@@ -80,6 +81,7 @@ st7701s.extend(
|
||||
"WAVESHARE-3.16-320X820",
|
||||
width=320,
|
||||
height=820,
|
||||
requires={"psram"},
|
||||
de_pin=40,
|
||||
hsync_pin=38,
|
||||
vsync_pin=39,
|
||||
|
||||
@@ -41,14 +41,11 @@ from esphome.const import (
|
||||
CONF_DATA_RATE,
|
||||
CONF_DC_PIN,
|
||||
CONF_DIMENSIONS,
|
||||
CONF_DISABLED,
|
||||
CONF_ENABLE_PIN,
|
||||
CONF_ID,
|
||||
CONF_INIT_SEQUENCE,
|
||||
CONF_INVERT_COLORS,
|
||||
CONF_LAMBDA,
|
||||
CONF_MIRROR_X,
|
||||
CONF_MIRROR_Y,
|
||||
CONF_MODEL,
|
||||
CONF_RESET_PIN,
|
||||
CONF_ROTATION,
|
||||
@@ -138,16 +135,7 @@ def denominator(config):
|
||||
def model_schema(config):
|
||||
model = MODELS[config[CONF_MODEL]]
|
||||
bus_mode = config[CONF_BUS_MODE]
|
||||
transform = cv.Any(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_MIRROR_X): cv.boolean,
|
||||
cv.Required(CONF_MIRROR_Y): cv.boolean,
|
||||
**model.swap_xy_schema(),
|
||||
}
|
||||
),
|
||||
cv.one_of(CONF_DISABLED, lower=True),
|
||||
)
|
||||
transform = model.transform_schema()
|
||||
# CUSTOM model will need to provide a custom init sequence
|
||||
iseqconf = (
|
||||
cv.Required(CONF_INIT_SEQUENCE)
|
||||
@@ -265,6 +253,7 @@ def customise_schema(config):
|
||||
extra=ALLOW_EXTRA,
|
||||
)(config)
|
||||
model = MODELS[config[CONF_MODEL]]
|
||||
model.check_requirements()
|
||||
bus_modes = (TYPE_SINGLE, TYPE_QUAD, TYPE_OCTAL)
|
||||
config = cv.Schema(
|
||||
{
|
||||
@@ -408,7 +397,7 @@ def get_instance(config):
|
||||
async def to_code(config):
|
||||
model = MODELS[config[CONF_MODEL]]
|
||||
var_id = config[CONF_ID]
|
||||
init_sequence = model.get_sequence(config, False)
|
||||
init_sequence = model.get_sequence(config, add_madctl=False, add_reset=True)
|
||||
var_id.type, templateargs = get_instance(config)
|
||||
var = cg.new_Pvariable(var_id, TemplateArguments(*templateargs))
|
||||
cg.add(var.set_init_sequence(init_sequence))
|
||||
|
||||
@@ -13,6 +13,8 @@ constexpr static const char *const TAG = "display.mipi_spi";
|
||||
|
||||
// Maximum bytes to log for commands (truncated if larger)
|
||||
static constexpr size_t MIPI_SPI_MAX_CMD_LOG_BYTES = 64;
|
||||
|
||||
// Command codes for MIPI SPI displays. Not all currently used, kept here for reference.
|
||||
static constexpr uint8_t SW_RESET_CMD = 0x01;
|
||||
static constexpr uint8_t SLEEP_OUT = 0x11;
|
||||
static constexpr uint8_t NORON = 0x13;
|
||||
@@ -151,14 +153,11 @@ class MipiSpi : public display::Display,
|
||||
this->reset_pin_->digital_write(false);
|
||||
delay(5);
|
||||
this->reset_pin_->digital_write(true);
|
||||
} else {
|
||||
// no reset pin, send software reset command
|
||||
this->write_command_(SW_RESET_CMD);
|
||||
// required delay after reset is already in the init sequence, don't duplicate
|
||||
}
|
||||
|
||||
// need to know when the display is ready for SLPOUT command - will be 120ms after reset
|
||||
auto when = millis() + 120;
|
||||
delay(10);
|
||||
size_t index = 0;
|
||||
auto &vec = this->init_sequence_;
|
||||
while (index != vec.size()) {
|
||||
@@ -170,6 +169,9 @@ class MipiSpi : public display::Display,
|
||||
uint8_t cmd = vec[index++];
|
||||
uint8_t x = vec[index++];
|
||||
if (x == DELAY_FLAG) {
|
||||
if (cmd == 0) {
|
||||
cmd = clamp_at_least((int) (when - millis()), 0);
|
||||
}
|
||||
esph_log_d(TAG, "Delay %dms", cmd);
|
||||
delay(cmd);
|
||||
} else {
|
||||
@@ -179,24 +181,9 @@ class MipiSpi : public display::Display,
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
switch (cmd) {
|
||||
case SLEEP_OUT: {
|
||||
// are we ready, boots?
|
||||
int duration = when - millis();
|
||||
if (duration > 0) {
|
||||
esph_log_d(TAG, "Sleep %dms", duration);
|
||||
delay(duration);
|
||||
}
|
||||
} break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
const auto *ptr = vec.data() + index;
|
||||
this->write_command_(cmd, ptr, num_args);
|
||||
index += num_args;
|
||||
if (cmd == SLEEP_OUT)
|
||||
delay(10);
|
||||
}
|
||||
}
|
||||
this->reset_params_();
|
||||
@@ -398,10 +385,10 @@ class MipiSpi : public display::Display,
|
||||
* @param ptr The pointer to the pixel data
|
||||
* @param w Width of each line in bytes
|
||||
* @param h Height of the buffer in rows
|
||||
* @param pad Padding in bytes after each line
|
||||
* @param stride Total length of each line in bytes, including any padding
|
||||
*/
|
||||
void write_display_data_(const uint8_t *ptr, size_t w, size_t h, size_t pad) {
|
||||
if (pad == 0) {
|
||||
void write_display_data_(const uint8_t *ptr, size_t w, size_t h, size_t stride) {
|
||||
if (stride == w) {
|
||||
if constexpr (BUS_TYPE == BUS_TYPE_SINGLE || BUS_TYPE == BUS_TYPE_SINGLE_16) {
|
||||
this->write_array(ptr, w * h);
|
||||
} else if constexpr (BUS_TYPE == BUS_TYPE_QUAD) {
|
||||
@@ -418,7 +405,7 @@ class MipiSpi : public display::Display,
|
||||
} else if constexpr (BUS_TYPE == BUS_TYPE_OCTAL) {
|
||||
this->write_cmd_addr_data(0, 0, 0, 0, ptr, w, 8);
|
||||
}
|
||||
ptr += w + pad;
|
||||
ptr += stride;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -436,7 +423,7 @@ class MipiSpi : public display::Display,
|
||||
ptr += y_offset * (x_offset + w + x_pad) + x_offset;
|
||||
if constexpr (BUFFERPIXEL == DISPLAYPIXEL) {
|
||||
this->write_display_data_(reinterpret_cast<const uint8_t *>(ptr), w * sizeof(BUFFERTYPE), h,
|
||||
x_pad * sizeof(BUFFERTYPE));
|
||||
(x_offset + w + x_pad) * sizeof(BUFFERTYPE));
|
||||
} else {
|
||||
// type conversion required, do it in chunks
|
||||
uint8_t dbuffer[DISPLAYPIXEL * 48];
|
||||
@@ -472,14 +459,14 @@ class MipiSpi : public display::Display,
|
||||
}
|
||||
// buffer full? Flush.
|
||||
if (dptr == dbuffer + sizeof(dbuffer)) {
|
||||
this->write_display_data_(dbuffer, sizeof(dbuffer), 1, 0);
|
||||
this->write_display_data_(dbuffer, sizeof(dbuffer), 1, sizeof(dbuffer));
|
||||
dptr = dbuffer;
|
||||
}
|
||||
}
|
||||
}
|
||||
// flush any remaining data
|
||||
if (dptr != dbuffer) {
|
||||
this->write_display_data_(dbuffer, dptr - dbuffer, 1, 0);
|
||||
this->write_display_data_(dbuffer, dptr - dbuffer, 1, dptr - dbuffer);
|
||||
}
|
||||
}
|
||||
this->disable();
|
||||
|
||||
@@ -13,6 +13,7 @@ ST7789V.extend(
|
||||
mirror_x=True,
|
||||
mirror_y=True,
|
||||
data_rate="80MHz",
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
ST7789V.extend(
|
||||
@@ -25,4 +26,5 @@ ST7789V.extend(
|
||||
dc_pin=39,
|
||||
reset_pin=40,
|
||||
invert_colors=True,
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ from esphome.components.mipi import (
|
||||
delay,
|
||||
)
|
||||
from esphome.components.spi import TYPE_QUAD
|
||||
from esphome.config_validation import UNDEFINED
|
||||
from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y
|
||||
|
||||
DriverChip(
|
||||
"T-DISPLAY-S3-AMOLED",
|
||||
@@ -29,6 +29,7 @@ DriverChip(
|
||||
brightness=0xD0,
|
||||
color_order=MODE_RGB,
|
||||
no_slpout=True, # SLPOUT is in the init sequence, early
|
||||
requires={"psram"},
|
||||
initsequence=(SLPOUT,),
|
||||
)
|
||||
|
||||
@@ -43,6 +44,7 @@ DriverChip(
|
||||
data_rate="40MHz",
|
||||
brightness=0xD0,
|
||||
color_order=MODE_RGB,
|
||||
requires={"psram"},
|
||||
initsequence=(
|
||||
(PAGESEL, 4),
|
||||
(0x6A, 0x00),
|
||||
@@ -90,6 +92,7 @@ T4_S3_AMOLED = RM690B0.extend(
|
||||
reset_pin=13,
|
||||
enable_pin=9,
|
||||
bus_mode=TYPE_QUAD,
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
CO5300 = DriverChip(
|
||||
@@ -98,7 +101,7 @@ CO5300 = DriverChip(
|
||||
color_order=MODE_RGB,
|
||||
bus_mode=TYPE_QUAD,
|
||||
no_slpout=True,
|
||||
swap_xy=UNDEFINED,
|
||||
transforms={CONF_MIRROR_X, CONF_MIRROR_Y},
|
||||
width=480,
|
||||
height=480,
|
||||
initsequence=(
|
||||
|
||||
@@ -314,6 +314,7 @@ DriverChip(
|
||||
data_rate="40MHz",
|
||||
dc_pin=4,
|
||||
cs_pin=5,
|
||||
requires={"psram"},
|
||||
# reset_pin={CONF_INVERTED: True, CONF_NUMBER: 48},
|
||||
initsequence=(
|
||||
(0xEF, 0x03, 0x80, 0x02),
|
||||
@@ -379,6 +380,7 @@ DriverChip(
|
||||
cs_pin=5,
|
||||
dc_pin=4,
|
||||
reset_pin=48,
|
||||
requires={"psram"},
|
||||
initsequence=(
|
||||
(0xEF, 0x03, 0x80, 0x02),
|
||||
(0xCF, 0x00, 0xC1, 0x30),
|
||||
@@ -711,6 +713,7 @@ ST7796.extend(
|
||||
reset_pin=4,
|
||||
dc_pin={"number": 0, "ignore_strapping_warning": True},
|
||||
invert_colors=True,
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
ST7789V.extend(
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
from esphome.components.mipi import MODE_RGB, DriverChip
|
||||
from esphome.components.spi import TYPE_QUAD
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_IGNORE_STRAPPING_WARNING, CONF_NUMBER
|
||||
from esphome.const import (
|
||||
CONF_IGNORE_STRAPPING_WARNING,
|
||||
CONF_MIRROR_X,
|
||||
CONF_MIRROR_Y,
|
||||
CONF_NUMBER,
|
||||
)
|
||||
|
||||
AXS15231 = DriverChip(
|
||||
"AXS15231",
|
||||
draw_rounding=8,
|
||||
swap_xy=cv.UNDEFINED,
|
||||
transforms={CONF_MIRROR_X, CONF_MIRROR_Y},
|
||||
color_order=MODE_RGB,
|
||||
bus_mode=TYPE_QUAD,
|
||||
no_swreset=True,
|
||||
initsequence=(
|
||||
(0xBB, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5A, 0xA5),
|
||||
(0xC1, 0x33),
|
||||
@@ -22,6 +27,7 @@ AXS15231.extend(
|
||||
height=480,
|
||||
cs_pin={CONF_NUMBER: 45, CONF_IGNORE_STRAPPING_WARNING: True},
|
||||
data_rate="40MHz",
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
DriverChip(
|
||||
@@ -36,6 +42,7 @@ DriverChip(
|
||||
color_order=MODE_RGB,
|
||||
bus_mode=TYPE_QUAD,
|
||||
data_rate="40MHz",
|
||||
requires={"psram"},
|
||||
initsequence=(
|
||||
(0xF0, 0x08),
|
||||
(0xF2, 0x08),
|
||||
@@ -267,6 +274,7 @@ DriverChip(
|
||||
color_order=MODE_RGB,
|
||||
bus_mode=TYPE_QUAD,
|
||||
data_rate="40MHz",
|
||||
requires={"psram"},
|
||||
initsequence=(
|
||||
(0xF0, 0x28),
|
||||
(0xF2, 0x28),
|
||||
@@ -495,6 +503,7 @@ DriverChip(
|
||||
color_order=MODE_RGB,
|
||||
bus_mode=TYPE_QUAD,
|
||||
data_rate="20MHz",
|
||||
requires={"psram"},
|
||||
initsequence=(
|
||||
(0xFF, 0xA5),
|
||||
(0x41, 0x03),
|
||||
|
||||
@@ -10,4 +10,5 @@ ST7789V.extend(
|
||||
cs_pin=22,
|
||||
dc_pin=21,
|
||||
reset_pin=18,
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ ST7789V.extend(
|
||||
dc_pin=13,
|
||||
reset_pin=9,
|
||||
data_rate="80MHz",
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
ST7789V.extend(
|
||||
@@ -42,6 +43,7 @@ ST7789V.extend(
|
||||
enable_pin=[9, 15],
|
||||
data_rate="10MHz",
|
||||
bus_mode=TYPE_OCTAL,
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
ST7796.extend(
|
||||
@@ -55,4 +57,5 @@ ST7796.extend(
|
||||
dc_pin=9,
|
||||
backlight_pin=48,
|
||||
invert_colors=True,
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
@@ -49,6 +49,7 @@ ILI9341.extend(
|
||||
invert_colors=True,
|
||||
pixel_mode="18bit",
|
||||
data_rate="40MHz",
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
GC9107 = ST7789V.extend(
|
||||
@@ -68,4 +69,5 @@ GC9107.extend(
|
||||
reset_pin=48,
|
||||
dc_pin=42,
|
||||
cs_pin=14,
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ from esphome.components.mipi import (
|
||||
PWSET,
|
||||
DriverChip,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_MIRROR_X, CONF_MIRROR_Y
|
||||
|
||||
from .amoled import CO5300
|
||||
from .ili import ILI9488_A, ST7789V
|
||||
@@ -155,7 +155,7 @@ ST7789P = DriverChip(
|
||||
|
||||
ILI9488_A.extend(
|
||||
"PICO-RESTOUCH-LCD-3.5",
|
||||
swap_xy=cv.UNDEFINED,
|
||||
transforms={CONF_MIRROR_X, CONF_MIRROR_Y},
|
||||
spi_16=True,
|
||||
pixel_mode="16bit",
|
||||
mirror_x=True,
|
||||
@@ -175,6 +175,7 @@ CO5300.extend(
|
||||
offset_width=6,
|
||||
cs_pin=12,
|
||||
reset_pin=39,
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
# Waveshare ESP32-S3 Touch AMOLED 2.16" (CO5300 controller)
|
||||
@@ -189,6 +190,7 @@ CO5300.extend(
|
||||
cs_pin=12,
|
||||
reset_pin=39,
|
||||
data_rate="40MHz",
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
AXS15231.extend(
|
||||
@@ -198,6 +200,7 @@ AXS15231.extend(
|
||||
data_rate="80MHz",
|
||||
cs_pin=9,
|
||||
reset_pin=21,
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
# Waveshare 1.83-v2
|
||||
@@ -281,6 +284,7 @@ ST7789V.extend(
|
||||
offset_height=40,
|
||||
invert_colors=True,
|
||||
data_rate="40MHz",
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
CO5300.extend(
|
||||
@@ -291,4 +295,5 @@ CO5300.extend(
|
||||
cs_pin=9,
|
||||
reset_pin=21,
|
||||
enable_pin=1,
|
||||
requires={"psram"},
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "nextion.h"
|
||||
|
||||
#include <cinttypes>
|
||||
#include <new>
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
@@ -352,8 +353,9 @@ void Nextion::loop() {
|
||||
this->connection_state_.ignore_is_setup_ = false;
|
||||
}
|
||||
|
||||
this->process_serial_(); // Receive serial data
|
||||
this->process_nextion_commands_(); // Process nextion return commands
|
||||
this->process_serial_(); // Receive serial data
|
||||
this->process_nextion_commands_(); // Process nextion return commands
|
||||
this->purge_stale_queue_entries_(); // Drop expired entries even when the display sends no data
|
||||
|
||||
if (!this->connection_state_.nextion_reports_is_setup_) {
|
||||
if (this->started_ms_ == 0)
|
||||
@@ -902,6 +904,11 @@ void Nextion::process_nextion_commands_() {
|
||||
this->command_data_.erase(0, to_process_length + DELIMITER_SIZE + 1);
|
||||
}
|
||||
|
||||
ESP_LOGN(TAG, "Loop end");
|
||||
this->process_serial_();
|
||||
} // Nextion::process_nextion_commands_()
|
||||
|
||||
void Nextion::purge_stale_queue_entries_() {
|
||||
const uint32_t ms = App.get_loop_component_start_time();
|
||||
|
||||
if (this->max_q_age_ms_ > 0 && !this->nextion_queue_.empty() &&
|
||||
@@ -927,10 +934,7 @@ void Nextion::process_nextion_commands_() {
|
||||
}
|
||||
}
|
||||
}
|
||||
ESP_LOGN(TAG, "Loop end");
|
||||
// App.feed_wdt(); Remove before master merge
|
||||
this->process_serial_();
|
||||
} // Nextion::process_nextion_commands_()
|
||||
}
|
||||
|
||||
void Nextion::set_nextion_sensor_state(int queue_type, const std::string &name, float state) {
|
||||
this->set_nextion_sensor_state(static_cast<NextionQueueType>(queue_type), name, state);
|
||||
@@ -1101,7 +1105,13 @@ void Nextion::add_no_result_to_queue_(const std::string &variable_name) {
|
||||
new (nextion_queue) nextion::NextionQueue();
|
||||
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
|
||||
nextion_queue->component = new nextion::NextionComponentBase;
|
||||
nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase;
|
||||
if (nextion_queue->component == nullptr) {
|
||||
ESP_LOGW(TAG, "Component alloc failed");
|
||||
nextion_queue->~NextionQueue();
|
||||
allocator.deallocate(nextion_queue, 1);
|
||||
return;
|
||||
}
|
||||
nextion_queue->component->set_variable_name(variable_name);
|
||||
|
||||
nextion_queue->queue_time = App.get_loop_component_start_time();
|
||||
@@ -1157,7 +1167,13 @@ void Nextion::add_no_result_to_queue_with_pending_command_(const std::string &va
|
||||
}
|
||||
new (nextion_queue) nextion::NextionQueue();
|
||||
|
||||
nextion_queue->component = new nextion::NextionComponentBase;
|
||||
nextion_queue->component = new (std::nothrow) nextion::NextionComponentBase;
|
||||
if (nextion_queue->component == nullptr) {
|
||||
ESP_LOGW(TAG, "Component alloc failed");
|
||||
nextion_queue->~NextionQueue();
|
||||
allocator.deallocate(nextion_queue, 1);
|
||||
return;
|
||||
}
|
||||
nextion_queue->component->set_variable_name(variable_name);
|
||||
nextion_queue->queue_time = App.get_loop_component_start_time();
|
||||
nextion_queue->pending_command = command; // Store command for retry
|
||||
|
||||
@@ -1486,6 +1486,10 @@ class Nextion final : public NextionBase, public PollingComponent, public uart::
|
||||
|
||||
void process_nextion_commands_();
|
||||
void process_serial_();
|
||||
/// Drop queue entries older than max_q_age_ms_. Called from loop() so it also runs when the
|
||||
/// display sends no data at all (disconnected or asleep), which would otherwise grow the queue
|
||||
/// without bound.
|
||||
void purge_stale_queue_entries_();
|
||||
uint16_t touch_sleep_timeout_ = 0;
|
||||
uint8_t wake_up_page_ = 255;
|
||||
#ifdef USE_NEXTION_CONF_START_UP_PAGE
|
||||
|
||||
@@ -69,7 +69,12 @@ from .const import (
|
||||
BOOTLOADER_ADAFRUIT_NRF52_SD140_V6,
|
||||
BOOTLOADER_ADAFRUIT_NRF52_SD140_V7,
|
||||
)
|
||||
from .framework import check_and_install, get_build_env, get_build_paths
|
||||
from .framework import (
|
||||
check_and_install,
|
||||
get_build_env,
|
||||
get_build_paths,
|
||||
setup_platformio_python_env,
|
||||
)
|
||||
|
||||
# force import gpio to register pin schema
|
||||
from .gpio import nrf52_pin_to_code # noqa: F401
|
||||
@@ -514,6 +519,7 @@ def _upload_using_platformio(
|
||||
) -> int | str:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
setup_platformio_python_env()
|
||||
if port is not None:
|
||||
upload_args += ["--upload-port", port]
|
||||
return toolchain.run_platformio_cli_run(config, CORE.verbose, *upload_args)
|
||||
@@ -809,6 +815,10 @@ def _copy_if_exists(src: Path, dst: Path) -> None:
|
||||
|
||||
def run_compile(args, config: ConfigType) -> bool:
|
||||
if CORE.using_toolchain_platformio:
|
||||
# The actual build is done by PlatformIO (the caller falls through to
|
||||
# it when this returns False); prepare the Python environment its
|
||||
# Zephyr build script expects first.
|
||||
setup_platformio_python_env()
|
||||
return False
|
||||
if not CORE.using_toolchain_sdk_nrf:
|
||||
raise EsphomeError(
|
||||
|
||||
@@ -4,6 +4,7 @@ import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import platformdirs
|
||||
@@ -27,6 +28,11 @@ _LOGGER = logging.getLogger(__name__)
|
||||
_REQUIREMENTS = Path(__file__).parent / "requirements.txt"
|
||||
TOOLCHAIN_VERSION = "0.17.4"
|
||||
|
||||
# Packages the PlatformIO toolchain's Zephyr build script needs beyond west
|
||||
# (which comes from requirements.txt). Keep the pin in sync with
|
||||
# framework-sdk-nrf scripts/platformio/platformio-build.py.
|
||||
_PLATFORMIO_PENV_REQUIREMENTS: tuple[str, ...] = ("cbor2==5.6.5",)
|
||||
|
||||
SDK_NG_TOOLCHAIN_MIRRORS = str_to_lst_of_str(
|
||||
os.environ.get(
|
||||
"ESPHOME_SDK_NG_TOOLCHAIN_MIRRORS",
|
||||
@@ -133,10 +139,94 @@ def get_build_env() -> dict:
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = str(venv_bin_dir) + os.pathsep + env.get("PATH", "")
|
||||
env["ZEPHYR_BASE"] = str(_get_framework_path(version) / "zephyr")
|
||||
env["Zephyr-sdk_DIR"] = str(_get_toolchain_path(TOOLCHAIN_VERSION) / "cmake")
|
||||
# ZEPHYR_SDK_INSTALL_DIR is the variable Zephyr documents for pointing at
|
||||
# the SDK: FindZephyr-sdk.cmake reads it (from the environment, via
|
||||
# zephyr_get) and passes it straight to find_package as a HINT. This
|
||||
# matters because the SDK lives in the esphome cache dir, which is not on
|
||||
# the module's static search path (/usr, /opt, $HOME, ...). A generic
|
||||
# "Zephyr-sdk_DIR" environment hint proved unreliable here: containerized
|
||||
# non-root builds failed to locate the SDK with it, while
|
||||
# ZEPHYR_SDK_INSTALL_DIR fixed the same invocation.
|
||||
env["ZEPHYR_SDK_INSTALL_DIR"] = str(_get_toolchain_path(TOOLCHAIN_VERSION))
|
||||
return env
|
||||
|
||||
|
||||
def _get_platformio_penv_path() -> Path:
|
||||
return get_sdk_nrf_tools_path() / "penvs" / "platformio"
|
||||
|
||||
|
||||
def _get_penv_site_packages(penv_path: Path) -> Path:
|
||||
if os.name == "nt":
|
||||
return penv_path / "Lib" / "site-packages"
|
||||
python_dir = f"python{sys.version_info.major}.{sys.version_info.minor}"
|
||||
return penv_path / "lib" / python_dir / "site-packages"
|
||||
|
||||
|
||||
def _prepend_env_path(name: str, entry: str) -> None:
|
||||
"""Prepend ``entry`` to the ``os.pathsep``-separated env var ``name``."""
|
||||
current = os.environ.get(name, "")
|
||||
entries = current.split(os.pathsep) if current else []
|
||||
if entry not in entries:
|
||||
os.environ[name] = os.pathsep.join([entry, *entries])
|
||||
|
||||
|
||||
def setup_platformio_python_env() -> None:
|
||||
"""Make the Zephyr build's Python packages available to PlatformIO.
|
||||
|
||||
The PlatformIO toolchain's Zephyr framework build script pip-installs
|
||||
west and cbor2 (and pyocd on x86_64) into the Python environment running
|
||||
PlatformIO whenever they are not importable. That environment is not
|
||||
always writable — for example the docker image run as a non-root user,
|
||||
where ESPHome lives in the system Python — so the install fails with
|
||||
"Permission denied". Instead, pre-install those packages into a dedicated
|
||||
venv under the sdk-nrf tools dir and expose it to the PlatformIO
|
||||
subprocesses through the environment:
|
||||
|
||||
* PYTHONPATH makes the venv's packages importable from the interpreter
|
||||
that runs PlatformIO/SCons, so the build script skips its installs.
|
||||
* VIRTUAL_ENV redirects any install the build script still performs via
|
||||
uv (pyocd is fetched on demand) into the writable venv.
|
||||
* PATH exposes console scripts installed into the venv (e.g. pyocd).
|
||||
"""
|
||||
penv_path = _get_platformio_penv_path()
|
||||
env_python_path = get_python_env_executable_path(penv_path, "python")
|
||||
sentinel = penv_path / ".ready"
|
||||
# Include the Python version: the venv breaks when the interpreter it
|
||||
# was created from is upgraded, so it must be rebuilt.
|
||||
requirements_hash = hashlib.sha256(
|
||||
_REQUIREMENTS.read_bytes()
|
||||
+ "\n".join(_PLATFORMIO_PENV_REQUIREMENTS).encode()
|
||||
+ f"python{sys.version_info.major}.{sys.version_info.minor}".encode()
|
||||
).hexdigest()
|
||||
if (
|
||||
not sentinel.exists()
|
||||
or sentinel.read_text(encoding="utf-8") != requirements_hash
|
||||
):
|
||||
rmdir(penv_path, msg="Clean up PlatformIO toolchain Python environment")
|
||||
|
||||
create_venv(penv_path, msg="PlatformIO toolchain")
|
||||
|
||||
_LOGGER.info("Installing PlatformIO toolchain requirements ...")
|
||||
cmd = [
|
||||
str(env_python_path),
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"-r",
|
||||
str(_REQUIREMENTS),
|
||||
*_PLATFORMIO_PENV_REQUIREMENTS,
|
||||
]
|
||||
if not run_command_ok(cmd):
|
||||
raise EsphomeError(
|
||||
"Install requirements for PlatformIO toolchain Python environment failure"
|
||||
)
|
||||
sentinel.write_text(requirements_hash, encoding="utf-8")
|
||||
|
||||
os.environ["VIRTUAL_ENV"] = str(penv_path)
|
||||
_prepend_env_path("PYTHONPATH", str(_get_penv_site_packages(penv_path)))
|
||||
_prepend_env_path("PATH", str(env_python_path.parent))
|
||||
|
||||
|
||||
def _patch_uf2conv_escape_sequences(framework_path: Path) -> None:
|
||||
# SDK v2.6.1 ships uf2conv.py with '\s+' — an unrecognised escape that
|
||||
# Python 3.12+ flags with SyntaxWarning (a future version will reject it).
|
||||
|
||||
@@ -212,9 +212,15 @@ _NUMBER_SCHEMA = (
|
||||
},
|
||||
cv.has_at_least_one_key(CONF_ABOVE, CONF_BELOW),
|
||||
),
|
||||
cv.Optional(CONF_UNIT_OF_MEASUREMENT): validate_unit_of_measurement,
|
||||
cv.Optional(CONF_MODE, default="AUTO"): cv.enum(NUMBER_MODES, upper=True),
|
||||
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
|
||||
cv.Optional(
|
||||
CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED
|
||||
): validate_unit_of_measurement,
|
||||
cv.Optional(
|
||||
CONF_MODE, default="AUTO", visibility=cv.Visibility.ADVANCED
|
||||
): cv.enum(NUMBER_MODES, upper=True),
|
||||
cv.Optional(
|
||||
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
|
||||
): validate_device_class,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -101,25 +101,31 @@ void SEN5XComponent::setup() {
|
||||
ESP_LOGV(TAG, "Serial number %s", this->serial_number_);
|
||||
|
||||
uint16_t raw_product_name[16];
|
||||
if (!this->get_register(SEN5X_CMD_GET_PRODUCT_NAME, raw_product_name, 16, 20)) {
|
||||
ESP_LOGE(TAG, "Failed to read product name");
|
||||
this->error_code_ = PRODUCT_NAME_FAILED;
|
||||
this->mark_failed();
|
||||
return;
|
||||
Sen5xType detected_type = Sen5xType::UNKNOWN;
|
||||
if (this->get_register(SEN5X_CMD_GET_PRODUCT_NAME, raw_product_name, 16, 20)) {
|
||||
const char *product_name = sensirion_convert_to_string_in_place(raw_product_name, 16);
|
||||
if (strncmp(product_name, "SEN50", 5) == 0) {
|
||||
detected_type = Sen5xType::SEN50;
|
||||
} else if (strncmp(product_name, "SEN54", 5) == 0) {
|
||||
detected_type = Sen5xType::SEN54;
|
||||
} else if (strncmp(product_name, "SEN55", 5) == 0) {
|
||||
detected_type = Sen5xType::SEN55;
|
||||
}
|
||||
}
|
||||
const char *product_name = sensirion_convert_to_string_in_place(raw_product_name, 16);
|
||||
if (strncmp(product_name, "SEN50", 5) == 0) {
|
||||
this->type_ = Sen5xType::SEN50;
|
||||
} else if (strncmp(product_name, "SEN54", 5) == 0) {
|
||||
this->type_ = Sen5xType::SEN54;
|
||||
} else if (strncmp(product_name, "SEN55", 5) == 0) {
|
||||
this->type_ = Sen5xType::SEN55;
|
||||
} else {
|
||||
|
||||
if (this->model_override_.has_value()) {
|
||||
if (detected_type != this->model_override_.value()) {
|
||||
ESP_LOGW(TAG, "Detected %s, using %s", LOG_STR_ARG(type_to_string(detected_type)),
|
||||
LOG_STR_ARG(type_to_string(this->model_override_.value())));
|
||||
}
|
||||
this->type_ = this->model_override_.value();
|
||||
} else if (detected_type == Sen5xType::UNKNOWN) {
|
||||
this->type_ = Sen5xType::UNKNOWN;
|
||||
ESP_LOGE(TAG, "Unknown product name: %.32s", product_name);
|
||||
this->error_code_ = PRODUCT_NAME_FAILED;
|
||||
this->mark_failed();
|
||||
return;
|
||||
} else {
|
||||
this->type_ = detected_type;
|
||||
}
|
||||
|
||||
ESP_LOGD(TAG, "Type: %s", LOG_STR_ARG(type_to_string(this->type_)));
|
||||
@@ -255,10 +261,12 @@ void SEN5XComponent::dump_config() {
|
||||
}
|
||||
}
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Type: %s\n"
|
||||
" Type: %s%s\n"
|
||||
" Firmware version: %d\n"
|
||||
" Serial number: %s",
|
||||
LOG_STR_ARG(type_to_string(this->type_)), this->firmware_version_, this->serial_number_);
|
||||
LOG_STR_ARG(type_to_string(this->type_)),
|
||||
this->model_override_.has_value() ? LOG_STR_LITERAL(" (overridden)") : LOG_STR_LITERAL(""),
|
||||
this->firmware_version_, this->serial_number_);
|
||||
if (this->auto_cleaning_interval_.has_value()) {
|
||||
ESP_LOGCONFIG(TAG, " Auto cleaning interval: %" PRId32 "s", this->auto_cleaning_interval_.value());
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ class SEN5XComponent final : public PollingComponent, public sensirion_common::S
|
||||
temp_comp.time_constant = time_constant;
|
||||
this->temperature_compensation_ = temp_comp;
|
||||
}
|
||||
void set_model(Sen5xType model) { this->model_override_ = model; }
|
||||
bool start_fan_cleaning();
|
||||
|
||||
protected:
|
||||
@@ -126,6 +127,7 @@ class SEN5XComponent final : public PollingComponent, public sensirion_common::S
|
||||
optional<GasTuning> voc_tuning_params_;
|
||||
optional<GasTuning> nox_tuning_params_;
|
||||
optional<TemperatureCompensation> temperature_compensation_;
|
||||
optional<Sen5xType> model_override_;
|
||||
ESPPreferenceObject pref_;
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from esphome.const import (
|
||||
CONF_INDEX_OFFSET,
|
||||
CONF_LEARNING_TIME_GAIN_HOURS,
|
||||
CONF_LEARNING_TIME_OFFSET_HOURS,
|
||||
CONF_MODEL,
|
||||
CONF_NORMALIZED_OFFSET_SLOPE,
|
||||
CONF_NOX,
|
||||
CONF_OFFSET,
|
||||
@@ -39,6 +40,7 @@ from esphome.const import (
|
||||
UNIT_MICROGRAMS_PER_CUBIC_METER,
|
||||
UNIT_PERCENT,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@martgras"]
|
||||
DEPENDENCIES = ["i2c"]
|
||||
@@ -49,6 +51,7 @@ SEN5XComponent = sen5x_ns.class_(
|
||||
"SEN5XComponent", cg.PollingComponent, sensirion_common.SensirionI2CDevice
|
||||
)
|
||||
RhtAccelerationMode = sen5x_ns.enum("RhtAccelerationMode")
|
||||
Sen5xType = sen5x_ns.enum("Sen5xType", is_class=True)
|
||||
|
||||
CONF_ACCELERATION_MODE = "acceleration_mode"
|
||||
CONF_AUTO_CLEANING_INTERVAL = "auto_cleaning_interval"
|
||||
@@ -63,6 +66,12 @@ ACCELERATION_MODES = {
|
||||
"high": RhtAccelerationMode.HIGH_ACCELERATION,
|
||||
}
|
||||
|
||||
MODELS = {
|
||||
"SEN50": Sen5xType.SEN50,
|
||||
"SEN54": Sen5xType.SEN54,
|
||||
"SEN55": Sen5xType.SEN55,
|
||||
}
|
||||
|
||||
|
||||
def _gas_sensor(
|
||||
*,
|
||||
@@ -186,6 +195,7 @@ CONFIG_SCHEMA = (
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ACCELERATION_MODE): cv.enum(ACCELERATION_MODES),
|
||||
cv.Optional(CONF_MODEL): cv.enum(MODELS, upper=True),
|
||||
}
|
||||
)
|
||||
.extend(cv.polling_component_schema("60s"))
|
||||
@@ -210,7 +220,7 @@ SETTING_MAP = {
|
||||
}
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await i2c.register_i2c_device(var, config)
|
||||
@@ -219,6 +229,9 @@ async def to_code(config):
|
||||
if cfg := config.get(key):
|
||||
cg.add(getattr(var, funcName)(cfg))
|
||||
|
||||
if (model := config.get(CONF_MODEL)) is not None:
|
||||
cg.add(var.set_model(model))
|
||||
|
||||
for key, funcName in SENSOR_MAP.items():
|
||||
if cfg := config.get(key):
|
||||
sens = await sensor.new_sensor(cfg)
|
||||
|
||||
@@ -129,12 +129,12 @@ def _request_high_performance_networking(config: ConfigType) -> ConfigType:
|
||||
"""
|
||||
network.require_high_performance_networking()
|
||||
# Socket consumption varies by mode:
|
||||
# - Server mode: 1 listening socket + 2 client connections (for handoff)
|
||||
# - Server mode: 1 listening socket + 4 client connections (established connection, unproven connections, and a spare)
|
||||
# - Client mode: 1 outbound connection
|
||||
socket.consume_sockets(
|
||||
1, "sendspin_websocket_server", socket.SocketType.TCP_LISTEN
|
||||
)(config)
|
||||
socket.consume_sockets(2, "sendspin_websocket_server")(config)
|
||||
socket.consume_sockets(4, "sendspin_websocket_server")(config)
|
||||
socket.consume_sockets(1, "sendspin_websocket_client")(config)
|
||||
|
||||
wifi.enable_runtime_power_save_control()
|
||||
@@ -198,7 +198,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
psram.request_external_task_stack()
|
||||
|
||||
# sendspin-cpp library
|
||||
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1")
|
||||
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.7.0")
|
||||
|
||||
cg.add_define("USE_SENDSPIN", True) # for MDNS
|
||||
|
||||
@@ -255,9 +255,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
if psram_stack:
|
||||
psram.request_external_task_stack()
|
||||
|
||||
# Library defaults: priority 18 (one above httpd_priority 17 so the decoder is not
|
||||
# starved by the HTTP server during the initial encoded-audio burst at stream start),
|
||||
# decode buffer location PREFER_EXTERNAL.
|
||||
player_struct_fields = [
|
||||
("audio_formats", audio_format_structs),
|
||||
("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]),
|
||||
|
||||
@@ -179,7 +179,12 @@ std::optional<uint32_t> SendspinHub::load_last_server_hash() {
|
||||
void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional<uint8_t> volume,
|
||||
std::optional<bool> mute) {
|
||||
if (this->is_ready()) {
|
||||
this->controller_role_->send_command(command, volume, mute);
|
||||
sendspin::ClientCommandControllerObject obj = {
|
||||
.command = command,
|
||||
.volume = volume,
|
||||
.muted = mute,
|
||||
};
|
||||
this->controller_role_->send_command(obj);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -321,13 +321,25 @@ _SENSOR_SCHEMA = (
|
||||
{
|
||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSensorComponent),
|
||||
cv.GenerateID(): cv.declare_id(Sensor),
|
||||
cv.Optional(CONF_UNIT_OF_MEASUREMENT): validate_unit_of_measurement,
|
||||
cv.Optional(CONF_ACCURACY_DECIMALS): validate_accuracy_decimals,
|
||||
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
|
||||
cv.Optional(CONF_STATE_CLASS): validate_state_class,
|
||||
cv.Optional(CONF_ENTITY_CATEGORY): sensor_entity_category,
|
||||
cv.Optional(CONF_FORCE_UPDATE, default=False): cv.boolean,
|
||||
cv.Optional(CONF_EXPIRE_AFTER): cv.All(
|
||||
cv.Optional(
|
||||
CONF_UNIT_OF_MEASUREMENT, visibility=cv.Visibility.ADVANCED
|
||||
): validate_unit_of_measurement,
|
||||
cv.Optional(
|
||||
CONF_ACCURACY_DECIMALS, visibility=cv.Visibility.ADVANCED
|
||||
): validate_accuracy_decimals,
|
||||
cv.Optional(
|
||||
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
|
||||
): validate_device_class,
|
||||
cv.Optional(
|
||||
CONF_STATE_CLASS, visibility=cv.Visibility.ADVANCED
|
||||
): validate_state_class,
|
||||
cv.Optional(
|
||||
CONF_ENTITY_CATEGORY, visibility=cv.Visibility.ADVANCED
|
||||
): sensor_entity_category,
|
||||
cv.Optional(
|
||||
CONF_FORCE_UPDATE, default=False, visibility=cv.Visibility.ADVANCED
|
||||
): cv.boolean,
|
||||
cv.Optional(CONF_EXPIRE_AFTER, visibility=cv.Visibility.ADVANCED): cv.All(
|
||||
cv.requires_component("mqtt"),
|
||||
cv.Any(None, cv.positive_time_period_milliseconds),
|
||||
),
|
||||
|
||||
@@ -41,17 +41,17 @@ void I2CSSD1306::command(uint8_t value) { this->write_byte(0x00, value); }
|
||||
void HOT I2CSSD1306::write_display_data() {
|
||||
if (this->is_sh1106_() || this->is_sh1107_()) {
|
||||
uint32_t i = 0;
|
||||
// Some panels wire their visible columns to a window of the controller RAM
|
||||
// that does not start at column 0 (e.g. SH1107 M5Stack Unit OLED needs offset_x: 32).
|
||||
// SH1106 keeps its historical 0x02 base column on top of any offset.
|
||||
uint8_t start_column = this->offset_x_;
|
||||
if (this->is_sh1106_()) {
|
||||
start_column += 0x02;
|
||||
}
|
||||
for (uint8_t page = 0; page < (uint8_t) this->get_height_internal() / 8; page++) {
|
||||
this->command(0xB0 + page); // row
|
||||
if (this->is_sh1106_()) {
|
||||
this->command(0x02); // lower column - 0x02 is historical SH1106 value
|
||||
} else {
|
||||
// Other SH1107 drivers use 0x00
|
||||
// Column values dont change and it seems they can be set only once,
|
||||
// but we follow SH1106 implementation and resend them
|
||||
this->command(0x00);
|
||||
}
|
||||
this->command(0x10); // higher column
|
||||
this->command(0xB0 + page); // row
|
||||
this->command(start_column & 0x0F); // lower column
|
||||
this->command(0x10 | (start_column >> 4)); // higher column
|
||||
for (uint8_t x = 0; x < (uint8_t) this->get_width_internal() / 16; x++) {
|
||||
uint8_t data[16];
|
||||
for (uint8_t &j : data)
|
||||
|
||||
@@ -38,14 +38,17 @@ void SPISSD1306::command(uint8_t value) {
|
||||
}
|
||||
void HOT SPISSD1306::write_display_data() {
|
||||
if (this->is_sh1106_() || this->is_sh1107_()) {
|
||||
// Some panels wire their visible columns to a window of the controller RAM
|
||||
// that does not start at column 0 (e.g. SH1107 M5Stack Unit OLED needs offset_x: 32).
|
||||
// SH1106 keeps its historical 0x02 base column on top of any offset.
|
||||
uint8_t start_column = this->offset_x_;
|
||||
if (this->is_sh1106_()) {
|
||||
start_column += 0x02;
|
||||
}
|
||||
for (uint8_t y = 0; y < (uint8_t) this->get_height_internal() / 8; y++) {
|
||||
this->command(0xB0 + y);
|
||||
if (this->is_sh1106_()) {
|
||||
this->command(0x02);
|
||||
} else {
|
||||
this->command(0x00);
|
||||
}
|
||||
this->command(0x10);
|
||||
this->command(start_column & 0x0F); // lower column
|
||||
this->command(0x10 | (start_column >> 4)); // higher column
|
||||
this->dc_pin_->digital_write(true);
|
||||
for (uint8_t x = 0; x < (uint8_t) this->get_width_internal(); x++) {
|
||||
this->enable();
|
||||
|
||||
@@ -78,7 +78,9 @@ _SWITCH_SCHEMA = (
|
||||
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_TURN_OFF): automation.validate_automation({}),
|
||||
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
|
||||
cv.Optional(
|
||||
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
|
||||
): validate_device_class,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -144,7 +144,9 @@ _TEXT_SENSOR_SCHEMA = (
|
||||
{
|
||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTTextSensor),
|
||||
cv.GenerateID(): cv.declare_id(TextSensor),
|
||||
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_VALUE): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_RAW_VALUE): automation.validate_automation({}),
|
||||
|
||||
@@ -54,7 +54,9 @@ _UPDATE_SCHEMA = (
|
||||
.extend(
|
||||
{
|
||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTUpdateComponent),
|
||||
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_ON_UPDATE_AVAILABLE): automation.validate_automation(
|
||||
single=True
|
||||
),
|
||||
@@ -136,7 +138,9 @@ async def to_code(config):
|
||||
automation.maybe_simple_id(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(UpdateEntity),
|
||||
cv.Optional(CONF_FORCE_UPDATE, default=False): cv.templatable(cv.boolean),
|
||||
cv.Optional(
|
||||
CONF_FORCE_UPDATE, default=False, visibility=cv.Visibility.ADVANCED
|
||||
): cv.templatable(cv.boolean),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
|
||||
@@ -160,7 +160,7 @@ void USBUartChannel::write_array(const uint8_t *data, size_t len) {
|
||||
}
|
||||
uint16_t chunk_len = std::min(len, UsbOutputChunk::MAX_CHUNK_SIZE);
|
||||
memcpy(chunk->data, data, chunk_len);
|
||||
chunk->length = static_cast<uint8_t>(chunk_len);
|
||||
chunk->length = chunk_len;
|
||||
// Push always succeeds: pool is sized to queue capacity (SIZE-1), so if
|
||||
// allocate() returned non-null, the queue cannot be full.
|
||||
this->output_queue_.push(chunk);
|
||||
|
||||
@@ -87,7 +87,9 @@ _VALVE_SCHEMA = (
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(Valve),
|
||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTValveComponent),
|
||||
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
|
||||
),
|
||||
|
||||
@@ -14,6 +14,7 @@ from esphome.const import (
|
||||
CONF_INFRARED,
|
||||
CONF_INTEGRATION_TIME,
|
||||
CONF_NAME,
|
||||
DEVICE_CLASS_EMPTY,
|
||||
DEVICE_CLASS_ILLUMINANCE,
|
||||
ICON_BRIGHTNESS_5,
|
||||
ICON_BRIGHTNESS_6,
|
||||
@@ -91,7 +92,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
unit_of_measurement=UNIT_COUNTS,
|
||||
icon=ICON_BRIGHTNESS_6,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_ILLUMINANCE,
|
||||
device_class=DEVICE_CLASS_EMPTY,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
key=CONF_NAME,
|
||||
@@ -111,7 +112,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
unit_of_measurement=UNIT_COUNTS,
|
||||
icon=ICON_BRIGHTNESS_7,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_ILLUMINANCE,
|
||||
device_class=DEVICE_CLASS_EMPTY,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
key=CONF_NAME,
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import logging
|
||||
import re
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import web_server_base
|
||||
@@ -24,6 +25,7 @@ from esphome.const import (
|
||||
CONF_OTA,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_TYPE,
|
||||
CONF_USERNAME,
|
||||
CONF_VERSION,
|
||||
CONF_WEB_SERVER,
|
||||
@@ -43,9 +45,13 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
AUTO_LOAD = ["json", "web_server_base"]
|
||||
|
||||
AUTH_TYPE_BASIC = "basic"
|
||||
AUTH_TYPE_DIGEST = "digest"
|
||||
|
||||
CONF_SORTING_GROUP_ID = "sorting_group_id"
|
||||
CONF_SORTING_GROUPS = "sorting_groups"
|
||||
CONF_SORTING_WEIGHT = "sorting_weight"
|
||||
CONF_ALLOWED_ORIGINS = "allowed_origins"
|
||||
|
||||
|
||||
web_server_ns = cg.esphome_ns.namespace("web_server")
|
||||
@@ -83,6 +89,19 @@ def validate_version_deprecated(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
def validate_auth_type_deprecated(auth: ConfigType) -> ConfigType:
|
||||
# Remove before 2027.1.0: the default auth scheme changes from basic to digest.
|
||||
if CONF_TYPE not in auth:
|
||||
_LOGGER.warning(
|
||||
"The 'web_server' 'auth' scheme currently defaults to 'basic', which sends the "
|
||||
"password over the network in an easily reversible form. The default will change "
|
||||
"to 'digest' in ESPHome 2027.1.0. To keep using basic authentication, set "
|
||||
"'type: basic' under 'auth:' explicitly; otherwise set 'type: digest' now to "
|
||||
"adopt the more secure scheme."
|
||||
)
|
||||
return auth
|
||||
|
||||
|
||||
def validate_local(config: ConfigType) -> ConfigType:
|
||||
if CONF_LOCAL in config and config[CONF_VERSION] == 1:
|
||||
raise cv.Invalid("'local' is not supported in version 1")
|
||||
@@ -104,6 +123,41 @@ def validate_ota(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
# An Origin header is always "scheme://host[:port]" with no path or trailing slash.
|
||||
_ORIGIN_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://[^/\s]+$")
|
||||
|
||||
|
||||
def validate_origin(value: str) -> str:
|
||||
# "*" is the wildcard that allows any origin.
|
||||
if value == "*":
|
||||
return value
|
||||
value = cv.string_strict(value)
|
||||
if not _ORIGIN_RE.match(value):
|
||||
raise cv.Invalid(
|
||||
f"'{value}' is not a valid origin. An origin must be 'scheme://host[:port]' with no "
|
||||
f"path or trailing slash (e.g. 'https://example.com'), or '*' to allow any origin."
|
||||
)
|
||||
# Browsers send the scheme and host lowercased in the Origin header, so normalize to match.
|
||||
return value.lower()
|
||||
|
||||
|
||||
def validate_private_network_access(config: ConfigType) -> ConfigType:
|
||||
# PNA preflights are always cross-origin, so they can only be authorized against the
|
||||
# allowed_origins list. Enabling PNA without any origins would deny every PNA request.
|
||||
if (
|
||||
config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]
|
||||
and config.get(CONF_ALLOWED_ORIGINS) is None
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_ALLOWED_ORIGINS}' must be set when "
|
||||
f"'{CONF_ENABLE_PRIVATE_NETWORK_ACCESS}' is enabled. List each origin that is "
|
||||
f"allowed to reach the device (e.g. 'https://example.com'). '*' allows any origin "
|
||||
f"but is not recommended.",
|
||||
path=[CONF_ENABLE_PRIVATE_NETWORK_ACCESS],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def validate_sorting_groups(config: ConfigType) -> ConfigType:
|
||||
if CONF_SORTING_GROUPS in config and config[CONF_VERSION] != 3:
|
||||
raise cv.Invalid(
|
||||
@@ -172,7 +226,9 @@ sorting_group = {
|
||||
|
||||
WEBSERVER_SORTING_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_WEB_SERVER): cv.Schema(
|
||||
# The per-entity web_server block is cosmetic dashboard ordering —
|
||||
# mark the whole block advanced; the children inherit via the cascade.
|
||||
cv.Optional(CONF_WEB_SERVER, visibility=cv.Visibility.ADVANCED): cv.Schema(
|
||||
{
|
||||
cv.OnlyWith(CONF_WEB_SERVER_ID, "web_server"): cv.use_id(WebServer),
|
||||
cv.Optional(CONF_SORTING_WEIGHT): cv.All(
|
||||
@@ -199,16 +255,25 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_CSS_INCLUDE): cv.file_,
|
||||
cv.Optional(CONF_JS_URL): cv.string,
|
||||
cv.Optional(CONF_JS_INCLUDE): cv.file_,
|
||||
cv.Optional(CONF_ENABLE_PRIVATE_NETWORK_ACCESS, default=True): cv.boolean,
|
||||
cv.Optional(CONF_AUTH): cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_USERNAME): cv.All(
|
||||
cv.string_strict, cv.Length(min=1)
|
||||
),
|
||||
cv.Required(CONF_PASSWORD): cv.sensitive(
|
||||
cv.All(cv.string_strict, cv.Length(min=1))
|
||||
),
|
||||
}
|
||||
cv.Optional(CONF_ENABLE_PRIVATE_NETWORK_ACCESS, default=False): cv.boolean,
|
||||
cv.Optional(CONF_ALLOWED_ORIGINS): cv.All(
|
||||
cv.ensure_list(validate_origin), cv.Length(min=1)
|
||||
),
|
||||
cv.Optional(CONF_AUTH): cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_USERNAME): cv.All(
|
||||
cv.string_strict, cv.Length(min=1)
|
||||
),
|
||||
cv.Required(CONF_PASSWORD): cv.sensitive(
|
||||
cv.All(cv.string_strict, cv.Length(min=1))
|
||||
),
|
||||
cv.Optional(CONF_TYPE): cv.one_of(
|
||||
AUTH_TYPE_BASIC, AUTH_TYPE_DIGEST, lower=True
|
||||
),
|
||||
}
|
||||
),
|
||||
validate_auth_type_deprecated,
|
||||
),
|
||||
cv.GenerateID(CONF_WEB_SERVER_BASE_ID): cv.use_id(
|
||||
web_server_base.WebServerBase
|
||||
@@ -236,6 +301,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
validate_local,
|
||||
validate_sorting_groups,
|
||||
validate_ota,
|
||||
validate_private_network_access,
|
||||
_consume_web_server_sockets,
|
||||
)
|
||||
|
||||
@@ -332,10 +398,18 @@ async def to_code(config):
|
||||
request_log_listener() # Request a log listener slot for web server log streaming
|
||||
if config[CONF_ENABLE_PRIVATE_NETWORK_ACCESS]:
|
||||
cg.add_define("USE_WEBSERVER_PRIVATE_NETWORK_ACCESS")
|
||||
if CONF_AUTH in config:
|
||||
if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None:
|
||||
cg.add_define("USE_WEBSERVER_ALLOWED_ORIGINS")
|
||||
cg.add(var.set_allowed_origins(allowed_origins))
|
||||
if (auth := config.get(CONF_AUTH)) is not None:
|
||||
cg.add_define("USE_WEBSERVER_AUTH")
|
||||
cg.add(paren.set_auth_username(config[CONF_AUTH][CONF_USERNAME]))
|
||||
cg.add(paren.set_auth_password(config[CONF_AUTH][CONF_PASSWORD]))
|
||||
# The scheme is fixed at build time so the unused Basic/Digest code path is compiled
|
||||
# out. Basic is the current default (the absence of this define); an explicit
|
||||
# 'type: digest' opts in early. Default changes to digest in 2027.1.0.
|
||||
if auth.get(CONF_TYPE) == AUTH_TYPE_DIGEST:
|
||||
cg.add_define("USE_WEBSERVER_AUTH_DIGEST")
|
||||
cg.add(paren.set_auth_username(auth[CONF_USERNAME]))
|
||||
cg.add(paren.set_auth_password(auth[CONF_PASSWORD]))
|
||||
if CONF_CSS_INCLUDE in config:
|
||||
cg.add_define("USE_WEBSERVER_CSS_INCLUDE")
|
||||
path = CORE.relative_config_path(config[CONF_CSS_INCLUDE])
|
||||
|
||||
@@ -56,9 +56,8 @@ namespace esphome::web_server {
|
||||
|
||||
static const char *const TAG = "web_server";
|
||||
|
||||
// Longest: UPDATE AVAILABLE (16 chars + null terminator, rounded up)
|
||||
static constexpr size_t PSTR_LOCAL_SIZE = 18;
|
||||
#define PSTR_LOCAL(mode_s) ESPHOME_strncpy_P(buf, (ESPHOME_PGM_P) ((mode_s)), PSTR_LOCAL_SIZE - 1)
|
||||
// View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266.
|
||||
[[maybe_unused]] static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast<ProgmemStr>(s); }
|
||||
|
||||
// Parse URL and return match info
|
||||
// URL formats (disambiguated by HTTP method for 3-segment case):
|
||||
@@ -457,9 +456,58 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) {
|
||||
}
|
||||
#endif
|
||||
|
||||
// Read a request header value portably across the Arduino and ESP-IDF web servers.
|
||||
// Returns an empty string when the header is absent (only allocates when a value is present).
|
||||
static std::string get_request_header(AsyncWebServerRequest *request, const char *name) {
|
||||
#ifdef USE_ESP32
|
||||
// ESP32 (Arduino and ESP-IDF) uses the web_server_idf backend.
|
||||
optional<std::string> value = request->get_header(name);
|
||||
return value.has_value() ? std::move(*value) : std::string();
|
||||
#else
|
||||
// ESP8266, RP2040 and LibreTiny use the Arduino ESPAsyncWebServer backend.
|
||||
const AsyncWebHeader *header = request->getHeader(name);
|
||||
return header != nullptr ? std::string(header->value().c_str()) : std::string();
|
||||
#endif
|
||||
}
|
||||
|
||||
bool WebServer::is_request_origin_allowed_(AsyncWebServerRequest *request, const std::string &origin) {
|
||||
// No Origin header: not a browser cross-origin request (e.g. curl, native API client). Allow.
|
||||
if (origin.empty())
|
||||
return true;
|
||||
|
||||
// Same-origin: the Origin authority (scheme stripped) matches the Host the request was sent to.
|
||||
// This covers the device's own IP, mDNS name, or DNS name without knowing any at compile time.
|
||||
const size_t scheme_sep = origin.find("://");
|
||||
if (scheme_sep != std::string::npos) {
|
||||
const std::string host = get_request_header(request, "Host");
|
||||
if (!host.empty() && origin.compare(scheme_sep + 3, std::string::npos, host) == 0)
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifdef USE_WEBSERVER_ALLOWED_ORIGINS
|
||||
// Otherwise the origin must be explicitly allowed via configuration.
|
||||
for (const char *allowed_origin : this->allowed_origins_) {
|
||||
// A single "*" entry allows any origin.
|
||||
if (allowed_origin[0] == '*' && allowed_origin[1] == '\0')
|
||||
return true;
|
||||
if (origin == allowed_origin)
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS
|
||||
void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) {
|
||||
const std::string origin = get_request_header(request, "Origin");
|
||||
if (!this->is_request_origin_allowed_(request, origin)) {
|
||||
request->send(403);
|
||||
return;
|
||||
}
|
||||
|
||||
AsyncWebServerResponse *response = request->beginResponse(200, ESPHOME_F(""));
|
||||
// Echo the specific origin back so the response is valid even when auth (credentials) is enabled.
|
||||
response->addHeader(ESPHOME_F("Access-Control-Allow-Origin"), origin.empty() ? "*" : origin.c_str());
|
||||
response->addHeader(ESPHOME_F("Access-Control-Allow-Private-Network"), ESPHOME_F("true"));
|
||||
response->addHeader(ESPHOME_F("Private-Network-Access-Name"), App.get_name().c_str());
|
||||
char mac_s[18];
|
||||
@@ -578,9 +626,9 @@ static void set_json_value(JsonObject &root, EntityBase *obj, const char *prefix
|
||||
root[ESPHOME_F("value")] = value;
|
||||
}
|
||||
|
||||
template<typename T>
|
||||
static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, const char *state,
|
||||
const T &value, JsonDetail start_config) {
|
||||
template<typename S, typename T>
|
||||
static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, S state, const T &value,
|
||||
JsonDetail start_config) {
|
||||
set_json_value(root, obj, prefix, value, start_config);
|
||||
root[ESPHOME_F("state")] = state;
|
||||
}
|
||||
@@ -1073,8 +1121,7 @@ json::SerializationBuffer<> WebServer::cover_json_(cover::Cover *obj, JsonDetail
|
||||
|
||||
set_json_icon_state_value(root, obj, "cover", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position,
|
||||
start_config);
|
||||
char buf[PSTR_LOCAL_SIZE];
|
||||
root[ESPHOME_F("current_operation")] = PSTR_LOCAL(cover::cover_operation_to_str(obj->current_operation));
|
||||
root[ESPHOME_F("current_operation")] = json_state_str(cover::cover_operation_to_str(obj->current_operation));
|
||||
|
||||
if (obj->get_traits().get_supports_position())
|
||||
root[ESPHOME_F("position")] = obj->position;
|
||||
@@ -1530,17 +1577,16 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json
|
||||
const auto traits = obj->get_traits();
|
||||
int8_t target_accuracy = traits.get_target_temperature_accuracy_decimals();
|
||||
int8_t current_accuracy = traits.get_current_temperature_accuracy_decimals();
|
||||
char buf[PSTR_LOCAL_SIZE];
|
||||
char temp_buf[VALUE_ACCURACY_MAX_LEN];
|
||||
|
||||
if (start_config == DETAIL_ALL) {
|
||||
JsonArray opt = root[ESPHOME_F("modes")].to<JsonArray>();
|
||||
for (climate::ClimateMode m : traits.get_supported_modes())
|
||||
opt.add(PSTR_LOCAL(climate::climate_mode_to_string(m)));
|
||||
opt.add(json_state_str(climate::climate_mode_to_string(m)));
|
||||
if (traits.get_supports_fan_modes()) {
|
||||
JsonArray opt = root[ESPHOME_F("fan_modes")].to<JsonArray>();
|
||||
for (climate::ClimateFanMode m : traits.get_supported_fan_modes())
|
||||
opt.add(PSTR_LOCAL(climate::climate_fan_mode_to_string(m)));
|
||||
opt.add(json_state_str(climate::climate_fan_mode_to_string(m)));
|
||||
}
|
||||
|
||||
if (!traits.get_supported_custom_fan_modes().empty()) {
|
||||
@@ -1551,12 +1597,12 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json
|
||||
if (traits.get_supports_swing_modes()) {
|
||||
JsonArray opt = root[ESPHOME_F("swing_modes")].to<JsonArray>();
|
||||
for (auto swing_mode : traits.get_supported_swing_modes())
|
||||
opt.add(PSTR_LOCAL(climate::climate_swing_mode_to_string(swing_mode)));
|
||||
opt.add(json_state_str(climate::climate_swing_mode_to_string(swing_mode)));
|
||||
}
|
||||
if (traits.get_supports_presets()) {
|
||||
JsonArray opt = root[ESPHOME_F("presets")].to<JsonArray>();
|
||||
for (climate::ClimatePreset m : traits.get_supported_presets())
|
||||
opt.add(PSTR_LOCAL(climate::climate_preset_to_string(m)));
|
||||
opt.add(json_state_str(climate::climate_preset_to_string(m)));
|
||||
}
|
||||
if (!traits.get_supported_custom_presets().empty()) {
|
||||
JsonArray opt = root[ESPHOME_F("custom_presets")].to<JsonArray>();
|
||||
@@ -1572,26 +1618,26 @@ json::SerializationBuffer<> WebServer::climate_json_(climate::Climate *obj, Json
|
||||
}
|
||||
|
||||
bool has_state = false;
|
||||
root[ESPHOME_F("mode")] = PSTR_LOCAL(climate_mode_to_string(obj->mode));
|
||||
root[ESPHOME_F("mode")] = json_state_str(climate_mode_to_string(obj->mode));
|
||||
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) {
|
||||
root[ESPHOME_F("action")] = PSTR_LOCAL(climate_action_to_string(obj->action));
|
||||
root[ESPHOME_F("action")] = json_state_str(climate_action_to_string(obj->action));
|
||||
root[ESPHOME_F("state")] = root[ESPHOME_F("action")];
|
||||
has_state = true;
|
||||
}
|
||||
if (traits.get_supports_fan_modes() && obj->fan_mode.has_value()) {
|
||||
root[ESPHOME_F("fan_mode")] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value()));
|
||||
root[ESPHOME_F("fan_mode")] = json_state_str(climate_fan_mode_to_string(obj->fan_mode.value()));
|
||||
}
|
||||
if (!traits.get_supported_custom_fan_modes().empty() && obj->has_custom_fan_mode()) {
|
||||
root[ESPHOME_F("custom_fan_mode")] = obj->get_custom_fan_mode();
|
||||
}
|
||||
if (traits.get_supports_presets() && obj->preset.has_value()) {
|
||||
root[ESPHOME_F("preset")] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value()));
|
||||
root[ESPHOME_F("preset")] = json_state_str(climate_preset_to_string(obj->preset.value()));
|
||||
}
|
||||
if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) {
|
||||
root[ESPHOME_F("custom_preset")] = obj->get_custom_preset();
|
||||
}
|
||||
if (traits.get_supports_swing_modes()) {
|
||||
root[ESPHOME_F("swing_mode")] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode));
|
||||
root[ESPHOME_F("swing_mode")] = json_state_str(climate_swing_mode_to_string(obj->swing_mode));
|
||||
}
|
||||
if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) {
|
||||
root[ESPHOME_F("current_temperature")] =
|
||||
@@ -1695,8 +1741,7 @@ json::SerializationBuffer<> WebServer::lock_json_(lock::Lock *obj, lock::LockSta
|
||||
json::JsonBuilder builder;
|
||||
JsonObject root = builder.root();
|
||||
|
||||
char buf[PSTR_LOCAL_SIZE];
|
||||
set_json_icon_state_value(root, obj, "lock", PSTR_LOCAL(lock::lock_state_to_string(value)), value, start_config);
|
||||
set_json_icon_state_value(root, obj, "lock", json_state_str(lock::lock_state_to_string(value)), value, start_config);
|
||||
if (start_config == DETAIL_ALL) {
|
||||
this->add_sorting_info_(root, obj);
|
||||
}
|
||||
@@ -1777,8 +1822,7 @@ json::SerializationBuffer<> WebServer::valve_json_(valve::Valve *obj, JsonDetail
|
||||
|
||||
set_json_icon_state_value(root, obj, "valve", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position,
|
||||
start_config);
|
||||
char buf[PSTR_LOCAL_SIZE];
|
||||
root[ESPHOME_F("current_operation")] = PSTR_LOCAL(valve::valve_operation_to_str(obj->current_operation));
|
||||
root[ESPHOME_F("current_operation")] = json_state_str(valve::valve_operation_to_str(obj->current_operation));
|
||||
|
||||
if (obj->get_traits().get_supports_position())
|
||||
root[ESPHOME_F("position")] = obj->position;
|
||||
@@ -1863,9 +1907,8 @@ json::SerializationBuffer<> WebServer::alarm_control_panel_json_(alarm_control_p
|
||||
json::JsonBuilder builder;
|
||||
JsonObject root = builder.root();
|
||||
|
||||
char buf[PSTR_LOCAL_SIZE];
|
||||
set_json_icon_state_value(root, obj, "alarm-control-panel", PSTR_LOCAL(alarm_control_panel_state_to_string(value)),
|
||||
value, start_config);
|
||||
set_json_icon_state_value(root, obj, "alarm-control-panel",
|
||||
json_state_str(alarm_control_panel_state_to_string(value)), value, start_config);
|
||||
if (start_config == DETAIL_ALL) {
|
||||
this->add_sorting_info_(root, obj);
|
||||
}
|
||||
@@ -1937,10 +1980,9 @@ json::SerializationBuffer<> WebServer::water_heater_all_json_generator(WebServer
|
||||
json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHeater *obj, JsonDetail start_config) {
|
||||
json::JsonBuilder builder;
|
||||
JsonObject root = builder.root();
|
||||
char buf[PSTR_LOCAL_SIZE];
|
||||
|
||||
const auto mode = obj->get_mode();
|
||||
const char *mode_s = PSTR_LOCAL(water_heater::water_heater_mode_to_string(mode));
|
||||
ProgmemStr mode_s = json_state_str(water_heater::water_heater_mode_to_string(mode));
|
||||
|
||||
set_json_icon_state_value(root, obj, "water_heater", mode_s, mode, start_config);
|
||||
|
||||
@@ -1949,7 +1991,7 @@ json::SerializationBuffer<> WebServer::water_heater_json_(water_heater::WaterHea
|
||||
if (start_config == DETAIL_ALL) {
|
||||
JsonArray modes = root[ESPHOME_F("modes")].to<JsonArray>();
|
||||
for (auto m : traits.get_supported_modes())
|
||||
modes.add(PSTR_LOCAL(water_heater::water_heater_mode_to_string(m)));
|
||||
modes.add(json_state_str(water_heater::water_heater_mode_to_string(m)));
|
||||
root[ESPHOME_F("min_temp")] = traits.get_min_temperature();
|
||||
root[ESPHOME_F("max_temp")] = traits.get_max_temperature();
|
||||
root[ESPHOME_F("step")] = traits.get_target_temperature_step();
|
||||
@@ -2277,8 +2319,7 @@ json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, J
|
||||
json::JsonBuilder builder;
|
||||
JsonObject root = builder.root();
|
||||
|
||||
char buf[PSTR_LOCAL_SIZE];
|
||||
set_json_icon_state_value(root, obj, "update", PSTR_LOCAL(update::update_state_to_string(obj->state)),
|
||||
set_json_icon_state_value(root, obj, "update", json_state_str(update::update_state_to_string(obj->state)),
|
||||
obj->update_info.latest_version, start_config);
|
||||
if (start_config == DETAIL_ALL) {
|
||||
root[ESPHOME_F("current_version")] = obj->update_info.current_version;
|
||||
@@ -2456,6 +2497,21 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) {
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS
|
||||
// Private Network Access preflight carries a cross-origin Origin by design; its handler does the
|
||||
// origin check itself, so let it run before the general enforcement below.
|
||||
if (request->method() == HTTP_OPTIONS && request->hasHeader(ESPHOME_F("Access-Control-Request-Private-Network"))) {
|
||||
this->handle_pna_cors_request(request);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Reject cross-origin browser requests unless the origin is explicitly allowed.
|
||||
if (!this->is_request_origin_allowed_(request, get_request_header(request, "Origin"))) {
|
||||
request->send(403);
|
||||
return;
|
||||
}
|
||||
|
||||
#if !defined(USE_ESP32) && defined(USE_ARDUINO)
|
||||
if (url == ESPHOME_F("/events")) {
|
||||
this->events_.add_new_client(this, request);
|
||||
@@ -2477,13 +2533,6 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) {
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS
|
||||
if (request->method() == HTTP_OPTIONS && request->hasHeader(ESPHOME_F("Access-Control-Request-Private-Network"))) {
|
||||
this->handle_pna_cors_request(request);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Parse URL for component routing
|
||||
// Pass HTTP method to disambiguate 3-segment URLs (GET=sub-device state, POST=main device action)
|
||||
UrlMatch match = match_url(url.c_str(), url.length(), false, request->method() == HTTP_POST);
|
||||
|
||||
@@ -242,6 +242,22 @@ class WebServer final : public Controller, public Component, public AsyncWebHand
|
||||
*/
|
||||
void set_expose_log(bool expose_log) { this->expose_log_ = expose_log; }
|
||||
|
||||
#ifdef USE_WEBSERVER_ALLOWED_ORIGINS
|
||||
/** Set the origins that browsers are allowed to make cross-origin requests from.
|
||||
*
|
||||
* Requests without an `Origin` header (e.g. non-browser clients like curl or the native API)
|
||||
* are always allowed. Requests whose `Origin` matches the address the device is served on
|
||||
* (same-origin) are always allowed. Any other browser origin must appear in this list, or the
|
||||
* request is rejected. A single "*" entry allows any origin. Each other entry must exactly match
|
||||
* the requesting page's `Origin` header (e.g. "https://example.com").
|
||||
*
|
||||
* This list is also used to authorize Private Network Access requests when that feature is enabled.
|
||||
*
|
||||
* @param origins The list of allowed origins.
|
||||
*/
|
||||
void set_allowed_origins(std::initializer_list<const char *> origins) { this->allowed_origins_ = origins; }
|
||||
#endif
|
||||
|
||||
// ========== INTERNAL METHODS ==========
|
||||
// (In most use cases you won't need these)
|
||||
/// Setup the internal web server and register handlers.
|
||||
@@ -593,6 +609,16 @@ class WebServer final : public Controller, public Component, public AsyncWebHand
|
||||
const char *js_include_{nullptr};
|
||||
#endif
|
||||
bool expose_log_{true};
|
||||
#ifdef USE_WEBSERVER_ALLOWED_ORIGINS
|
||||
// Extra origins allowed to make cross-origin browser requests ("*" means any origin).
|
||||
// Only compiled when allowed_origins is configured; same-origin is always allowed regardless.
|
||||
FixedVector<const char *> allowed_origins_;
|
||||
#endif
|
||||
|
||||
/// Check whether the given request Origin is permitted. Same-origin (matching the Host the
|
||||
/// request was sent to) and requests without an Origin header are always allowed; any other
|
||||
/// origin must be listed in allowed_origins. The caller passes the already-read Origin header.
|
||||
bool is_request_origin_allowed_(AsyncWebServerRequest *request, const std::string &origin);
|
||||
|
||||
private:
|
||||
#ifdef USE_SENSOR
|
||||
|
||||
@@ -59,7 +59,15 @@ class AuthMiddlewareHandler : public MiddlewareHandler {
|
||||
bool check_auth(AsyncWebServerRequest *request) {
|
||||
bool success = request->authenticate(credentials_->username.c_str(), credentials_->password.c_str());
|
||||
if (!success) {
|
||||
// The scheme is chosen at build time (USE_WEBSERVER_AUTH_DIGEST); the unused path is
|
||||
// compiled out. On ESP32 our own server picks the scheme internally.
|
||||
#if USE_ESP32
|
||||
request->requestAuthentication();
|
||||
#elif defined(USE_WEBSERVER_AUTH_DIGEST)
|
||||
request->requestAuthentication(nullptr, true);
|
||||
#else
|
||||
request->requestAuthentication(nullptr, false);
|
||||
#endif
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
#include "utils.h"
|
||||
#include "web_server_idf.h"
|
||||
|
||||
#ifdef USE_WEBSERVER_AUTH_DIGEST
|
||||
#include <esp_random.h>
|
||||
#include <esp_rom_md5.h>
|
||||
#endif
|
||||
|
||||
#ifdef USE_WEBSERVER_OTA
|
||||
#include <multipart_parser.h>
|
||||
#include "multipart.h" // For parse_multipart_boundary and other utils
|
||||
@@ -372,6 +377,125 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code
|
||||
}
|
||||
|
||||
#ifdef USE_WEBSERVER_AUTH
|
||||
|
||||
#ifdef USE_WEBSERVER_AUTH_DIGEST
|
||||
namespace {
|
||||
|
||||
// Extract the value of a Digest auth parameter (e.g. "nonce") from the comma-separated
|
||||
// parameter list. Values may be quoted or bare. Returns an empty ref when the key is absent.
|
||||
// Only whole parameter names match, so "nc" does not match inside "cnonce".
|
||||
StringRef digest_param(StringRef params, const char *key) {
|
||||
size_t key_len = strlen(key);
|
||||
const char *base = params.c_str();
|
||||
size_t n = params.size();
|
||||
size_t i = 0;
|
||||
while (i < n) {
|
||||
while (i < n && (base[i] == ' ' || base[i] == ','))
|
||||
i++;
|
||||
size_t name_start = i;
|
||||
while (i < n && base[i] != '=' && base[i] != ',')
|
||||
i++;
|
||||
if (i >= n || base[i] == ',')
|
||||
continue; // token without a '=', skip it
|
||||
size_t name_len = i - name_start;
|
||||
while (name_len > 0 && base[name_start + name_len - 1] == ' ')
|
||||
name_len--;
|
||||
i++; // consume '='
|
||||
const char *val_start;
|
||||
size_t val_len;
|
||||
if (i < n && base[i] == '"') {
|
||||
i++;
|
||||
val_start = base + i;
|
||||
while (i < n && base[i] != '"')
|
||||
i++;
|
||||
val_len = (base + i) - val_start;
|
||||
if (i < n)
|
||||
i++; // consume closing quote
|
||||
} else {
|
||||
val_start = base + i;
|
||||
while (i < n && base[i] != ',')
|
||||
i++;
|
||||
val_len = (base + i) - val_start;
|
||||
}
|
||||
if (name_len == key_len && memcmp(base + name_start, key, key_len) == 0)
|
||||
return StringRef(val_start, val_len);
|
||||
while (i < n && base[i] != ',')
|
||||
i++;
|
||||
}
|
||||
return StringRef();
|
||||
}
|
||||
|
||||
// Verify an RFC 2617 Digest response. Stateless (the nonce we issued is not tracked), which
|
||||
// matches the ESPAsyncWebServer backend used on the Arduino platforms.
|
||||
bool check_digest_auth(const char *username, const char *password, const std::string &header, const char *method) {
|
||||
const size_t prefix_len = sizeof("Digest ") - 1;
|
||||
StringRef params(header.c_str() + prefix_len, header.size() - prefix_len);
|
||||
|
||||
if (digest_param(params, "username") != username)
|
||||
return false;
|
||||
|
||||
StringRef realm = digest_param(params, "realm");
|
||||
StringRef nonce = digest_param(params, "nonce");
|
||||
StringRef uri = digest_param(params, "uri");
|
||||
StringRef qop = digest_param(params, "qop");
|
||||
StringRef nc = digest_param(params, "nc");
|
||||
StringRef cnonce = digest_param(params, "cnonce");
|
||||
StringRef response = digest_param(params, "response");
|
||||
if (response.size() != 32)
|
||||
return false;
|
||||
|
||||
// Compute the three MD5 hashes by streaming the pieces straight into the ROM MD5 engine, so
|
||||
// nothing is concatenated on the heap. Each hash is emitted as 32 lowercase hex characters.
|
||||
md5_context_t ctx;
|
||||
uint8_t digest[16];
|
||||
|
||||
// HA1 = MD5(username:realm:password) -- uses the realm the client echoed back.
|
||||
char ha1[33];
|
||||
esp_rom_md5_init(&ctx);
|
||||
esp_rom_md5_update(&ctx, username, strlen(username));
|
||||
esp_rom_md5_update(&ctx, ":", 1);
|
||||
esp_rom_md5_update(&ctx, realm.c_str(), realm.size());
|
||||
esp_rom_md5_update(&ctx, ":", 1);
|
||||
esp_rom_md5_update(&ctx, password, strlen(password));
|
||||
esp_rom_md5_final(digest, &ctx);
|
||||
format_hex_to(ha1, digest, sizeof(digest));
|
||||
|
||||
// HA2 = MD5(method:uri) -- uses the uri the client echoed back.
|
||||
char ha2[33];
|
||||
esp_rom_md5_init(&ctx);
|
||||
esp_rom_md5_update(&ctx, method, strlen(method));
|
||||
esp_rom_md5_update(&ctx, ":", 1);
|
||||
esp_rom_md5_update(&ctx, uri.c_str(), uri.size());
|
||||
esp_rom_md5_final(digest, &ctx);
|
||||
format_hex_to(ha2, digest, sizeof(digest));
|
||||
|
||||
// expected = MD5(HA1:nonce:nc:cnonce:qop:HA2)
|
||||
char expected[33];
|
||||
esp_rom_md5_init(&ctx);
|
||||
esp_rom_md5_update(&ctx, ha1, 32);
|
||||
esp_rom_md5_update(&ctx, ":", 1);
|
||||
esp_rom_md5_update(&ctx, nonce.c_str(), nonce.size());
|
||||
esp_rom_md5_update(&ctx, ":", 1);
|
||||
esp_rom_md5_update(&ctx, nc.c_str(), nc.size());
|
||||
esp_rom_md5_update(&ctx, ":", 1);
|
||||
esp_rom_md5_update(&ctx, cnonce.c_str(), cnonce.size());
|
||||
esp_rom_md5_update(&ctx, ":", 1);
|
||||
esp_rom_md5_update(&ctx, qop.c_str(), qop.size());
|
||||
esp_rom_md5_update(&ctx, ":", 1);
|
||||
esp_rom_md5_update(&ctx, ha2, 32);
|
||||
esp_rom_md5_final(digest, &ctx);
|
||||
format_hex_to(expected, digest, sizeof(digest));
|
||||
|
||||
// Constant-time comparison of the two 32-char hex digests.
|
||||
uint8_t result = 0;
|
||||
for (size_t i = 0; i < 32; i++)
|
||||
result |= static_cast<uint8_t>(expected[i] ^ response[i]);
|
||||
return result == 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
#endif // USE_WEBSERVER_AUTH_DIGEST
|
||||
|
||||
bool AsyncWebServerRequest::authenticate(const char *username, const char *password) const {
|
||||
if (username == nullptr || password == nullptr || *username == 0) {
|
||||
return true;
|
||||
@@ -383,9 +507,18 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw
|
||||
|
||||
auto *auth_str = auth.value().c_str();
|
||||
|
||||
#ifdef USE_WEBSERVER_AUTH_DIGEST
|
||||
// The build fixed the scheme to Digest, so the Basic path is compiled out entirely.
|
||||
const auto auth_prefix_len = sizeof("Digest ") - 1;
|
||||
if (strncmp("Digest ", auth_str, auth_prefix_len) != 0) {
|
||||
ESP_LOGW(TAG, "Only Digest authorization supported");
|
||||
return false;
|
||||
}
|
||||
return check_digest_auth(username, password, auth.value(), http_method_str(this->method()));
|
||||
#else
|
||||
const auto auth_prefix_len = sizeof("Basic ") - 1;
|
||||
if (strncmp("Basic ", auth_str, auth_prefix_len) != 0) {
|
||||
ESP_LOGW(TAG, "Only Basic authorization supported yet");
|
||||
ESP_LOGW(TAG, "Only Basic authorization supported");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -434,16 +567,33 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw
|
||||
result |= static_cast<uint8_t>(digest[i] ^ provided_ch);
|
||||
}
|
||||
return result == 0;
|
||||
#endif // USE_WEBSERVER_AUTH_DIGEST
|
||||
}
|
||||
|
||||
void AsyncWebServerRequest::requestAuthentication(const char *realm) const {
|
||||
void AsyncWebServerRequest::requestAuthentication() const {
|
||||
httpd_resp_set_hdr(*this, "Connection", "keep-alive");
|
||||
// Note: realm is never configured in ESPHome, always nullptr -> "Login Required"
|
||||
(void) realm; // Unused - always use default
|
||||
#ifdef USE_WEBSERVER_AUTH_DIGEST
|
||||
// Issue a fresh random nonce and opaque. The nonce is not stored, so this is stateless and
|
||||
// does not defend against replay -- its purpose is to keep the password off the wire.
|
||||
// The header value must stay alive until httpd_resp_send_err() below sends it, so the buffer
|
||||
// lives on this stack frame (httpd_resp_set_hdr stores the pointer, it does not copy).
|
||||
uint8_t random_bytes[16];
|
||||
char nonce[33];
|
||||
char opaque[33];
|
||||
char header[160];
|
||||
esp_fill_random(random_bytes, sizeof(random_bytes));
|
||||
format_hex_to(nonce, random_bytes, sizeof(random_bytes));
|
||||
esp_fill_random(random_bytes, sizeof(random_bytes));
|
||||
format_hex_to(opaque, random_bytes, sizeof(random_bytes));
|
||||
snprintf(header, sizeof(header), R"(Digest realm="Login Required", qop="auth", nonce="%s", opaque="%s")", nonce,
|
||||
opaque);
|
||||
httpd_resp_set_hdr(*this, "WWW-Authenticate", header);
|
||||
#else
|
||||
httpd_resp_set_hdr(*this, "WWW-Authenticate", "Basic realm=\"Login Required\"");
|
||||
#endif // USE_WEBSERVER_AUTH_DIGEST
|
||||
httpd_resp_send_err(*this, HTTPD_401_UNAUTHORIZED, nullptr);
|
||||
}
|
||||
#endif
|
||||
#endif // USE_WEBSERVER_AUTH
|
||||
|
||||
AsyncWebParameter *AsyncWebServerRequest::getParam(const char *name) {
|
||||
// Check cache first - only successful lookups are cached
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user