mirror of
https://github.com/esphome/esphome.git
synced 2026-08-27 16:29:29 +00:00
Merge remote-tracking branch 'origin/dev' into web-server-offline-hint
This commit is contained in:
@@ -182,8 +182,6 @@ jobs:
|
||||
contents: read # actions/checkout to load the test configs
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# Modest cap so this smoke test leaves room on the shared runner pool.
|
||||
max-parallel: 8
|
||||
matrix:
|
||||
# One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4)
|
||||
# share a toolchain bundle, so esp32 is exercised on the base variant
|
||||
|
||||
@@ -946,7 +946,6 @@ jobs:
|
||||
ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: ${{ needs.determine-jobs.outputs.release-pr == 'true' && 32 || 16 }}
|
||||
matrix:
|
||||
batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }}
|
||||
steps:
|
||||
|
||||
+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.13.0
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.13.1
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
+9
-8
@@ -2734,10 +2734,14 @@ def run_esphome(argv):
|
||||
# Skipped when -s overrides are passed, since the cache was written
|
||||
# against the previous substitution set.
|
||||
config: ConfigType | None = None
|
||||
cache_eligible = (
|
||||
cache_write_eligible = (
|
||||
args.command in ("upload", "logs") and not command_line_substitutions
|
||||
)
|
||||
if cache_eligible:
|
||||
# An explicit --toolchain must re-run the per-platform validators, so
|
||||
# gate only the cache read; the refresh below saves the result unless
|
||||
# the sidecar records a different toolchain.
|
||||
cache_read_eligible = cache_write_eligible and args.toolchain is None
|
||||
if cache_read_eligible:
|
||||
from esphome.compiled_config import load_compiled_config
|
||||
|
||||
config = load_compiled_config(conf_path)
|
||||
@@ -2761,17 +2765,14 @@ def run_esphome(argv):
|
||||
return 2
|
||||
CORE.config = config
|
||||
|
||||
# Fallback for platforms whose validators didn't set the toolchain
|
||||
# (only the esp32 component reads esp32.framework.toolchain). All
|
||||
# other platforms only support PlatformIO today. Must run before the
|
||||
# cache refresh below so its sidecar records the same toolchain a
|
||||
# compile would.
|
||||
# The cache fast path skips validation, and legacy sidecars lack the
|
||||
# toolchain field. Must run before the cache refresh below.
|
||||
if CORE.toolchain is None:
|
||||
CORE.toolchain = Toolchain.PLATFORMIO
|
||||
|
||||
# Refresh the cache so the next upload/logs hits the fast path
|
||||
# instead of re-running read_config.
|
||||
if cache_eligible and cache_missed:
|
||||
if cache_write_eligible and cache_missed:
|
||||
from esphome.compiled_config import save_compiled_config_and_sidecar
|
||||
|
||||
save_compiled_config_and_sidecar(config)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Native (PlatformIO-free) build support for the ESP8266 Arduino core.
|
||||
|
||||
This package downloads the Arduino ESP8266 core and the xtensa-lx106
|
||||
toolchain, generates a ninja build for them plus the ESPHome sources, and
|
||||
drives the build directly — the ESP8266 equivalent of ``esphome.espidf``.
|
||||
|
||||
Deliberately importable without the esp8266 component to avoid circular
|
||||
imports; the component wires these modules in via lazy imports.
|
||||
"""
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Download and install the Arduino ESP8266 core, toolchain, and ninja.
|
||||
|
||||
Artifacts land in a machine-global cache (shared across projects, like the
|
||||
ESP-IDF install in ``esphome.espidf.framework``):
|
||||
|
||||
<cache>/arduino8266/frameworks/<version>/ framework-arduinoespressif8266
|
||||
<cache>/arduino8266/toolchains/<version>/ toolchain-xtensa (gcc 10.3)
|
||||
|
||||
Packages come from the PlatformIO registry (identical bits to the PlatformIO
|
||||
backend); ``ESPHOME_ARDUINO8266_*_MIRRORS`` overrides the URLs. ninja comes
|
||||
from PATH or the ninja PyPI wheel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
from esphome.build_helpers.ccache import ccache_defaults_env
|
||||
from esphome.build_helpers.ninja import find_ninja
|
||||
from esphome.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path
|
||||
from esphome.core import EsphomeError, Version
|
||||
from esphome.framework_helpers import str_to_lst_of_str
|
||||
from esphome.platformio.registry import install_package, prefetch_packages
|
||||
|
||||
FRAMEWORK_PACKAGE = "framework-arduinoespressif8266"
|
||||
TOOLCHAIN_PACKAGE = "toolchain-xtensa"
|
||||
# gcc 10.3, the toolchain Arduino core 3.x builds with; the build
|
||||
# generator's compile flags are tuned to it.
|
||||
TOOLCHAIN_VERSION = "2.100300.220621"
|
||||
|
||||
ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS = str_to_lst_of_str(
|
||||
os.environ.get("ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS", "")
|
||||
)
|
||||
ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS = str_to_lst_of_str(
|
||||
os.environ.get("ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS", "")
|
||||
)
|
||||
|
||||
|
||||
def get_arduino8266_tools_path() -> Path:
|
||||
# Machine-global so all projects share one install; see
|
||||
# espidf.framework.get_idf_tools_path for the location rationale.
|
||||
return tools_cache_path(*ARDUINO8266_TOOLS_CACHE)
|
||||
|
||||
|
||||
# 3.1.1 rather than 3.1.0: the registry has no package for 3.1.0, and the
|
||||
# encoder below cannot name 3.0.0/3.0.1 either (see its docstring)
|
||||
MIN_FRAMEWORK_VERSION = Version(3, 1, 1)
|
||||
|
||||
|
||||
def framework_package_version(ver: Version) -> str:
|
||||
"""Map an Arduino core version to its registry package version (3.1.2 ->
|
||||
3.30102.0; the leading 3 is the package major).
|
||||
|
||||
Exact registry names only for cores > 2.6.2 and >= 3.0.2; callers floor
|
||||
at MIN_FRAMEWORK_VERSION.
|
||||
"""
|
||||
if ver.major > 3:
|
||||
raise EsphomeError(
|
||||
f"Arduino core {ver} is not supported yet; "
|
||||
"the newest known core series is 3.x"
|
||||
)
|
||||
if ver <= Version(2, 6, 2):
|
||||
# Cores <= 2.6.2 use the older 1.x/2.x package-major encodings (same
|
||||
# boundary as _format_framework_arduino_version's era guard)
|
||||
raise EsphomeError(
|
||||
f"Arduino core {ver} uses an older package encoding than this "
|
||||
"helper implements (newer than 2.6.2)"
|
||||
)
|
||||
return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
|
||||
|
||||
|
||||
def get_framework_path(package_version: str) -> Path:
|
||||
return get_arduino8266_tools_path() / "frameworks" / package_version
|
||||
|
||||
|
||||
def get_toolchain_path() -> Path:
|
||||
return get_arduino8266_tools_path() / "toolchains" / TOOLCHAIN_VERSION
|
||||
|
||||
|
||||
class InstalledPaths(NamedTuple):
|
||||
"""Locations of the installed framework, toolchain, and ninja binary."""
|
||||
|
||||
framework: Path
|
||||
toolchain: Path
|
||||
ninja: Path
|
||||
|
||||
|
||||
def check_and_install(framework_version: Version) -> InstalledPaths:
|
||||
"""Ensure framework, toolchain, and ninja are installed; return their paths."""
|
||||
if framework_version < MIN_FRAMEWORK_VERSION:
|
||||
# Config validation enforces this too; keep the module honest when
|
||||
# called directly.
|
||||
raise EsphomeError(
|
||||
f"The native toolchain requires the Arduino core "
|
||||
f">= {MIN_FRAMEWORK_VERSION}, got {framework_version}"
|
||||
)
|
||||
# Probe the cheap local dependency before ~110 MB of downloads
|
||||
ninja_path = find_ninja()
|
||||
package_version = framework_package_version(framework_version)
|
||||
framework_path = get_framework_path(package_version)
|
||||
downloads_dir = get_arduino8266_tools_path() / "downloads"
|
||||
toolchain_path = get_toolchain_path()
|
||||
# One spec per package: the prefetch and the installs must agree
|
||||
specs = (
|
||||
(
|
||||
FRAMEWORK_PACKAGE,
|
||||
package_version,
|
||||
framework_path,
|
||||
ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS,
|
||||
("cores/esp8266", "tools/sdk", "libraries"),
|
||||
),
|
||||
(
|
||||
TOOLCHAIN_PACKAGE,
|
||||
TOOLCHAIN_VERSION,
|
||||
toolchain_path,
|
||||
ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS,
|
||||
# xtensa-lx106-elf pins the target: every gcc package has a bin/
|
||||
("bin", "xtensa-lx106-elf"),
|
||||
),
|
||||
)
|
||||
# Fetch both archives at once; the installs below verify and extract
|
||||
prefetch_packages([spec[:4] for spec in specs], downloads_dir)
|
||||
for name, version, dest, mirrors, expect in specs:
|
||||
install_package(name, version, dest, mirrors, downloads_dir, expect=expect)
|
||||
return InstalledPaths(
|
||||
framework=framework_path, toolchain=toolchain_path, ninja=ninja_path
|
||||
)
|
||||
|
||||
|
||||
def toolchain_tool(toolchain_path: Path, name: str) -> Path:
|
||||
"""Path to one toolchain tool (gcc, g++, ar, size, addr2line, ...).
|
||||
|
||||
The single owner of the ``bin/xtensa-lx106-elf-<name>`` layout and the
|
||||
Windows suffix, so a toolchain package bump touches one spot.
|
||||
"""
|
||||
suffix = ".exe" if os.name == "nt" else ""
|
||||
return toolchain_path / "bin" / f"xtensa-lx106-elf-{name}{suffix}"
|
||||
|
||||
|
||||
def get_build_env(toolchain_path: Path, ccache: str | None) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
# Drop empty entries: a trailing separator from an absent PATH would
|
||||
# make the shell search the current directory for tools
|
||||
parts = [
|
||||
str(toolchain_path / "bin"),
|
||||
*filter(None, env.get("PATH", "").split(os.pathsep)),
|
||||
]
|
||||
env["PATH"] = os.pathsep.join(parts)
|
||||
env.update(ccache_env(ccache))
|
||||
return env
|
||||
|
||||
|
||||
def ccache_env(ccache: str | None) -> dict[str, str]:
|
||||
"""Return ccache settings for the build subprocess (not os.environ).
|
||||
|
||||
``ccache`` is the pre-resolved binary (resolve_ccache_path), or None
|
||||
when disabled. Values the user already set in the environment are
|
||||
respected.
|
||||
"""
|
||||
if ccache is None:
|
||||
return {}
|
||||
return ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Shared ccache policy for build backends: env-knob parsing, binary
|
||||
resolution, and default ``CCACHE_*`` values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs
|
||||
from esphome.helpers import FALSY_ENV_STRINGS, TRUTHY_ENV_STRINGS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ccache_runs(ccache: str) -> bool:
|
||||
"""Return True when the ``ccache`` found on PATH actually runs."""
|
||||
return tool_version_runs(
|
||||
ccache,
|
||||
"Ignoring ccache at %s because it failed to run; compiling without ccache",
|
||||
)
|
||||
|
||||
|
||||
def parse_enable_env(name: str) -> bool | None:
|
||||
"""Strictly parse an on/off environment knob; None when unset or invalid.
|
||||
|
||||
``bool(str)`` truthiness would flip ``no``/``off`` to enabled, so only
|
||||
1/true/yes/on and 0/false/no/off count; anything else warns and reads
|
||||
as unset so the caller's default policy applies.
|
||||
"""
|
||||
raw = os.environ.get(name)
|
||||
if raw is None:
|
||||
return None
|
||||
lowered = raw.strip().lower()
|
||||
if not lowered:
|
||||
# ENV KNOB= (Docker/CI) has always read as a disable
|
||||
return False
|
||||
if lowered in TRUTHY_ENV_STRINGS:
|
||||
return True
|
||||
if lowered in FALSY_ENV_STRINGS:
|
||||
return False
|
||||
_LOGGER.warning("Ignoring unrecognized %s=%r; use 1 or 0", name, raw)
|
||||
return None
|
||||
|
||||
|
||||
def resolve_ccache_path() -> str | None:
|
||||
"""The ccache binary to wrap compiles with, or None when disabled.
|
||||
|
||||
An explicit ``ESPHOME_CCACHE_ENABLE=1`` skips the runnability probe; the
|
||||
Windows extended-length prefix is stripped before probing (#18399).
|
||||
"""
|
||||
import shutil
|
||||
|
||||
explicit = parse_enable_env("ESPHOME_CCACHE_ENABLE")
|
||||
if explicit is False:
|
||||
return None
|
||||
ccache = shutil.which("ccache")
|
||||
if ccache is None:
|
||||
if explicit:
|
||||
_LOGGER.warning(
|
||||
"ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; "
|
||||
"compiling without ccache"
|
||||
)
|
||||
return None
|
||||
ccache = strip_win_long_path_prefix(ccache)
|
||||
if not explicit and not _ccache_runs(ccache):
|
||||
return None
|
||||
return ccache
|
||||
|
||||
|
||||
def ccache_defaults_env(cache_dir: Path) -> dict[str, str]:
|
||||
"""Default ``CCACHE_*`` values for a build subprocess (not os.environ).
|
||||
|
||||
Values the user already set in the environment are respected. Depend
|
||||
mode is on: both native backends emit depfiles (-MMD / CMake), which
|
||||
keeps cache-miss overhead low.
|
||||
"""
|
||||
from esphome.core import CORE
|
||||
|
||||
# An unset build_path means the env was built before preload; fail loudly
|
||||
# rather than silently drop CCACHE_BASEDIR.
|
||||
if CORE.build_path is None:
|
||||
raise ValueError(
|
||||
"CORE.build_path must be set before constructing the build environment"
|
||||
)
|
||||
defaults = {
|
||||
"CCACHE_DIR": str(cache_dir),
|
||||
"CCACHE_NOHASHDIR": "true",
|
||||
"CCACHE_DEPEND": "1",
|
||||
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
|
||||
}
|
||||
return {k: v for k, v in defaults.items() if k not in os.environ}
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Platform-neutral helpers for ninja-driven native builds."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ninja_runs(binary: str) -> bool:
|
||||
"""Whether the ninja found on PATH actually runs (see tool_version_runs)."""
|
||||
return tool_version_runs(
|
||||
binary,
|
||||
"Ignoring ninja at %s because it failed to run; "
|
||||
"falling back to the bundled wheel",
|
||||
)
|
||||
|
||||
|
||||
def find_ninja() -> Path:
|
||||
"""Locate the ninja binary: a runnable PATH hit first, else the ninja
|
||||
PyPI wheel."""
|
||||
if binary := shutil.which("ninja"):
|
||||
binary = strip_win_long_path_prefix(binary)
|
||||
if _ninja_runs(binary):
|
||||
return Path(binary)
|
||||
import_error: ImportError | None = None
|
||||
try:
|
||||
import ninja
|
||||
except ImportError as err:
|
||||
import_error = err
|
||||
wheel_binary = None
|
||||
else:
|
||||
wheel_binary = Path(ninja.BIN_DIR) / (
|
||||
"ninja.exe" if os.name == "nt" else "ninja"
|
||||
)
|
||||
if wheel_binary is None or not wheel_binary.is_file():
|
||||
raise EsphomeError(
|
||||
"ninja not found on PATH or in the ninja package; reinstall the "
|
||||
"esphome Python environment"
|
||||
) from import_error
|
||||
return wheel_binary
|
||||
|
||||
|
||||
def escape(value: Path | str) -> str:
|
||||
"""Escape a path or token for a ninja file."""
|
||||
return str(value).replace("$", "$$").replace(":", "$:").replace(" ", "$ ")
|
||||
|
||||
|
||||
def quote_arg(tok: str) -> str:
|
||||
"""Quote with the CreateProcess argv rule (as ``subprocess.list2cmdline``):
|
||||
backslash runs double only before a quote. Windows-only; ``$`` must
|
||||
already be doubled for ninja.
|
||||
"""
|
||||
quoted = re.sub(r'(\\*)"', lambda m: m.group(1) * 2 + '\\"', tok)
|
||||
quoted = re.sub(r"(\\+)\Z", lambda m: m.group(1) * 2, quoted)
|
||||
return f'"{quoted}"'
|
||||
|
||||
|
||||
# Force-quote any token containing a character outside the shlex.quote-style
|
||||
# safe set: ninja hands POSIX commands to /bin/sh -c, so bare (, ;, <, *, `
|
||||
# and friends would be re-parsed as shell syntax.
|
||||
_NEEDS_QUOTE = re.compile(r"[^\w@%+=:,./-]")
|
||||
|
||||
|
||||
def shell_token(tok: str, force: bool = False) -> str:
|
||||
"""Re-quote a lexed token for the platform shell; ``force`` always quotes.
|
||||
|
||||
Single quotes on POSIX (/bin/sh), the argv rule on Windows
|
||||
(CreateProcess). ``$`` is doubled first because ninja expands it before
|
||||
the command reaches the shell.
|
||||
"""
|
||||
tok = tok.replace("$", "$$") # ninja would expand a bare $ to nothing
|
||||
if not (force or not tok or _NEEDS_QUOTE.search(tok)):
|
||||
return tok
|
||||
# An empty token must become '' / "" or it vanishes from the argv
|
||||
if os.name == "nt":
|
||||
return quote_arg(tok)
|
||||
# shlex.quote's rule; inlined because the $-doubled token must not be
|
||||
# re-examined for safe characters
|
||||
return "'" + tok.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def quote_path(value: Path | str) -> str:
|
||||
"""Force-quote a path for the ninja command line (shell/CreateProcess)."""
|
||||
return shell_token(str(value), force=True)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Machine-global tools cache location shared by the native backends."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def tools_cache_path(env_var: str, subdir: str) -> Path:
|
||||
"""A backend's machine-global tools directory, with an env override.
|
||||
|
||||
A blank/whitespace override is treated as unset: ``Path("")`` resolves
|
||||
to the CWD, which ``clean-all`` would then delete.
|
||||
"""
|
||||
import platformdirs
|
||||
|
||||
from esphome.helpers import get_str_env
|
||||
|
||||
if prefix := get_str_env(env_var, "").strip():
|
||||
# resolve(): symlinked prefixes otherwise trip idf.py's
|
||||
# venv-mismatch warning on every build
|
||||
return Path(prefix).expanduser().resolve()
|
||||
# appauthor=False keeps the Windows path short (no vendor segment);
|
||||
# deep IDF trees run into MAX_PATH otherwise
|
||||
return (
|
||||
Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / subdir
|
||||
).resolve()
|
||||
|
||||
|
||||
# (env override, cache subdir) per native backend. writer.clean_all wipes
|
||||
# every entry via tools_cache_path, so listing a cache here is the single
|
||||
# step that registers it for removal; the backends' own path getters use
|
||||
# the same named pairs so the two cannot drift.
|
||||
IDF_TOOLS_CACHE = ("ESPHOME_ESP_IDF_PREFIX", "idf")
|
||||
SDK_NRF_TOOLS_CACHE = ("ESPHOME_SDK_NRF_PREFIX", "sdk-nrf")
|
||||
ARDUINO8266_TOOLS_CACHE = ("ESPHOME_ARDUINO8266_PREFIX", "arduino8266")
|
||||
TOOLS_CACHE_SPECS = (IDF_TOOLS_CACHE, SDK_NRF_TOOLS_CACHE, ARDUINO8266_TOOLS_CACHE)
|
||||
@@ -100,6 +100,21 @@ def _refresh_sidecar() -> bool:
|
||||
)
|
||||
return False
|
||||
if old is not None and old.can_apply_to_core():
|
||||
if (
|
||||
old.toolchain is not None
|
||||
and CORE.toolchain is not None
|
||||
and old.toolchain != CORE.toolchain.value
|
||||
):
|
||||
# Platforms normalize toolchain-sensitive keys differently;
|
||||
# never cache a config validated under a different toolchain
|
||||
# than the compile's
|
||||
_LOGGER.debug(
|
||||
"Not caching: config validated with toolchain %r but the "
|
||||
"last compile used %r",
|
||||
CORE.toolchain.value,
|
||||
old.toolchain,
|
||||
)
|
||||
return False
|
||||
# Compile-written; nothing to refresh.
|
||||
return True
|
||||
if CORE.build_path is not None and CORE.build_path.exists():
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from esphome import automation
|
||||
@@ -499,6 +500,40 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
KEY_VALUE_SCHEMA = cv.Schema({cv.string: cv.templatable(cv.string_strict)})
|
||||
|
||||
_ID_CALL_PROG = re.compile(r"\bid\s*\(")
|
||||
|
||||
|
||||
# Remove before 2027.3.0: untagged strings that look like lambda source keep
|
||||
# being compiled as lambdas during the deprecation window
|
||||
def _coerce_implicit_lambda(value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if cv.looks_like_returning_lambda(value):
|
||||
_LOGGER.warning(
|
||||
"[api] The 'variables' value '%s' looks like a lambda but is "
|
||||
"missing the !lambda tag. It is compiled as a lambda for now but "
|
||||
"will be sent as literal text from 2027.3.0. Add !lambda to keep "
|
||||
"it evaluated; literal text belongs under 'data:'.",
|
||||
value,
|
||||
)
|
||||
# cv.templatable runs returning_lambda on the coerced Lambda
|
||||
return cv.lambda_(value)
|
||||
if _ID_CALL_PROG.search(value):
|
||||
# lambda source without a return: issue 5394's mistake class
|
||||
_LOGGER.warning(
|
||||
"[api] The 'variables' value '%s' is sent as literal text; wrap "
|
||||
"it in !lambda 'return ...;' to evaluate it instead.",
|
||||
value,
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
# Static strings or !lambda values. cv.templatable stays introspectable for
|
||||
# schema tooling; removing the shim leaves KEY_VALUE_SCHEMA.
|
||||
VARIABLES_SCHEMA = cv.Schema(
|
||||
{cv.string: cv.All(_coerce_implicit_lambda, cv.templatable(cv.string_strict))}
|
||||
)
|
||||
|
||||
|
||||
def _validate_response_config(config: ConfigType) -> ConfigType:
|
||||
# Validate dependencies:
|
||||
@@ -535,9 +570,7 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
|
||||
),
|
||||
cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA,
|
||||
cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA,
|
||||
cv.Optional(CONF_VARIABLES, default={}): cv.Schema(
|
||||
{cv.string: cv.returning_lambda}
|
||||
),
|
||||
cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA,
|
||||
cv.Optional(CONF_RESPONSE_TEMPLATE): cv.templatable(cv.string),
|
||||
cv.Optional(CONF_CAPTURE_RESPONSE, default=False): cv.boolean,
|
||||
cv.Optional(CONF_ON_SUCCESS): automation.validate_automation(single=True),
|
||||
@@ -598,6 +631,8 @@ async def homeassistant_service_to_code(
|
||||
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
|
||||
for key, value in config[CONF_VARIABLES].items():
|
||||
templ = await cg.templatable(value, args, None)
|
||||
if isinstance(templ, str):
|
||||
templ = cg.FlashStringLiteral(templ)
|
||||
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
|
||||
|
||||
if on_error := config.get(CONF_ON_ERROR):
|
||||
@@ -652,7 +687,7 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema(
|
||||
cv.Required(CONF_EVENT): validate_homeassistant_event,
|
||||
cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA,
|
||||
cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA,
|
||||
cv.Optional(CONF_VARIABLES, default={}): KEY_VALUE_SCHEMA,
|
||||
cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -698,6 +733,8 @@ async def homeassistant_event_to_code(
|
||||
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
|
||||
for key, value in config[CONF_VARIABLES].items():
|
||||
templ = await cg.templatable(value, args, None)
|
||||
if isinstance(templ, str):
|
||||
templ = cg.FlashStringLiteral(templ)
|
||||
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
|
||||
|
||||
return var
|
||||
|
||||
@@ -1654,7 +1654,8 @@ message ListEntitiesMediaPlayerResponse {
|
||||
bool disabled_by_default = 6;
|
||||
EntityCategory entity_category = 7;
|
||||
|
||||
bool supports_pause = 8;
|
||||
// Deprecated in ESPHome 2026.9.0; use feature_flags instead.
|
||||
bool supports_pause = 8 [deprecated = true];
|
||||
|
||||
repeated MediaPlayerSupportedFormat supported_formats = 9;
|
||||
|
||||
|
||||
@@ -1099,7 +1099,6 @@ uint16_t APIConnection::try_send_media_player_info(EntityBase *entity, APIConnec
|
||||
auto *media_player = static_cast<media_player::MediaPlayer *>(entity);
|
||||
ListEntitiesMediaPlayerResponse msg;
|
||||
auto traits = media_player->get_traits();
|
||||
msg.supports_pause = traits.get_supports_pause();
|
||||
msg.feature_flags = traits.get_feature_flags();
|
||||
for (auto &supported_format : traits.get_supported_formats()) {
|
||||
msg.supported_formats.emplace_back();
|
||||
@@ -1790,10 +1789,12 @@ void APIConnection::complete_authentication_() {
|
||||
bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
// Copy client name with truncation if needed (set_client_name handles truncation)
|
||||
this->helper_->set_client_name(msg.client_info.c_str(), msg.client_info.size());
|
||||
this->client_api_version_major_ = msg.api_version_major;
|
||||
this->client_api_version_minor_ = msg.api_version_minor;
|
||||
this->client_api_version_major_ =
|
||||
static_cast<uint8_t>(std::min<uint32_t>(msg.api_version_major, std::numeric_limits<uint8_t>::max()));
|
||||
this->client_api_version_minor_ =
|
||||
static_cast<uint8_t>(std::min<uint32_t>(msg.api_version_minor, std::numeric_limits<uint8_t>::max()));
|
||||
char peername[socket::SOCKADDR_STR_LEN];
|
||||
ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %" PRIu16 ".%" PRIu16, this->helper_->get_client_name(),
|
||||
ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %u.%u", this->helper_->get_client_name(),
|
||||
this->helper_->get_peername_to(peername), this->client_api_version_major_, this->client_api_version_minor_);
|
||||
|
||||
HelloResponse resp;
|
||||
@@ -2224,7 +2225,7 @@ bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool APIConnection::send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn,
|
||||
bool APIConnection::send_message_(uint32_t payload_size, uint16_t message_type, MessageEncodeFn encode_fn,
|
||||
const void *msg) {
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
// Skip dump for log messages (recursive logging risk) and camera frames (high-frequency noise)
|
||||
@@ -2253,7 +2254,7 @@ uint16_t APIConnection::encode_to_buffer_slow(uint32_t calculated_size, MessageE
|
||||
APIConnection *conn, uint32_t remaining_size) {
|
||||
return encode_to_buffer(calculated_size, encode_fn, msg, conn, remaining_size);
|
||||
}
|
||||
bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) {
|
||||
bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint16_t message_type) {
|
||||
const bool is_log_message = (message_type == SubscribeLogsResponse::MESSAGE_TYPE);
|
||||
|
||||
if (!this->try_to_clear_buffer(!is_log_message)) {
|
||||
@@ -2283,12 +2284,12 @@ void APIConnection::on_fatal_error() {
|
||||
this->flags_.remove = true;
|
||||
}
|
||||
|
||||
bool APIConnection::schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) {
|
||||
bool APIConnection::schedule_message_front_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) {
|
||||
this->deferred_batch_.add_item_front(entity, message_type, estimated_size);
|
||||
return this->schedule_batch_();
|
||||
}
|
||||
|
||||
bool APIConnection::send_message_smart_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size,
|
||||
bool APIConnection::send_message_smart_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
|
||||
uint8_t aux_data_index) {
|
||||
if (this->should_send_immediately_(message_type) && this->helper_->can_write_without_blocking()) {
|
||||
auto &shared_buf = this->parent_->get_shared_buffer_ref();
|
||||
|
||||
@@ -326,8 +326,10 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
bool is_marked_for_removal() const { return this->flags_.remove; }
|
||||
uint8_t get_log_subscription_level() const { return this->flags_.log_subscription; }
|
||||
|
||||
// Get client API version for feature detection
|
||||
bool client_supports_api_version(uint16_t major, uint16_t minor) const {
|
||||
// Get client API version for feature detection.
|
||||
// Stored versions saturate at 255 (see send_hello_response_), so requesting
|
||||
// a minimum above that can never match.
|
||||
bool client_supports_api_version(uint8_t major, uint8_t minor) const {
|
||||
return this->client_api_version_major_ > major ||
|
||||
(this->client_api_version_major_ == major && this->client_api_version_minor_ >= minor);
|
||||
}
|
||||
@@ -374,7 +376,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
return true;
|
||||
return this->try_to_clear_buffer_slow_(log_out_of_space);
|
||||
}
|
||||
bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type);
|
||||
bool send_buffer(ProtoWriteBuffer buffer, uint16_t message_type);
|
||||
|
||||
const char *get_name() const { return this->helper_->get_client_name(); }
|
||||
/// Get peer name (IP address) into caller-provided buffer, returns buf for convenience
|
||||
@@ -423,7 +425,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
}
|
||||
|
||||
// Non-template buffer management for send_message
|
||||
bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg);
|
||||
bool send_message_(uint32_t payload_size, uint16_t message_type, MessageEncodeFn encode_fn, const void *msg);
|
||||
|
||||
// Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites.
|
||||
// Defined in api_connection_buffer.h (needs APIServer complete).
|
||||
@@ -664,10 +666,9 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
|
||||
struct BatchItem {
|
||||
EntityBase *entity; // 4 bytes - Entity pointer
|
||||
uint8_t message_type; // 1 byte - Message type for protocol and dispatch
|
||||
uint16_t message_type; // 2 bytes - Message type for protocol and dispatch
|
||||
uint8_t estimated_size; // 1 byte - Estimated message size (max 255 bytes)
|
||||
uint8_t aux_data_index{AUX_DATA_UNUSED}; // 1 byte - For events: index into entity's event_types
|
||||
// 1 byte padding
|
||||
};
|
||||
|
||||
std::vector<BatchItem> items;
|
||||
@@ -677,7 +678,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// connections that do, buffers are released after initial sync anyway
|
||||
|
||||
// Add item to the batch (with deduplication)
|
||||
void add_item(EntityBase *entity, uint8_t message_type, uint8_t estimated_size,
|
||||
void add_item(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
|
||||
uint8_t aux_data_index = AUX_DATA_UNUSED) {
|
||||
// Dedup: O(n) scan but optimized for RAM over performance
|
||||
// Skip deduplication for events - they are edge-triggered, every occurrence matters
|
||||
@@ -693,7 +694,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
this->items.push_back({entity, message_type, estimated_size, aux_data_index});
|
||||
}
|
||||
// Add item to the front of the batch (for high priority messages like ping)
|
||||
void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) {
|
||||
void add_item_front(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) {
|
||||
// Swap to front avoids expensive vector::insert which shifts all elements
|
||||
this->items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED});
|
||||
if (this->items.size() > 1) {
|
||||
@@ -758,13 +759,15 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
#endif
|
||||
} flags_{}; // 2 bytes total
|
||||
|
||||
// 2-byte types immediately after flags_ (no padding between them)
|
||||
uint16_t client_api_version_major_{0};
|
||||
uint16_t client_api_version_minor_{0};
|
||||
// 2-byte type immediately after flags_ (no padding between them)
|
||||
uint16_t batch_message_type_{0}; // Current message type during batch encoding
|
||||
// 1-byte types to fill remaining space before next 4-byte boundary
|
||||
// Client API versions are clamped to 255 on receive (see send_hello_response_)
|
||||
uint8_t client_api_version_major_{0};
|
||||
uint8_t client_api_version_minor_{0};
|
||||
ActiveIterator active_iterator_{ActiveIterator::NONE};
|
||||
uint8_t batch_message_type_{0}; // Current message type during batch encoding
|
||||
// Total: 2 (flags) + 2 + 2 + 1 + 1 = 8 bytes, aligned to 4-byte boundary
|
||||
// Total: 2 (flags) + 2 + 1 + 1 + 1 + 1 (batch_header_size_ below) = 8 bytes,
|
||||
// aligned to 4-byte boundary
|
||||
|
||||
// Actual header size used by encode_to_buffer for the current message.
|
||||
// Read by process_batch_multi_ to pass into MessageInfo.
|
||||
@@ -813,7 +816,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// 2. It's an EventResponse (events are edge-triggered - every occurrence matters)
|
||||
// 3. OR: User has opted into immediate sending (should_try_send_immediately = true
|
||||
// AND batch_delay = 0)
|
||||
inline bool should_send_immediately_(uint8_t message_type) const {
|
||||
inline bool should_send_immediately_(uint16_t message_type) const {
|
||||
return (
|
||||
#ifdef USE_UPDATE
|
||||
message_type == UpdateStateResponse::MESSAGE_TYPE ||
|
||||
@@ -827,11 +830,11 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// Helper method to send a message either immediately or via batching
|
||||
// Tries immediate send if should_send_immediately_() returns true and buffer has space
|
||||
// Falls back to batching if immediate send fails or isn't applicable
|
||||
bool send_message_smart_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size,
|
||||
bool send_message_smart_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
|
||||
uint8_t aux_data_index = DeferredBatch::AUX_DATA_UNUSED);
|
||||
|
||||
// Helper function to schedule a deferred message with known message type
|
||||
bool schedule_message_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size,
|
||||
bool schedule_message_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
|
||||
uint8_t aux_data_index = DeferredBatch::AUX_DATA_UNUSED) {
|
||||
this->deferred_batch_.add_item(entity, message_type, estimated_size, aux_data_index);
|
||||
return this->schedule_batch_();
|
||||
@@ -839,7 +842,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
|
||||
// Helper function to schedule a high priority message at the front of the batch
|
||||
// Out-of-line: callers (on_shutdown, check_keepalive_) are cold paths
|
||||
bool schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size);
|
||||
bool schedule_message_front_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size);
|
||||
|
||||
// Helper function to log client messages with name and peername
|
||||
void log_client_(int level, const LogString *message);
|
||||
|
||||
@@ -172,7 +172,7 @@ APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uin
|
||||
|
||||
// Queue unsent data into overflow buffer
|
||||
if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, static_cast<uint16_t>(sent))) {
|
||||
HELPER_LOG("Overflow buffer full, dropping connection");
|
||||
HELPER_LOG("Overflow buffer full or out of memory, dropping connection");
|
||||
this->state_ = State::FAILED;
|
||||
return APIError::SOCKET_WRITE_FAILED;
|
||||
}
|
||||
|
||||
@@ -49,16 +49,16 @@ struct ReadPacketBuffer {
|
||||
};
|
||||
|
||||
// Packed message info structure to minimize memory usage
|
||||
// Note: message_type is uint8_t — all current protobuf message types fit in 8 bits.
|
||||
// The noise wire format encodes types as 16-bit, but the high byte is always 0.
|
||||
// If message types ever exceed 255, this and encrypt_noise_message_ must be updated.
|
||||
// message_type matches the wire formats: noise carries a fixed 16-bit type
|
||||
// field, plaintext a type varint. The proto codegen caps message IDs at 16383
|
||||
// so the plaintext type varint fits the 2 bytes budgeted in HEADER_PADDING.
|
||||
struct MessageInfo {
|
||||
uint16_t offset; // Offset in buffer where message starts
|
||||
uint16_t payload_size; // Size of the message payload
|
||||
uint8_t message_type; // Message type (0-255)
|
||||
uint16_t message_type; // Message type (0-16383)
|
||||
uint8_t header_size; // Actual header size used (avoids recomputation in write path)
|
||||
|
||||
MessageInfo(uint8_t type, uint16_t off, uint16_t size, uint8_t hdr)
|
||||
MessageInfo(uint16_t type, uint16_t off, uint16_t size, uint8_t hdr)
|
||||
: offset(off), payload_size(size), message_type(type), header_size(hdr) {}
|
||||
};
|
||||
|
||||
@@ -173,7 +173,7 @@ class APIFrameHelper {
|
||||
}
|
||||
// Write a single protobuf message - the hot path (87-100% of all writes).
|
||||
// Caller must ensure state is DATA before calling.
|
||||
virtual APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) = 0;
|
||||
virtual APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) = 0;
|
||||
// Write multiple protobuf messages in a single batched operation.
|
||||
// Caller must ensure state is DATA and messages is not empty.
|
||||
// messages contains (message_type, offset, length) for each message in the buffer.
|
||||
@@ -187,15 +187,15 @@ class APIFrameHelper {
|
||||
// Distinguishes protocols via frame_footer_size_ (noise always has a non-zero MAC
|
||||
// footer, plaintext has footer=0). If a protocol with a plaintext footer is ever
|
||||
// added, this should become a virtual method.
|
||||
uint8_t frame_header_size(uint16_t payload_size, uint8_t message_type) const {
|
||||
uint8_t frame_header_size(uint16_t payload_size, uint16_t message_type) const {
|
||||
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
|
||||
return this->frame_footer_size_
|
||||
? this->frame_header_padding_
|
||||
: static_cast<uint8_t>(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint8(message_type));
|
||||
: static_cast<uint8_t>(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint16(message_type));
|
||||
#elif defined(USE_API_NOISE)
|
||||
return this->frame_header_padding_;
|
||||
#else // USE_API_PLAINTEXT only
|
||||
return static_cast<uint8_t>(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint8(message_type));
|
||||
return static_cast<uint8_t>(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint16(message_type));
|
||||
#endif
|
||||
}
|
||||
// Get the frame footer size required by this protocol
|
||||
|
||||
@@ -442,7 +442,7 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) {
|
||||
}
|
||||
// Encrypt a single noise message in place and return the encrypted frame length.
|
||||
// Returns APIError::OK on success.
|
||||
APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type,
|
||||
APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint16_t message_type,
|
||||
uint16_t &encrypted_len_out) {
|
||||
// The noise frame header is written after encryption, when the size is known
|
||||
|
||||
@@ -472,7 +472,7 @@ APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_
|
||||
return APIError::OK;
|
||||
}
|
||||
|
||||
APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) {
|
||||
APIError APINoiseFrameHelper::write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) {
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
assert(this->state_ == State::DATA);
|
||||
#endif
|
||||
|
||||
@@ -31,7 +31,7 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
||||
#endif
|
||||
APIError loop() override;
|
||||
APIError read_packet(ReadPacketBuffer *buffer) override;
|
||||
APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override;
|
||||
APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) override;
|
||||
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) override;
|
||||
|
||||
protected:
|
||||
@@ -44,7 +44,7 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
||||
APIError state_action_handshake_write_();
|
||||
APIError try_read_frame_();
|
||||
APIError write_frame_(const uint8_t *data, uint16_t len);
|
||||
APIError encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type,
|
||||
APIError encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint16_t message_type,
|
||||
uint16_t &encrypted_len_out);
|
||||
APIError init_handshake_();
|
||||
APIError check_handshake_finished_();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "api_pb2.h"
|
||||
#include "proto.h"
|
||||
#include <cstring>
|
||||
#include <cinttypes>
|
||||
@@ -252,24 +253,21 @@ ESPHOME_ALWAYS_INLINE static inline void encode_varint_16(uint16_t value, uint8_
|
||||
*p = static_cast<uint8_t>(value);
|
||||
}
|
||||
|
||||
// Encode an 8-bit varint (1-2 bytes) using pre-computed length.
|
||||
ESPHOME_ALWAYS_INLINE static inline void encode_varint_8(uint8_t value, uint8_t varint_len, uint8_t *p) {
|
||||
if (varint_len == 2) {
|
||||
*p++ = static_cast<uint8_t>(value | 0x80);
|
||||
*p = static_cast<uint8_t>(value >> 7);
|
||||
} else {
|
||||
*p = value;
|
||||
}
|
||||
}
|
||||
// The generator rejects message IDs above MAX_MESSAGE_TYPE, so the type varint
|
||||
// can never outgrow the 2 bytes HEADER_PADDING budgets for it. Without this
|
||||
// bound, write_plaintext_header's header_offset would underflow for the first
|
||||
// message in a batch and the header write would land outside the buffer.
|
||||
static_assert(1 + 3 + ProtoSize::varint16(MAX_MESSAGE_TYPE) <= APIPlaintextFrameHelper::HEADER_PADDING,
|
||||
"HEADER_PADDING cannot fit the type varint of the largest message ID");
|
||||
|
||||
// Write plaintext header into pre-allocated padding before payload.
|
||||
// padding_size: bytes reserved before payload (HEADER_PADDING for first/single msg,
|
||||
// actual header size for contiguous batch messages).
|
||||
// Returns the total header length (indicator + varints).
|
||||
ESPHOME_ALWAYS_INLINE static inline uint8_t write_plaintext_header(uint8_t *buf_start, uint16_t payload_size,
|
||||
uint8_t message_type, uint8_t padding_size) {
|
||||
uint16_t message_type, uint8_t padding_size) {
|
||||
uint8_t size_varint_len = ProtoSize::varint16(payload_size);
|
||||
uint8_t type_varint_len = ProtoSize::varint8(message_type);
|
||||
uint8_t type_varint_len = ProtoSize::varint16(message_type);
|
||||
uint8_t total_header_len = 1 + size_varint_len + type_varint_len;
|
||||
|
||||
// The header is right-justified within the padding so it sits immediately before payload.
|
||||
@@ -292,12 +290,12 @@ ESPHOME_ALWAYS_INLINE static inline uint8_t write_plaintext_header(uint8_t *buf_
|
||||
|
||||
// Encode varints directly into buffer using pre-computed lengths
|
||||
encode_varint_16(payload_size, size_varint_len, buf_start + header_offset + 1);
|
||||
encode_varint_8(message_type, type_varint_len, buf_start + header_offset + 1 + size_varint_len);
|
||||
encode_varint_16(message_type, type_varint_len, buf_start + header_offset + 1 + size_varint_len);
|
||||
|
||||
return total_header_len;
|
||||
}
|
||||
|
||||
APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) {
|
||||
APIError APIPlaintextFrameHelper::write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) {
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
assert(this->state_ == State::DATA);
|
||||
#endif
|
||||
|
||||
@@ -10,7 +10,8 @@ class APIPlaintextFrameHelper final : public APIFrameHelper {
|
||||
// Plaintext header structure (worst case):
|
||||
// Pos 0: indicator (0x00)
|
||||
// Pos 1-3: payload size varint (up to 3 bytes)
|
||||
// Pos 4-5: message type varint (up to 2 bytes)
|
||||
// Pos 4-5: message type varint (up to 2 bytes; covers message IDs up to
|
||||
// 16383, enforced by the proto codegen)
|
||||
// Pos 6+: actual payload data
|
||||
static constexpr uint8_t HEADER_PADDING = 1 + 3 + 2; // indicator + size varint + type varint
|
||||
|
||||
@@ -21,7 +22,7 @@ class APIPlaintextFrameHelper final : public APIFrameHelper {
|
||||
APIError init() override;
|
||||
APIError loop() override;
|
||||
APIError read_packet(ReadPacketBuffer *buffer) override;
|
||||
APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override;
|
||||
APIError write_protobuf_packet(uint16_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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "api_overflow_buffer.h"
|
||||
#ifdef USE_API
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
|
||||
namespace esphome::api {
|
||||
|
||||
@@ -61,9 +62,18 @@ bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_
|
||||
return false;
|
||||
|
||||
uint16_t buffer_size = total_len - skip;
|
||||
// nothrow: a failed allocation returns nullptr so the connection is dropped
|
||||
// cleanly instead of plain new's crash or abort on OOM
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
|
||||
auto *entry = new Entry{new uint8_t[buffer_size], buffer_size, 0};
|
||||
this->queue_[this->tail_] = entry;
|
||||
auto *data = new (std::nothrow) uint8_t[buffer_size];
|
||||
if (data == nullptr)
|
||||
return false;
|
||||
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
|
||||
auto *entry = new (std::nothrow) Entry{data, buffer_size, 0};
|
||||
if (entry == nullptr) {
|
||||
delete[] data;
|
||||
return false;
|
||||
}
|
||||
|
||||
uint16_t to_skip = skip;
|
||||
uint16_t write_pos = 0;
|
||||
@@ -80,6 +90,8 @@ bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_
|
||||
}
|
||||
}
|
||||
|
||||
// Publish only after the copy completes so a half-built entry is never reachable
|
||||
this->queue_[this->tail_] = entry;
|
||||
this->tail_ = (this->tail_ + 1) % API_MAX_SEND_QUEUE;
|
||||
this->count_++;
|
||||
return true;
|
||||
|
||||
@@ -61,7 +61,7 @@ class APIOverflowBuffer {
|
||||
|
||||
/// Enqueue unsent IOV data into the backlog.
|
||||
/// Copies iov data starting at byte offset `skip` into a new entry.
|
||||
/// Returns false if the queue is full (caller should fail the connection).
|
||||
/// Returns false if the queue is full or allocation fails (caller should fail the connection).
|
||||
bool enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip);
|
||||
|
||||
protected:
|
||||
|
||||
@@ -2323,7 +2323,6 @@ uint8_t *ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer &buffer PROTO_
|
||||
#endif
|
||||
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default);
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast<uint32_t>(this->entity_category));
|
||||
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supports_pause);
|
||||
for (auto &it : this->supported_formats) {
|
||||
ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 9, it);
|
||||
}
|
||||
@@ -2343,7 +2342,6 @@ uint32_t ListEntitiesMediaPlayerResponse::calculate_size() const {
|
||||
#endif
|
||||
size += ProtoSize::calc_bool(1, this->disabled_by_default);
|
||||
size += this->entity_category ? 2 : 0;
|
||||
size += ProtoSize::calc_bool(1, this->supports_pause);
|
||||
if (!this->supported_formats.empty()) {
|
||||
for (const auto &it : this->supported_formats) {
|
||||
size += ProtoSize::calc_message_force(1, it.calculate_size());
|
||||
|
||||
+145
-142
File diff suppressed because it is too large
Load Diff
@@ -1962,7 +1962,6 @@ const char *ListEntitiesMediaPlayerResponse::dump_to(DumpBuffer &out) const {
|
||||
#endif
|
||||
dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default);
|
||||
dump_field(out, ESPHOME_PSTR("entity_category"), static_cast<enums::EntityCategory>(this->entity_category));
|
||||
dump_field(out, ESPHOME_PSTR("supports_pause"), this->supports_pause);
|
||||
for (const auto &it : this->supported_formats) {
|
||||
out.append(4, ' ').append_p(ESPHOME_PSTR("supported_formats")).append(": ");
|
||||
it.dump_to(out);
|
||||
|
||||
@@ -684,11 +684,6 @@ class ProtoSize {
|
||||
return value < VARINT_THRESHOLD_1_BYTE ? 1 : (value < VARINT_THRESHOLD_2_BYTE ? 2 : 3);
|
||||
}
|
||||
|
||||
// Varint encoded length for an 8-bit value (1 or 2 bytes).
|
||||
static constexpr inline uint8_t ESPHOME_ALWAYS_INLINE varint8(uint8_t value) {
|
||||
return value < VARINT_THRESHOLD_1_BYTE ? 1 : 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Calculates the size in bytes needed to encode a uint32_t value as a varint
|
||||
*
|
||||
|
||||
@@ -551,7 +551,14 @@ ClimateCall ClimateDeviceRestoreState::to_call(Climate *climate) {
|
||||
|
||||
void ClimateDeviceRestoreState::apply(Climate *climate) {
|
||||
auto traits = climate->get_traits();
|
||||
climate->mode = this->mode;
|
||||
// A saved mode the device no longer offers cannot be selected again, so skip it and leave the
|
||||
// entity on the mode it already has. The other saved fields are still restored.
|
||||
if (traits.supports_mode(this->mode)) {
|
||||
climate->mode = this->mode;
|
||||
} else {
|
||||
ESP_LOGW(TAG, "'%s' - Saved mode %s is no longer supported, keeping %s", climate->get_name().c_str(),
|
||||
LOG_STR_ARG(climate_mode_to_string(this->mode)), LOG_STR_ARG(climate_mode_to_string(climate->mode)));
|
||||
}
|
||||
if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
|
||||
CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {
|
||||
climate->target_temperature_low = this->target_temperature_low;
|
||||
|
||||
@@ -13,9 +13,11 @@ CONF_HEADER_LOW = "header_low"
|
||||
CONF_BIT_HIGH = "bit_high"
|
||||
CONF_BIT_ONE_LOW = "bit_one_low"
|
||||
CONF_BIT_ZERO_LOW = "bit_zero_low"
|
||||
CONF_ADVANCED_COMMANDS_SUPPORT = "advanced_commands_support"
|
||||
|
||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(LgIrClimate).extend(
|
||||
{
|
||||
cv.Optional(CONF_ADVANCED_COMMANDS_SUPPORT, default=False): cv.boolean,
|
||||
cv.Optional(
|
||||
CONF_HEADER_HIGH, default="8000us"
|
||||
): cv.positive_time_period_microseconds,
|
||||
@@ -38,6 +40,7 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(LgIrClimate).extend(
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = await climate_ir.new_climate_ir(config)
|
||||
|
||||
cg.add(var.set_advanced_commands_support(config[CONF_ADVANCED_COMMANDS_SUPPORT]))
|
||||
cg.add(var.set_header_high(config[CONF_HEADER_HIGH]))
|
||||
cg.add(var.set_header_low(config[CONF_HEADER_LOW]))
|
||||
cg.add(var.set_bit_high(config[CONF_BIT_HIGH]))
|
||||
|
||||
@@ -5,11 +5,85 @@ namespace esphome::climate_ir_lg {
|
||||
|
||||
static const char *const TAG = "climate.climate_ir_lg";
|
||||
|
||||
// Commands
|
||||
const uint32_t COMMAND_MASK = 0xFF000;
|
||||
const uint32_t COMMAND_OFF = 0xC0000;
|
||||
const uint32_t COMMAND_SWING = 0x10000;
|
||||
// All codes provided here are missing the checksum (last 4 bits)
|
||||
// this checksum needs to be calculated before sending (look at `calc_checksum_()`)
|
||||
|
||||
const uint32_t LG_HEADER = 0x8800000;
|
||||
|
||||
// Commands
|
||||
const uint32_t COMMAND_HEADER_MASK = 0xFF000;
|
||||
const uint32_t COMMAND_DATA_MASK = 0x00FF0;
|
||||
const uint32_t CHECKSUM_MASK = 0xF;
|
||||
|
||||
enum CommandBasic : uint32_t {
|
||||
HEADER_BASIC = 0x10000,
|
||||
BASIC_SWING_TOGGLE = 0x000,
|
||||
|
||||
// JET MODE (only for cooling/drying/heating modes)
|
||||
// For 30 minutes: max airflow (stronger than F5 aka FAN_MAX) + PO (min/min/max temperature respectively)
|
||||
// After 30 minutes: F5 aka FAN_MAX + min/min/max temperature respectively
|
||||
BASIC_JET = 0x080,
|
||||
};
|
||||
|
||||
enum CommandSys : uint32_t {
|
||||
HEADER_SYS = 0xC0000,
|
||||
|
||||
COMMAND_OFF = 0x050,
|
||||
|
||||
// Also known as 'auto-dry'
|
||||
AUTO_CLEAN_ON = 0x0B0,
|
||||
AUTO_CLEAN_OFF = 0x0C0,
|
||||
|
||||
PURIFY_ON = 0x000, // From either OFF or Mode -> Purify
|
||||
PURIFY_OFF = 0x080, // From Mode + Purify -> Mode
|
||||
|
||||
QUIET_OUTDOOR_ON = 0xA60,
|
||||
QUIET_OUTDOOR_OFF = 0xA70,
|
||||
|
||||
// ENERGY CTRL (only in Cooling mode)
|
||||
COOL_ENERG_CTRL_80 = 0x7D0, // 80%
|
||||
COOL_ENERG_CTRL_60 = 0x7E0, // 60%
|
||||
COOL_ENERG_CTRL_40 = 0x800, // 40%
|
||||
COOL_ENERG_CTRL_OFF = 0x7F0, // OFF
|
||||
|
||||
DISPLAY_KW = 0x460,
|
||||
LIGHT_ON_OFF = 0x0A0,
|
||||
|
||||
TEMP_UNIT_F = 0x170,
|
||||
TEMP_UNIT_C = 0x160,
|
||||
};
|
||||
|
||||
enum CommandAdvSwing : uint32_t {
|
||||
HEADER_ADV_SWING = 0x13000,
|
||||
|
||||
// Only 5 bits are relevant, I got 0x13952 once - not sure what is the 8th bit so ignoring that.
|
||||
ADV_SWING_DATA_MASK = 0x1F0,
|
||||
|
||||
// Commands for Advanced Vertical Control: Swing + 6 fixed positions
|
||||
VERT_FIX_1 = 0x040, // Down
|
||||
VERT_FIX_2 = 0x050,
|
||||
VERT_FIX_3 = 0x060,
|
||||
VERT_FIX_4 = 0x070,
|
||||
VERT_FIX_5 = 0x080,
|
||||
VERT_FIX_6 = 0x090, // Up
|
||||
VERT_SWING_ON = 0x140, // Swing between 1 and 6
|
||||
VERT_SWING_OFF = 0x150, // Stops immediately
|
||||
|
||||
// Commands for Advanced Horizontal Control: Swing (3 modes) + 5 fixed positions
|
||||
HORI_FIX_1 = 0x0B0, // Left
|
||||
HORI_FIX_2 = 0x0C0,
|
||||
HORI_FIX_3 = 0x0D0,
|
||||
HORI_FIX_4 = 0x0E0,
|
||||
HORI_FIX_5 = 0x0F0, // Right
|
||||
HORI_SWING_ON_LEFT = 0x100, // Swing between 1 and 3
|
||||
HORI_SWING_ON_RIGHT = 0x110, // Swing between 3 and 5
|
||||
HORI_SWING_ON_FULL = 0x160, // Swing between 1 and 5
|
||||
HORI_SWING_OFF = 0x170, // Stops immediately
|
||||
};
|
||||
|
||||
// Following commands contain mode, fan speed and temperature
|
||||
|
||||
// Modes
|
||||
const uint32_t COMMAND_ON_COOL = 0x00000;
|
||||
const uint32_t COMMAND_ON_DRY = 0x01000;
|
||||
const uint32_t COMMAND_ON_FAN_ONLY = 0x02000;
|
||||
@@ -23,11 +97,13 @@ const uint32_t COMMAND_AI = 0x0B000;
|
||||
const uint32_t COMMAND_HEAT = 0x0C000;
|
||||
|
||||
// Fan speed
|
||||
const uint32_t FAN_MASK = 0xF0;
|
||||
const uint32_t FAN_SPEED_MASK = 0xF0;
|
||||
const uint32_t FAN_AUTO = 0x50;
|
||||
const uint32_t FAN_MIN = 0x00;
|
||||
const uint32_t FAN_MED = 0x20;
|
||||
const uint32_t FAN_MAX = 0x40;
|
||||
const uint32_t FAN_MIN = 0x00; // AKA F1
|
||||
const uint32_t FAN_F2 = 0x90;
|
||||
const uint32_t FAN_MED = 0x20; // AKA F3
|
||||
const uint32_t FAN_F4 = 0xA0;
|
||||
const uint32_t FAN_MAX = 0x40; // AKA F5
|
||||
|
||||
// Temperature
|
||||
const uint8_t TEMP_RANGE = TEMP_MAX - TEMP_MIN + 1;
|
||||
@@ -37,16 +113,37 @@ const uint32_t TEMP_SHIFT = 8;
|
||||
const uint16_t BITS = 28;
|
||||
|
||||
void LgIrClimate::transmit_state() {
|
||||
uint32_t remote_state = 0x8800000;
|
||||
uint32_t remote_state = LG_HEADER;
|
||||
|
||||
// ESP_LOGD(TAG, "climate_lg_ir mode_before_ code: 0x%02X", modeBefore_);
|
||||
// ESP_LOGD(TAG, "climate_lg_ir mode_before_ code: 0x%02X", this->modeBefore_);
|
||||
|
||||
// Set command
|
||||
if (this->send_swing_cmd_) {
|
||||
this->send_swing_cmd_ = false;
|
||||
remote_state |= COMMAND_SWING;
|
||||
} else {
|
||||
bool climate_is_off = (this->mode_before_ == climate::CLIMATE_MODE_OFF);
|
||||
if (this->advanced_commands_support_) {
|
||||
switch (this->swing_mode) {
|
||||
case climate::CLIMATE_SWING_VERTICAL:
|
||||
ESP_LOGD(TAG, "setting swing vertical");
|
||||
remote_state |= CommandAdvSwing::HEADER_ADV_SWING;
|
||||
remote_state |= CommandAdvSwing::VERT_SWING_ON;
|
||||
break;
|
||||
case climate::CLIMATE_SWING_OFF:
|
||||
ESP_LOGD(TAG, "setting swing off");
|
||||
remote_state |= CommandAdvSwing::HEADER_ADV_SWING;
|
||||
remote_state |= CommandAdvSwing::VERT_SWING_OFF;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
this->transmit_(remote_state);
|
||||
this->publish_state();
|
||||
return;
|
||||
} else { // just toggle swing when advanced_commands_support is not set
|
||||
remote_state |= HEADER_BASIC;
|
||||
remote_state |= BASIC_SWING_TOGGLE;
|
||||
}
|
||||
} else { // Mode commands
|
||||
const bool climate_is_off = (this->mode_before_ == climate::CLIMATE_MODE_OFF);
|
||||
switch (this->mode) {
|
||||
case climate::CLIMATE_MODE_COOL:
|
||||
remote_state |= climate_is_off ? COMMAND_ON_COOL : COMMAND_COOL;
|
||||
@@ -65,8 +162,8 @@ void LgIrClimate::transmit_state() {
|
||||
break;
|
||||
case climate::CLIMATE_MODE_OFF:
|
||||
default:
|
||||
remote_state |= COMMAND_OFF;
|
||||
break;
|
||||
remote_state |= CommandSys::HEADER_SYS;
|
||||
remote_state |= CommandSys::COMMAND_OFF;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,9 +172,8 @@ void LgIrClimate::transmit_state() {
|
||||
ESP_LOGD(TAG, "climate_lg_ir mode code: 0x%02X", this->mode);
|
||||
|
||||
// Set fan speed
|
||||
if (this->mode == climate::CLIMATE_MODE_OFF) {
|
||||
remote_state |= FAN_AUTO;
|
||||
} else {
|
||||
if (this->mode !=
|
||||
climate::CLIMATE_MODE_OFF) { // https://github.com/esphome/esphome/pull/10875#issuecomment-5042765948
|
||||
switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) {
|
||||
case climate::CLIMATE_FAN_HIGH:
|
||||
remote_state |= FAN_MAX;
|
||||
@@ -95,10 +191,20 @@ void LgIrClimate::transmit_state() {
|
||||
}
|
||||
}
|
||||
|
||||
// Set temperature
|
||||
if (this->mode == climate::CLIMATE_MODE_COOL || this->mode == climate::CLIMATE_MODE_HEAT) {
|
||||
auto temp = (uint8_t) roundf(clamp<float>(this->target_temperature, TEMP_MIN, TEMP_MAX));
|
||||
remote_state |= ((temp - 15) << TEMP_SHIFT);
|
||||
uint8_t temp;
|
||||
switch (this->mode) {
|
||||
case climate::CLIMATE_MODE_HEAT_COOL:
|
||||
if (!this->advanced_commands_support_) { // Keep previous behavior
|
||||
break;
|
||||
}
|
||||
[[fallthrough]];
|
||||
case climate::CLIMATE_MODE_COOL:
|
||||
case climate::CLIMATE_MODE_HEAT:
|
||||
temp = static_cast<uint8_t>(roundf(clamp<float>(this->target_temperature, TEMP_MIN, TEMP_MAX)));
|
||||
remote_state |= (temp - 15) << TEMP_SHIFT;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
this->transmit_(remote_state);
|
||||
@@ -124,62 +230,134 @@ bool LgIrClimate::on_receive(remote_base::RemoteReceiveData data) {
|
||||
}
|
||||
}
|
||||
|
||||
ESP_LOGD(TAG, "Decoded 0x%02" PRIX32, remote_state);
|
||||
if ((remote_state & 0xFF00000) != 0x8800000)
|
||||
ESP_LOGD(TAG, "Received 0x%02" PRIX32, remote_state);
|
||||
if ((remote_state & 0xFF00000) != LG_HEADER)
|
||||
return false;
|
||||
|
||||
// Get command
|
||||
if ((remote_state & COMMAND_MASK) == COMMAND_OFF) {
|
||||
this->mode = climate::CLIMATE_MODE_OFF;
|
||||
} else if ((remote_state & COMMAND_MASK) == COMMAND_SWING) {
|
||||
this->swing_mode =
|
||||
this->swing_mode == climate::CLIMATE_SWING_OFF ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF;
|
||||
} else {
|
||||
switch (remote_state & COMMAND_MASK) {
|
||||
case COMMAND_DRY:
|
||||
case COMMAND_ON_DRY:
|
||||
this->mode = climate::CLIMATE_MODE_DRY;
|
||||
break;
|
||||
case COMMAND_FAN_ONLY:
|
||||
case COMMAND_ON_FAN_ONLY:
|
||||
this->mode = climate::CLIMATE_MODE_FAN_ONLY;
|
||||
break;
|
||||
case COMMAND_AI:
|
||||
case COMMAND_ON_AI:
|
||||
this->mode = climate::CLIMATE_MODE_HEAT_COOL;
|
||||
break;
|
||||
case COMMAND_HEAT:
|
||||
case COMMAND_ON_HEAT:
|
||||
this->mode = climate::CLIMATE_MODE_HEAT;
|
||||
break;
|
||||
case COMMAND_COOL:
|
||||
case COMMAND_ON_COOL:
|
||||
default:
|
||||
this->mode = climate::CLIMATE_MODE_COOL;
|
||||
break;
|
||||
}
|
||||
|
||||
// Get fan speed
|
||||
if (this->mode == climate::CLIMATE_MODE_HEAT_COOL) {
|
||||
this->fan_mode = climate::CLIMATE_FAN_AUTO;
|
||||
} else if (this->mode == climate::CLIMATE_MODE_COOL || this->mode == climate::CLIMATE_MODE_DRY ||
|
||||
this->mode == climate::CLIMATE_MODE_FAN_ONLY || this->mode == climate::CLIMATE_MODE_HEAT) {
|
||||
if ((remote_state & FAN_MASK) == FAN_AUTO) {
|
||||
this->fan_mode = climate::CLIMATE_FAN_AUTO;
|
||||
} else if ((remote_state & FAN_MASK) == FAN_MIN) {
|
||||
this->fan_mode = climate::CLIMATE_FAN_LOW;
|
||||
} else if ((remote_state & FAN_MASK) == FAN_MED) {
|
||||
this->fan_mode = climate::CLIMATE_FAN_MEDIUM;
|
||||
} else if ((remote_state & FAN_MASK) == FAN_MAX) {
|
||||
this->fan_mode = climate::CLIMATE_FAN_HIGH;
|
||||
// Decode commands
|
||||
switch (remote_state & COMMAND_HEADER_MASK) {
|
||||
case CommandSys::HEADER_SYS:
|
||||
ESP_LOGD(TAG, "Got system command! With data: 0x%02" PRIX32, remote_state & COMMAND_DATA_MASK);
|
||||
if ((remote_state & COMMAND_DATA_MASK) == CommandSys::COMMAND_OFF) {
|
||||
this->mode = climate::CLIMATE_MODE_OFF;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case CommandAdvSwing::HEADER_ADV_SWING:
|
||||
ESP_LOGD(TAG, "Got advanced swing command! With data: 0x%02" PRIX32,
|
||||
remote_state & CommandAdvSwing::ADV_SWING_DATA_MASK);
|
||||
switch (remote_state & CommandAdvSwing::ADV_SWING_DATA_MASK) {
|
||||
case CommandAdvSwing::VERT_SWING_ON:
|
||||
this->swing_mode = climate::CLIMATE_SWING_VERTICAL;
|
||||
break;
|
||||
case CommandAdvSwing::VERT_SWING_OFF:
|
||||
case CommandAdvSwing::VERT_FIX_1:
|
||||
case CommandAdvSwing::VERT_FIX_2:
|
||||
case CommandAdvSwing::VERT_FIX_3:
|
||||
case CommandAdvSwing::VERT_FIX_4:
|
||||
case CommandAdvSwing::VERT_FIX_5:
|
||||
case CommandAdvSwing::VERT_FIX_6:
|
||||
this->swing_mode = climate::CLIMATE_SWING_OFF;
|
||||
break;
|
||||
default:
|
||||
return false; // Ignore all other (horizontal) swing commands
|
||||
}
|
||||
}
|
||||
|
||||
// Get temperature
|
||||
if (this->mode == climate::CLIMATE_MODE_COOL || this->mode == climate::CLIMATE_MODE_HEAT) {
|
||||
this->target_temperature = ((remote_state & TEMP_MASK) >> TEMP_SHIFT) + 15;
|
||||
}
|
||||
this->publish_state();
|
||||
return true;
|
||||
|
||||
case HEADER_BASIC:
|
||||
if ((remote_state & COMMAND_DATA_MASK) == BASIC_JET) {
|
||||
switch (this->mode) {
|
||||
case climate::CLIMATE_MODE_COOL:
|
||||
case climate::CLIMATE_MODE_HEAT:
|
||||
case climate::CLIMATE_MODE_DRY:
|
||||
this->target_temperature =
|
||||
this->mode == climate::CLIMATE_MODE_HEAT ? this->maximum_temperature_ : this->minimum_temperature_;
|
||||
this->fan_mode = climate::CLIMATE_FAN_HIGH;
|
||||
// When enabling PO(WER) also known as JET mode, swing is set to VERT_3, but after 30 mins it will switch
|
||||
// back to what it was before, so let's just not change it here it at all
|
||||
this->publish_state();
|
||||
return true;
|
||||
default:
|
||||
ESP_LOGD(TAG, "Got jet command, but current mode does not support it! Ignoring.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep previous behavior in case of other BASIC command
|
||||
if (this->swing_mode == climate::CLIMATE_SWING_OFF) { // Just flip between vertical and off
|
||||
this->swing_mode = climate::CLIMATE_SWING_VERTICAL;
|
||||
} else {
|
||||
this->swing_mode = climate::CLIMATE_SWING_OFF;
|
||||
}
|
||||
this->publish_state();
|
||||
return true;
|
||||
// Following commands also contain fan speed and temperature, so no 'return' in these cases
|
||||
case COMMAND_DRY:
|
||||
case COMMAND_ON_DRY:
|
||||
this->mode = climate::CLIMATE_MODE_DRY;
|
||||
break;
|
||||
case COMMAND_FAN_ONLY:
|
||||
case COMMAND_ON_FAN_ONLY:
|
||||
this->mode = climate::CLIMATE_MODE_FAN_ONLY;
|
||||
break;
|
||||
case COMMAND_AI:
|
||||
case COMMAND_ON_AI:
|
||||
this->mode = climate::CLIMATE_MODE_HEAT_COOL;
|
||||
break;
|
||||
case COMMAND_HEAT:
|
||||
case COMMAND_ON_HEAT:
|
||||
this->mode = climate::CLIMATE_MODE_HEAT;
|
||||
break;
|
||||
case COMMAND_COOL:
|
||||
case COMMAND_ON_COOL:
|
||||
this->mode = climate::CLIMATE_MODE_COOL;
|
||||
break;
|
||||
default:
|
||||
ESP_LOGD(TAG, "Got unknown command! Ignoring!");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Decode fan speed
|
||||
switch (remote_state & FAN_SPEED_MASK) {
|
||||
case FAN_AUTO:
|
||||
this->fan_mode = climate::CLIMATE_FAN_AUTO;
|
||||
break;
|
||||
case FAN_MIN:
|
||||
case FAN_F2:
|
||||
this->fan_mode = climate::CLIMATE_FAN_LOW;
|
||||
break;
|
||||
case FAN_MED:
|
||||
case FAN_F4:
|
||||
this->fan_mode = climate::CLIMATE_FAN_MEDIUM;
|
||||
break;
|
||||
case FAN_MAX:
|
||||
this->fan_mode = climate::CLIMATE_FAN_HIGH;
|
||||
break;
|
||||
default:
|
||||
ESP_LOGD(TAG, "Got unknown fan speed! Ignoring!");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Keep previous behavior
|
||||
if (this->mode == climate::CLIMATE_MODE_HEAT_COOL && !(this->advanced_commands_support_)) {
|
||||
this->fan_mode = climate::CLIMATE_FAN_AUTO;
|
||||
}
|
||||
|
||||
// Decode temperature for modes that support it
|
||||
switch (this->mode) {
|
||||
case climate::CLIMATE_MODE_HEAT_COOL:
|
||||
case climate::CLIMATE_MODE_COOL:
|
||||
case climate::CLIMATE_MODE_HEAT:
|
||||
this->target_temperature = ((remote_state & TEMP_MASK) >> TEMP_SHIFT) + 15;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
this->mode_before_ = this->mode;
|
||||
this->publish_state();
|
||||
|
||||
return true;
|
||||
@@ -207,14 +385,14 @@ void LgIrClimate::transmit_(uint32_t value) {
|
||||
data->mark(this->bit_high_);
|
||||
transmit.perform();
|
||||
}
|
||||
|
||||
void LgIrClimate::calc_checksum_(uint32_t &value) {
|
||||
uint32_t mask = 0xF;
|
||||
uint32_t sum = 0;
|
||||
for (uint8_t i = 1; i < 8; i++) {
|
||||
sum += (value & (mask << (i * 4))) >> (i * 4);
|
||||
sum += (value & (CHECKSUM_MASK << (i * 4))) >> (i * 4);
|
||||
}
|
||||
|
||||
value |= (sum & mask);
|
||||
value |= (sum & CHECKSUM_MASK);
|
||||
}
|
||||
|
||||
} // namespace esphome::climate_ir_lg
|
||||
|
||||
@@ -21,12 +21,13 @@ class LgIrClimate final : public climate_ir::ClimateIR {
|
||||
/// Override control to change settings of the climate device.
|
||||
void control(const climate::ClimateCall &call) override {
|
||||
this->send_swing_cmd_ = call.get_swing_mode().has_value();
|
||||
// swing resets after unit powered off
|
||||
// swing resets after unit powered off, except when advanced_commands_support_ is set
|
||||
auto mode = call.get_mode();
|
||||
if (mode.has_value() && *mode == climate::CLIMATE_MODE_OFF)
|
||||
if (mode.has_value() && *mode == climate::CLIMATE_MODE_OFF && !(this->advanced_commands_support_))
|
||||
this->swing_mode = climate::CLIMATE_SWING_OFF;
|
||||
climate_ir::ClimateIR::control(call);
|
||||
}
|
||||
void set_advanced_commands_support(bool value) { this->advanced_commands_support_ = value; }
|
||||
void set_header_high(uint32_t header_high) { this->header_high_ = header_high; }
|
||||
void set_header_low(uint32_t header_low) { this->header_low_ = header_low; }
|
||||
void set_bit_high(uint32_t bit_high) { this->bit_high_ = bit_high; }
|
||||
@@ -44,6 +45,7 @@ class LgIrClimate final : public climate_ir::ClimateIR {
|
||||
void calc_checksum_(uint32_t &value);
|
||||
void transmit_(uint32_t value);
|
||||
|
||||
bool advanced_commands_support_{false};
|
||||
uint32_t header_high_;
|
||||
uint32_t header_low_;
|
||||
uint32_t bit_high_;
|
||||
|
||||
@@ -116,14 +116,16 @@ _CALLBACK_AUTOMATIONS = (
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await uart.register_uart_device(var, config)
|
||||
|
||||
# Initialize sensor storage with count from final_validate
|
||||
# Initialize sensor storage with count from final_validate before any
|
||||
# await, so platform to_code() calls always see it initialized
|
||||
# regardless of YAML key order.
|
||||
sensor_count = _get_data().sensor_counts.get(str(config[CONF_ID]), 0)
|
||||
if sensor_count > 0:
|
||||
cg.add(var.init_sensors(sensor_count))
|
||||
|
||||
await cg.register_component(var, config)
|
||||
await uart.register_uart_device(var, config)
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
|
||||
|
||||
|
||||
@@ -1105,19 +1105,11 @@ def _check_esp_idf_versions(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
def _validate_toolchain(value) -> Toolchain:
|
||||
return Toolchain(
|
||||
cv.one_of(Toolchain.PLATFORMIO, Toolchain.ESP_IDF, lower=True)(value)
|
||||
)
|
||||
|
||||
|
||||
def _resolve_toolchain(value: ConfigType) -> ConfigType:
|
||||
# Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default.
|
||||
# Runs before _detect_variant so downstream validators can rely on
|
||||
# CORE.toolchain instead of re-resolving it from the config dict.
|
||||
if CORE.toolchain is None:
|
||||
CORE.toolchain = value.get(CONF_TOOLCHAIN, Toolchain.ESP_IDF)
|
||||
return value
|
||||
_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.ESP_IDF)
|
||||
_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS)
|
||||
# Runs before _detect_variant so downstream validators can rely on
|
||||
# CORE.toolchain instead of re-resolving it from the config dict.
|
||||
_resolve_toolchain = cv.resolve_toolchain("ESP32", _TOOLCHAINS, Toolchain.ESP_IDF)
|
||||
|
||||
|
||||
def _check_versions(config: ConfigType) -> ConfigType:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "preferences.h"
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
@@ -29,6 +30,13 @@ void loop_task(void *pv_params) {
|
||||
}
|
||||
|
||||
extern "C" void app_main() {
|
||||
// Apply the custom eFuse MAC (if burned and valid) as the base MAC before any
|
||||
// interface (Wi-Fi, Ethernet, Bluetooth, 802.15.4) derives its address from it.
|
||||
// The logger does not exist yet, so only log-free helpers may be used here.
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
if (get_custom_mac_address(mac)) {
|
||||
set_mac_address(mac);
|
||||
}
|
||||
initArduino();
|
||||
esp32::setup_preferences();
|
||||
#if CONFIG_FREERTOS_UNICORE
|
||||
|
||||
@@ -71,23 +71,32 @@ static bool read_valid_mac(uint8_t *mac, esp_err_t err) { return err == ESP_OK &
|
||||
|
||||
static constexpr size_t MAC_ADDRESS_SIZE_BITS = MAC_ADDRESS_SIZE * 8; // 48 bits
|
||||
|
||||
// Must not use the ESPHome logger (may run before it exists, e.g. from app_main()).
|
||||
bool get_custom_mac_address(uint8_t *mac) {
|
||||
// has_custom_mac_address() checks the raw eFuse field, while the reads below select their
|
||||
// method differently and may still fail (CRC), so the result must be validated again.
|
||||
if (!has_custom_mac_address())
|
||||
return false;
|
||||
#if defined(CONFIG_SOC_IEEE802154_SUPPORTED)
|
||||
return read_valid_mac(mac, esp_efuse_read_field_blob(ESP_EFUSE_MAC_CUSTOM, mac, MAC_ADDRESS_SIZE_BITS));
|
||||
#else
|
||||
return read_valid_mac(mac, esp_efuse_mac_get_custom(mac));
|
||||
#endif
|
||||
}
|
||||
|
||||
void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter)
|
||||
if (get_custom_mac_address(mac)) {
|
||||
return;
|
||||
}
|
||||
#if defined(CONFIG_SOC_IEEE802154_SUPPORTED)
|
||||
// When CONFIG_SOC_IEEE802154_SUPPORTED is defined, esp_efuse_mac_get_default
|
||||
// returns the 802.15.4 EUI-64 address, so we read directly from eFuse instead.
|
||||
// Both paths already read raw eFuse bytes, so there is no CRC-bypass fallback
|
||||
// This already reads raw eFuse bytes, so there is no CRC-bypass fallback
|
||||
// (unlike the non-IEEE802154 path where esp_efuse_mac_get_default does CRC checks).
|
||||
if (has_custom_mac_address() &&
|
||||
read_valid_mac(mac, esp_efuse_read_field_blob(ESP_EFUSE_MAC_CUSTOM, mac, MAC_ADDRESS_SIZE_BITS))) {
|
||||
return;
|
||||
}
|
||||
if (read_valid_mac(mac, esp_efuse_read_field_blob(ESP_EFUSE_MAC_FACTORY, mac, MAC_ADDRESS_SIZE_BITS))) {
|
||||
return;
|
||||
}
|
||||
#else
|
||||
if (has_custom_mac_address() && read_valid_mac(mac, esp_efuse_mac_get_custom(mac))) {
|
||||
return;
|
||||
}
|
||||
if (read_valid_mac(mac, esp_efuse_mac_get_default(mac))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -143,6 +143,13 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType:
|
||||
# BLE uses the airtime wifi does not claim.
|
||||
IDF_SCAN_WINDOW_FIX_VERSION = cv.Version(5, 5, 5)
|
||||
|
||||
# Above this the scanner holds the shared radio long enough that wifi drops
|
||||
# packets and connections on some access points (others cope fine, which is
|
||||
# why this is a warning and not an error); old proxy configs with 1100 ms
|
||||
# windows are a recurring cause of instability (esphome/esphome#18655). Only
|
||||
# wifi shares the radio; long windows are fine on ethernet builds.
|
||||
MAX_RECOMMENDED_WIFI_SCAN_WINDOW = TimePeriod(milliseconds=600)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackerData:
|
||||
@@ -209,6 +216,45 @@ def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
def _warn_long_scan_window_with_wifi(config: ConfigType) -> ConfigType:
|
||||
"""Warn when the scan window is long enough to starve wifi.
|
||||
|
||||
Runs after _raise_defaulted_scan_window so it sees the final window.
|
||||
software_coexistence is only present when wifi is configured, so ethernet
|
||||
builds never warn: BLE has the radio to itself there. Presence is what
|
||||
matters, not the value; with the arbiter disabled a long window starves
|
||||
wifi outright.
|
||||
"""
|
||||
params = config[CONF_SCAN_PARAMETERS]
|
||||
window = params[CONF_WINDOW]
|
||||
if CONF_SOFTWARE_COEXISTENCE not in config:
|
||||
return config
|
||||
if window <= MAX_RECOMMENDED_WIFI_SCAN_WINDOW:
|
||||
return config
|
||||
if _get_data().scan_window_defaulted:
|
||||
# The window was raised to match the interval, so point at the key the
|
||||
# user actually set.
|
||||
_LOGGER.warning(
|
||||
"BLE scan interval of %s sets the scan window to the same value, "
|
||||
"which starves wifi on the same radio and can cause wifi disconnects "
|
||||
"depending on the access point; keep the interval at or below %s "
|
||||
"(for example interval: 320ms). Long windows are only a problem with "
|
||||
"wifi, they are fine on ethernet",
|
||||
params[CONF_INTERVAL],
|
||||
MAX_RECOMMENDED_WIFI_SCAN_WINDOW,
|
||||
)
|
||||
return config
|
||||
_LOGGER.warning(
|
||||
"BLE scan window of %s with wifi on the same radio starves wifi and "
|
||||
"can cause wifi disconnects depending on the access point; keep the "
|
||||
"window at or below %s (for example interval: 320ms, window: 300ms). "
|
||||
"Long windows are only a problem with wifi, they are fine on ethernet",
|
||||
window,
|
||||
MAX_RECOMMENDED_WIFI_SCAN_WINDOW,
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
# 320 ms is the ESP-IDF reference scan interval; the shared schema also
|
||||
# tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects
|
||||
# window/interval pairs that collapse to the same 0.625 ms unit count.
|
||||
@@ -271,6 +317,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
validate_max_connections_deprecated,
|
||||
_raise_defaulted_scan_window,
|
||||
_warn_long_scan_window_with_wifi,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ from esphome.platformio.toolchain import copy_ccache_script
|
||||
from esphome.storage_json import StorageJSON
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .boards import BOARDS, ESP8266_LD_SCRIPTS
|
||||
from .boards import BOARDS, ESP8266_LD_SCRIPTS, board_ld_script
|
||||
from .const import (
|
||||
CONF_EARLY_PIN_INIT,
|
||||
CONF_ENABLE_SERIAL,
|
||||
@@ -44,6 +44,7 @@ from .const import (
|
||||
KEY_BOARD,
|
||||
KEY_ESP8266,
|
||||
KEY_FLASH_SIZE,
|
||||
KEY_LDSCRIPT,
|
||||
KEY_PIN_INITIAL_STATES,
|
||||
KEY_SERIAL1_REQUIRED,
|
||||
KEY_SERIAL_REQUIRED,
|
||||
@@ -136,7 +137,16 @@ def _format_framework_arduino_version(ver: cv.Version) -> str:
|
||||
return f"~1.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
|
||||
if ver <= cv.Version(2, 6, 2):
|
||||
return f"~2.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
|
||||
return f"~3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
|
||||
# Same encoding the native toolchain uses for its package download, so a
|
||||
# version bump cannot drift between the two paths.
|
||||
from esphome.arduino8266.framework import framework_package_version
|
||||
|
||||
try:
|
||||
return f"~{framework_package_version(ver)}"
|
||||
except EsphomeError as err:
|
||||
# Anchor the 4.x rejection to the framework version line instead of
|
||||
# aborting with a bare traceback-level error
|
||||
raise cv.Invalid(str(err), path=[CONF_VERSION]) from err
|
||||
|
||||
|
||||
# NOTE: Keep this in mind when updating the recommended version:
|
||||
@@ -246,6 +256,9 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean,
|
||||
}
|
||||
),
|
||||
# Until the native toolchain lands, PlatformIO is the only backend;
|
||||
# reject a --toolchain this platform cannot serve yet.
|
||||
cv.require_platformio_toolchain("ESP8266"),
|
||||
set_core_data,
|
||||
)
|
||||
|
||||
@@ -276,6 +289,31 @@ def check_rosetta() -> None:
|
||||
)
|
||||
|
||||
|
||||
def _choose_ld_script(board: str, ver: cv.Version) -> str | None:
|
||||
"""The flash ld to pin for this board and core, or None for cores
|
||||
without ld-script support."""
|
||||
board_data = BOARDS[board]
|
||||
ld_scripts = ESP8266_LD_SCRIPTS[board_data[KEY_FLASH_SIZE]]
|
||||
if ver <= cv.Version(2, 3, 0):
|
||||
# No ld script support
|
||||
return None
|
||||
if ver <= cv.Version(2, 4, 2):
|
||||
# Old ld script path; the modern per-board override names do not
|
||||
# exist in this core's SDK, so the override cannot be honored.
|
||||
# Substituting the size default would move _FS_end and the
|
||||
# preferences sector, wiping flash-backed state on flash.
|
||||
if KEY_LDSCRIPT in board_data:
|
||||
raise EsphomeError(
|
||||
f"Board {board} requires its {board_data[KEY_LDSCRIPT]} "
|
||||
f"flash layout, which Arduino core {ver} cannot honor; "
|
||||
"use a core newer than 2.4.2"
|
||||
)
|
||||
return ld_scripts[0]
|
||||
# A per-board override preserves a layout the board shipped with
|
||||
# (see d1_wroom_02 in boards.py)
|
||||
return board_ld_script(board_data)
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.PLATFORM)
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
cg.add(esp8266_ns.setup_preferences())
|
||||
@@ -397,17 +435,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
)
|
||||
|
||||
if config[CONF_BOARD] in BOARDS:
|
||||
flash_size = BOARDS[config[CONF_BOARD]][KEY_FLASH_SIZE]
|
||||
ld_scripts = ESP8266_LD_SCRIPTS[flash_size]
|
||||
|
||||
if ver <= cv.Version(2, 3, 0):
|
||||
# No ld script support
|
||||
ld_script = None
|
||||
elif ver <= cv.Version(2, 4, 2):
|
||||
# Old ld script path
|
||||
ld_script = ld_scripts[0]
|
||||
else:
|
||||
ld_script = ld_scripts[1]
|
||||
ld_script = _choose_ld_script(config[CONF_BOARD], ver)
|
||||
|
||||
if ld_script is not None:
|
||||
cg.add_platformio_option("board_build.ldscript", ld_script)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from .const import KEY_FLASH_SIZE, KEY_LDSCRIPT
|
||||
|
||||
FLASH_SIZE_1_MB = 2**20
|
||||
FLASH_SIZE_512_KB = FLASH_SIZE_1_MB // 2
|
||||
FLASH_SIZE_2_MB = 2 * FLASH_SIZE_1_MB
|
||||
@@ -164,7 +166,8 @@ ESP8266_BOARD_PINS = {
|
||||
}
|
||||
|
||||
"""
|
||||
BOARDS generate with:
|
||||
BOARDS generate with (preserve per-board KEY_LDSCRIPT overrides such as
|
||||
d1_wroom_02; the recipe emits only name/flash_size):
|
||||
|
||||
git clone https://github.com/platformio/platform-espressif8266
|
||||
for x in platform-espressif8266/boards/*.json; do
|
||||
@@ -182,6 +185,19 @@ for x in platform-espressif8266/boards/*.json; do
|
||||
done | sort
|
||||
"""
|
||||
|
||||
|
||||
def board_ld_script(board_data: dict) -> str:
|
||||
"""The modern (core > 2.4.2) flash linker script for a board: its
|
||||
shipped-layout override, else the size default (the no-FS layout).
|
||||
|
||||
Single source of truth for the PlatformIO pinning in __init__ and the
|
||||
native generator's fallback, so the per-board rule cannot drift.
|
||||
"""
|
||||
return board_data.get(
|
||||
KEY_LDSCRIPT, ESP8266_LD_SCRIPTS[board_data[KEY_FLASH_SIZE]][1]
|
||||
)
|
||||
|
||||
|
||||
BOARDS = {
|
||||
"agruminolemon": {
|
||||
"name": "Lifely Agrumino Lemon v4",
|
||||
@@ -199,6 +215,15 @@ BOARDS = {
|
||||
"name": "WeMos D1 mini Pro",
|
||||
"flash_size": FLASH_SIZE_16_MB,
|
||||
},
|
||||
"d1_wroom_02": {
|
||||
"name": "WeMos D1 ESP-WROOM-02",
|
||||
"flash_size": FLASH_SIZE_2_MB,
|
||||
# This board joined BOARDS after shipping with the manifest default
|
||||
# (64 KB filesystem region); the flash-size default (2m.ld) would
|
||||
# move _FS_end and with it the preferences sector, wiping existing
|
||||
# devices' flash-backed state on update.
|
||||
KEY_LDSCRIPT: "eagle.flash.2m64.ld",
|
||||
},
|
||||
"d1": {
|
||||
"name": "WEMOS D1 R1",
|
||||
"flash_size": FLASH_SIZE_4_MB,
|
||||
@@ -360,3 +385,112 @@ BOARDS = {
|
||||
"flash_size": FLASH_SIZE_4_MB,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Per-board variant dir + identity defines from platform-espressif8266 4.x
|
||||
# build.extra_flags; the shared -DESP8266/-DARDUINO_ARCH_ESP8266 are added
|
||||
# by the generator.
|
||||
#
|
||||
# Regenerate ESP8266_BOARD_BUILD with (v4.2.1 is the platform version the
|
||||
# native toolchain mirrors; regenerate against the tag when bumping it):
|
||||
#
|
||||
# git clone -b v4.2.1 https://github.com/platformio/platform-espressif8266
|
||||
# python3 - <<'EOF'
|
||||
# import json, glob, os
|
||||
# for f in sorted(glob.glob("platform-espressif8266/boards/*.json")):
|
||||
# b = json.load(open(f))["build"]
|
||||
# extra = b["extra_flags"]
|
||||
# extra = extra.split() if isinstance(extra, str) else extra
|
||||
# defines = [
|
||||
# e[2:] for e in extra if e not in ("-DESP8266", "-DARDUINO_ARCH_ESP8266")
|
||||
# ]
|
||||
# entries = ", ".join(f'"{d}"' for d in defines) + ("," if len(defines) == 1 else "")
|
||||
# board = os.path.splitext(os.path.basename(f))[0]
|
||||
# print(f' "{board}": {{"variant": "{b["variant"]}", "defines": ({entries})}},')
|
||||
# EOF
|
||||
ESP8266_BOARD_BUILD = {
|
||||
"agruminolemon": {
|
||||
"variant": "agruminolemonv4",
|
||||
"defines": ("ARDUINO_ESP8266_AGRUMINO_LEMON_V4",),
|
||||
},
|
||||
"d1": {"variant": "d1", "defines": ("ARDUINO_ESP8266_WEMOS_D1R1",)},
|
||||
"d1_mini": {"variant": "d1_mini", "defines": ("ARDUINO_ESP8266_WEMOS_D1MINI",)},
|
||||
"d1_mini_lite": {
|
||||
"variant": "d1_mini",
|
||||
"defines": ("ARDUINO_ESP8266_WEMOS_D1MINILITE",),
|
||||
},
|
||||
"d1_mini_pro": {
|
||||
"variant": "d1_mini",
|
||||
"defines": ("ARDUINO_ESP8266_WEMOS_D1MINIPRO",),
|
||||
},
|
||||
"d1_wroom_02": {
|
||||
"variant": "d1_mini",
|
||||
"defines": ("ARDUINO_ESP8266_WEMOS_D1WROOM02",),
|
||||
},
|
||||
"eduinowifi": {
|
||||
"variant": "eduinowifi",
|
||||
"defines": ("ARDUINO_ESP8266_SCHIRMILABS_EDUINO_WIFI",),
|
||||
},
|
||||
"esp01": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP01",)},
|
||||
"esp01_1m": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP01",)},
|
||||
"esp07": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP07",)},
|
||||
"esp07s": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP07",)},
|
||||
"esp12e": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP12",)},
|
||||
"esp210": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP210",)},
|
||||
"esp8285": {"variant": "esp8285", "defines": ("ARDUINO_ESP8266_ESP01",)},
|
||||
"esp_wroom_02": {
|
||||
"variant": "nodemcu",
|
||||
"defines": ("ARDUINO_ESP8266_ESP_WROOM_02",),
|
||||
},
|
||||
"espduino": {"variant": "ESPDuino", "defines": ("ARDUINO_ESP8266_ESP13",)},
|
||||
"espectro": {"variant": "espectro", "defines": ("ARDUINO_ESP8266_ESPECTRO_CORE",)},
|
||||
"espino": {"variant": "espino", "defines": ("ARDUINO_ESP8266_ESP12",)},
|
||||
"espinotee": {"variant": "espinotee", "defines": ("ARDUINO_ESP8266_ESP13",)},
|
||||
"espmxdevkit": {
|
||||
"variant": "esp8285",
|
||||
"defines": ("ARDUINO_ESP8266_ESP01", "LED_BUILTIN=16"),
|
||||
},
|
||||
"espresso_lite_v1": {
|
||||
"variant": "espresso_lite_v1",
|
||||
"defines": ("ARDUINO_ESP8266_ESPRESSO_LITE_V1",),
|
||||
},
|
||||
"espresso_lite_v2": {
|
||||
"variant": "espresso_lite_v2",
|
||||
"defines": ("ARDUINO_ESP8266_ESPRESSO_LITE_V2",),
|
||||
},
|
||||
"gen4iod": {"variant": "generic", "defines": ("ARDUINO_GEN4_IOD",)},
|
||||
"heltec_wifi_kit_8": {
|
||||
"variant": "wifi_kit_8",
|
||||
"defines": ("ARDUINO_wifi_kit_8",),
|
||||
},
|
||||
"huzzah": {"variant": "adafruit", "defines": ("ARDUINO_ESP8266_ADAFRUIT_HUZZAH",)},
|
||||
"inventone": {"variant": "inventone", "defines": ("ARDUINO_ESP8266_INVENT_ONE",)},
|
||||
"modwifi": {"variant": "generic", "defines": ("ARDUINO_MOD_WIFI_ESP8266",)},
|
||||
"nodemcu": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_NODEMCU",)},
|
||||
"nodemcuv2": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_NODEMCU_ESP12E",)},
|
||||
"oak": {"variant": "oak", "defines": ("ARDUINO_ESP8266_OAK",)},
|
||||
"phoenix_v1": {
|
||||
"variant": "phoenix_v1",
|
||||
"defines": ("ARDUINO_ESP8266_PHOENIX_V1",),
|
||||
},
|
||||
"phoenix_v2": {
|
||||
"variant": "phoenix_v2",
|
||||
"defines": ("ARDUINO_ESP8266_PHOENIX_V2",),
|
||||
},
|
||||
"sonoff_basic": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_BASIC",)},
|
||||
"sonoff_s20": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_S20",)},
|
||||
"sonoff_sv": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_SV",)},
|
||||
"sonoff_th": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_TH",)},
|
||||
"sparkfunBlynk": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING",)},
|
||||
"thing": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING",)},
|
||||
"thingdev": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING_DEV",)},
|
||||
"wifi_slot": {"variant": "wifi_slot", "defines": ("ARDUINO_AMPERKA_WIFI_SLOT",)},
|
||||
"wifiduino": {"variant": "wifiduino", "defines": ("ARDUINO_WIFIDUINO_ESP8266",)},
|
||||
"wifinfo": {"variant": "wifinfo", "defines": ("ARDUINO_WIFINFO",)},
|
||||
"wio_link": {"variant": "wiolink", "defines": ("ARDUINO_ESP8266_WIO_LINK",)},
|
||||
"wio_node": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP_WROOM_02",)},
|
||||
"xinabox_cw01": {
|
||||
"variant": "xinabox",
|
||||
"defines": ("ARDUINO_ESP8266_XINABOX_CW01",),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Linker-script surgery shared with the native (PlatformIO-free) toolchain.
|
||||
|
||||
These mirror the PlatformIO extra scripts in this directory
|
||||
(``relocate_ratetable.py.script`` and ``testing_mode.py.script``), which run
|
||||
inside SCons and must stay self-contained. The native build generator applies
|
||||
the same patches to the linker scripts it generates, so the logic lives here
|
||||
as plain functions. Keep both in sync when changing either.
|
||||
``segment_length`` is native-toolchain-only and has no script twin.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Collection
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
# Move the NONOS SDK wifi rate tables from flash to DRAM; see
|
||||
# relocate_ratetable.py.script for the full background (NONOS SDK issue 320).
|
||||
RATETABLE_RULE = "*libnet80211.a:ieee80211_phy.o(.irom.text .irom.text.*)"
|
||||
_RATETABLE_COMMENT = (
|
||||
"/* ESPHome: wifi rate tables must live in DRAM, see NONOS SDK issue 320 */"
|
||||
)
|
||||
# Match the whole line: "_data_start" is also a substring of the
|
||||
# "_dport0_data_start" line in the earlier .dport0.data section
|
||||
_RATETABLE_ANCHOR = re.compile(r"^\s*_data_start = ABSOLUTE\(\.\);", re.MULTILINE)
|
||||
|
||||
# Memory sizes for testing mode (allow larger builds for CI component grouping)
|
||||
TESTING_IRAM_SIZE = "0x200000" # 2MB
|
||||
TESTING_DRAM_SIZE = "0x200000" # 2MB
|
||||
TESTING_FLASH_SIZE = "0x2000000" # 32MB
|
||||
|
||||
|
||||
def relocate_ratetable(content: str) -> str:
|
||||
"""Insert the rate-table DRAM rule into a generated common linker script."""
|
||||
if RATETABLE_RULE in content:
|
||||
return content
|
||||
match = _RATETABLE_ANCHOR.search(content)
|
||||
if match is None:
|
||||
raise RuntimeError(
|
||||
"'_data_start' anchor not found in the generated linker script; "
|
||||
"cannot apply wifi rate table DRAM relocation "
|
||||
"(has the Arduino core linker script changed?)"
|
||||
)
|
||||
insert_pos = match.end()
|
||||
return (
|
||||
content[:insert_pos]
|
||||
+ f"\n {_RATETABLE_COMMENT}"
|
||||
+ f"\n {RATETABLE_RULE}"
|
||||
+ content[insert_pos:]
|
||||
)
|
||||
|
||||
|
||||
_TESTING_SEGMENT_SIZES = {
|
||||
"iram1_0_seg": TESTING_IRAM_SIZE,
|
||||
"dram0_0_seg": TESTING_DRAM_SIZE,
|
||||
"irom0_0_seg": TESTING_FLASH_SIZE,
|
||||
}
|
||||
|
||||
|
||||
def _segment_line_re(segment_name: str) -> re.Pattern[str]:
|
||||
"""The MEMORY line for one segment: ``<seg> : org = 0x..., len = 0x...``.
|
||||
|
||||
Anchored to the start of the line so a name never matches inside a
|
||||
longer one (``ram0_0_seg`` must not read ``dram0_0_seg``). The size
|
||||
group stops at the hex digits, leaving any ``ul`` suffix (from the
|
||||
preprocessed ``MMU_IRAM_SIZE``) in place.
|
||||
"""
|
||||
return re.compile(
|
||||
rf"(^[ \t]*{re.escape(segment_name)}"
|
||||
r"\s*:\s*org\s*=\s*0x[0-9a-fA-F]+\s*,\s*len\s*=\s*)"
|
||||
r"(0x[0-9a-fA-F]+)",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def apply_testing_memory_patches(content: str, segments: Collection[str]) -> str:
|
||||
"""Enlarge the named memory segments so grouped CI test builds can link.
|
||||
|
||||
Each caller passes the segments its linker script defines: the
|
||||
generated common ld carries ``iram1_0_seg``; the flash ld carries
|
||||
``dram0_0_seg`` and ``irom0_0_seg``. A segment that fails to match
|
||||
raises, since a silently kept real memory limit would fail grouped
|
||||
builds far from the cause.
|
||||
"""
|
||||
for segment in _TESTING_SEGMENT_SIZES:
|
||||
if segment not in segments and _segment_line_re(segment).search(content):
|
||||
raise RuntimeError(
|
||||
f"Testing-mode segment {segment} is present in the linker "
|
||||
"script but was not selected for patching"
|
||||
)
|
||||
for segment in segments:
|
||||
if segment not in _TESTING_SEGMENT_SIZES:
|
||||
raise RuntimeError(f"Unknown testing-mode segment {segment!r}")
|
||||
content, count = _segment_line_re(segment).subn(
|
||||
rf"\g<1>{_TESTING_SEGMENT_SIZES[segment]}", content
|
||||
)
|
||||
if count == 0:
|
||||
raise RuntimeError(
|
||||
f"Testing-mode memory patch failed: segment {segment} "
|
||||
"not found (has the Arduino core linker script changed?)"
|
||||
)
|
||||
return content
|
||||
|
||||
|
||||
def segment_length(content: str, segment_name: str) -> int | None:
|
||||
"""Read a memory segment's length from linker script content.
|
||||
|
||||
Returns None for an absent segment OR an unparsable line; callers must
|
||||
treat None as "no usable budget" and warn (as the Flash summary does),
|
||||
never as "no limit".
|
||||
"""
|
||||
match = _segment_line_re(segment_name).search(content)
|
||||
return int(match.group(2), 16) if match else None
|
||||
|
||||
|
||||
def surgery_fingerprint() -> str:
|
||||
"""Hash of this module's source; linker-script caches include it so an
|
||||
edit here invalidates them."""
|
||||
import inspect
|
||||
import sys
|
||||
|
||||
source = inspect.getsource(sys.modules[__name__])
|
||||
return hashlib.sha256(source.encode()).hexdigest()
|
||||
@@ -15,6 +15,11 @@ CONF_ENABLE_SERIAL1 = "enable_serial1"
|
||||
KEY_WAVEFORM_REQUIRED = "waveform_required"
|
||||
KEY_SERIAL_REQUIRED = "serial_required"
|
||||
KEY_SERIAL1_REQUIRED = "serial1_required"
|
||||
# Set for the native (non-PlatformIO) toolchain's build generator
|
||||
KEY_FLASH_MODE = "flash_mode"
|
||||
KEY_SCANF_FLOAT = "scanf_float"
|
||||
# Per-board flash-layout override consumed by board_ld_script()
|
||||
KEY_LDSCRIPT = "ldscript"
|
||||
|
||||
# esp8266 namespace is already defined by arduino, manually prefix esphome
|
||||
esp8266_ns = cg.global_ns.namespace("esphome").namespace("esp8266")
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import button
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import ICON_AIR_FILTER
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns
|
||||
|
||||
DEPENDENCIES = ["hoermann_hcp"]
|
||||
|
||||
CONF_HALF_OPEN = "half_open"
|
||||
CONF_VENT = "vent"
|
||||
|
||||
ICON_GARAGE_OPEN_VARIANT = "mdi:garage-open-variant"
|
||||
|
||||
HoermannHcpVentButton = hoermann_hcp_ns.class_("HoermannHcpVentButton", button.Button)
|
||||
HoermannHcpHalfOpenButton = hoermann_hcp_ns.class_(
|
||||
"HoermannHcpHalfOpenButton", button.Button
|
||||
)
|
||||
|
||||
BUTTON_KEYS = (CONF_VENT, CONF_HALF_OPEN)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp),
|
||||
cv.Optional(CONF_VENT): button.button_schema(
|
||||
HoermannHcpVentButton, icon=ICON_AIR_FILTER
|
||||
),
|
||||
cv.Optional(CONF_HALF_OPEN): button.button_schema(
|
||||
HoermannHcpHalfOpenButton, icon=ICON_GARAGE_OPEN_VARIANT
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(*BUTTON_KEYS),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID])
|
||||
for key in BUTTON_KEYS:
|
||||
if (conf := config.get(key)) is not None:
|
||||
await button.new_button(conf, parent)
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/components/button/button.h"
|
||||
#include "../hoermann_hcp.h"
|
||||
|
||||
namespace esphome::hoermann_hcp {
|
||||
|
||||
// The door commands the cover has no equivalent for. A refused command is already reported by the hub and
|
||||
// leaves nothing to correct here, because a button carries no state of its own.
|
||||
class HoermannHcpButton : public button::Button {
|
||||
public:
|
||||
explicit HoermannHcpButton(HoermannHcp *parent) : parent_(parent) {}
|
||||
|
||||
protected:
|
||||
HoermannHcp *const parent_;
|
||||
};
|
||||
|
||||
class HoermannHcpVentButton final : public HoermannHcpButton {
|
||||
public:
|
||||
using HoermannHcpButton::HoermannHcpButton;
|
||||
|
||||
protected:
|
||||
void press_action() override { this->parent_->vent_door(); }
|
||||
};
|
||||
|
||||
class HoermannHcpHalfOpenButton final : public HoermannHcpButton {
|
||||
public:
|
||||
using HoermannHcpButton::HoermannHcpButton;
|
||||
|
||||
protected:
|
||||
void press_action() override { this->parent_->half_open_door(); }
|
||||
};
|
||||
|
||||
} // namespace esphome::hoermann_hcp
|
||||
@@ -22,6 +22,9 @@ static constexpr uint8_t MAX_LIGHT_TOGGLES_IN_FLIGHT = 4;
|
||||
static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110};
|
||||
static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120};
|
||||
static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140};
|
||||
// The intermediate positions are named in the second register, so the first only carries the phase.
|
||||
static constexpr HoermannHcpCommand COMMAND_VENT{"vent", 0x0200, 0x0100, 0x4000, 0x4000};
|
||||
static constexpr HoermannHcpCommand COMMAND_HALF_OPEN{"half open", 0x0200, 0x0100, 0x0400, 0x0400};
|
||||
// The lamp is named in the second register, but its phase bytes follow no scheme the door commands share.
|
||||
static constexpr HoermannHcpCommand COMMAND_TOGGLE_LAMP{"toggle light", 0x0100, 0x0800, 0x0200, 0x0200, false};
|
||||
|
||||
@@ -286,6 +289,8 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) {
|
||||
bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); }
|
||||
bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); }
|
||||
bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); }
|
||||
bool HoermannHcp::vent_door() { return this->queue_command_(COMMAND_VENT); }
|
||||
bool HoermannHcp::half_open_door() { return this->queue_command_(COMMAND_HALF_OPEN); }
|
||||
bool HoermannHcp::toggle_light() {
|
||||
if (this->light_toggles_in_flight_ >= MAX_LIGHT_TOGGLES_IN_FLIGHT) {
|
||||
ESP_LOGW(TAG, "Too many lamp toggles are still waiting to be confirmed, dropping this one");
|
||||
|
||||
@@ -22,7 +22,8 @@ enum class DoorState : uint8_t {
|
||||
};
|
||||
|
||||
// A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a
|
||||
// short delay the released value. Each half also carries a second register, which only the lamp command uses.
|
||||
// short delay the released value. Each half also carries a second register, which names the buttons that do
|
||||
// not fit into the first.
|
||||
struct HoermannHcpCommand {
|
||||
const char *name;
|
||||
uint16_t pressed_value;
|
||||
@@ -54,6 +55,9 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
|
||||
bool open_door();
|
||||
bool close_door();
|
||||
bool impulse_door();
|
||||
// The door drives to these intermediate positions on its own, so neither takes a target to be stopped at.
|
||||
bool vent_door();
|
||||
bool half_open_door();
|
||||
bool stop_door();
|
||||
bool set_position(float position);
|
||||
bool toggle_light();
|
||||
|
||||
@@ -37,6 +37,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_MAC_ADDRESS, default="98:35:69:ab:f6:79"): cv.mac_address,
|
||||
}
|
||||
),
|
||||
cv.require_platformio_toolchain("host"),
|
||||
set_core_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -17,12 +17,14 @@ from esphome.const import (
|
||||
CONF_TIMEOUT,
|
||||
CONF_URL,
|
||||
CONF_WATCHDOG_TIMEOUT,
|
||||
PLATFORM_ESP32,
|
||||
PLATFORM_HOST,
|
||||
PlatformFramework,
|
||||
__version__,
|
||||
)
|
||||
from esphome.core import CORE, ID, Lambda
|
||||
from esphome.core import CORE, ID, Lambda, TimePeriodMilliseconds
|
||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
import esphome.final_validate as fv
|
||||
from esphome.helpers import IS_MACOS
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -94,6 +96,34 @@ def validate_ssl_verification(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
# esp_http_client_open() runs DNS, TCP connect and the TLS handshake with no
|
||||
# watchdog feed in between; each can take up to `timeout` on ESP-IDF.
|
||||
WATCHDOG_TIMEOUT_MULTIPLIER = 3
|
||||
# Headroom over the exact worst case so a fully stalled open does not land on
|
||||
# the watchdog deadline.
|
||||
WATCHDOG_TIMEOUT_MARGIN_MS = 1000
|
||||
|
||||
|
||||
def default_watchdog_timeout(config: ConfigType) -> None:
|
||||
"""Arm the request watchdog on ESP32 when the user did not set it.
|
||||
|
||||
The default never goes below the platform task watchdog, so a user who
|
||||
widened `esp32.watchdog_timeout` keeps that window during requests.
|
||||
"""
|
||||
if not CORE.is_esp32 or CONF_WATCHDOG_TIMEOUT in config:
|
||||
return
|
||||
derived_ms = (
|
||||
config[CONF_TIMEOUT].total_milliseconds * WATCHDOG_TIMEOUT_MULTIPLIER
|
||||
+ WATCHDOG_TIMEOUT_MARGIN_MS
|
||||
)
|
||||
platform_ms = fv.full_config.get()[PLATFORM_ESP32][
|
||||
CONF_WATCHDOG_TIMEOUT
|
||||
].total_milliseconds
|
||||
config[CONF_WATCHDOG_TIMEOUT] = TimePeriodMilliseconds(
|
||||
milliseconds=max(derived_ms, platform_ms)
|
||||
)
|
||||
|
||||
|
||||
def _declare_request_class(value: Any) -> ID:
|
||||
if CORE.is_host:
|
||||
return cv.declare_id(HttpRequestHost)(value)
|
||||
@@ -153,6 +183,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
validate_ssl_verification,
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = default_watchdog_timeout
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
|
||||
@@ -142,12 +142,13 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const std::string &url, c
|
||||
const char *buf = body.c_str();
|
||||
while (write_left > 0) {
|
||||
int written = esp_http_client_write(client, buf + write_index, write_left);
|
||||
if (written < 0) {
|
||||
if (written <= 0) {
|
||||
err = ESP_FAIL;
|
||||
break;
|
||||
}
|
||||
write_left -= written;
|
||||
write_index += written;
|
||||
container->feed_wdt();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -300,7 +300,7 @@ FRAMEWORK_SCHEMA = cv.All(
|
||||
_check_debug_order,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(_notify_old_style)
|
||||
CONFIG_SCHEMA = cv.All(_notify_old_style, cv.require_platformio_toolchain("LibreTiny"))
|
||||
|
||||
BASE_SCHEMA = cv.Schema(
|
||||
{
|
||||
@@ -314,6 +314,7 @@ BASE_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
BASE_SCHEMA.add_extra(_detect_variant)
|
||||
BASE_SCHEMA.add_extra(cv.require_platformio_toolchain("LibreTiny"))
|
||||
BASE_SCHEMA.add_extra(_update_core_data)
|
||||
|
||||
|
||||
|
||||
@@ -525,6 +525,52 @@ void IndicatorLine::update_length_() {
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_LVGL_TABLE
|
||||
uint32_t lv_table_get_selected_row(lv_obj_t *obj) {
|
||||
uint32_t row;
|
||||
uint32_t column;
|
||||
lv_table_get_selected_cell(obj, &row, &column);
|
||||
return row;
|
||||
}
|
||||
|
||||
uint32_t lv_table_get_selected_column(lv_obj_t *obj) {
|
||||
uint32_t row;
|
||||
uint32_t column;
|
||||
lv_table_get_selected_cell(obj, &row, &column);
|
||||
return column;
|
||||
}
|
||||
|
||||
void LvTableType::set_obj(lv_obj_t *lv_obj) {
|
||||
LvCompound::set_obj(lv_obj);
|
||||
lv_obj_add_event_cb(
|
||||
lv_obj,
|
||||
[](lv_event_t *e) {
|
||||
auto *table = static_cast<LvTableType *>(lv_event_get_user_data(e));
|
||||
table->update_column_widths_();
|
||||
},
|
||||
LV_EVENT_SIZE_CHANGED, this);
|
||||
}
|
||||
|
||||
void LvTableType::add_column_width_pct(uint32_t col, uint8_t pct) {
|
||||
for (auto &i : this->column_pct_) {
|
||||
if (i.col == col) {
|
||||
i.pct = pct;
|
||||
this->update_column_widths_();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this->column_pct_.push_back({col, pct});
|
||||
this->update_column_widths_();
|
||||
}
|
||||
|
||||
void LvTableType::update_column_widths_() {
|
||||
auto content_width = lv_obj_get_content_width(this->obj);
|
||||
for (const auto &col : this->column_pct_) {
|
||||
lv_table_set_column_width(this->obj, col.col, content_width * col.pct / 100);
|
||||
}
|
||||
}
|
||||
#endif // USE_LVGL_TABLE
|
||||
|
||||
#ifdef USE_LVGL_KEY_LISTENER
|
||||
LVEncoderListener::LVEncoderListener(lv_indev_type_t type, uint16_t long_press_time, uint16_t long_press_repeat_time) {
|
||||
this->drv_ = lv_indev_create();
|
||||
@@ -551,21 +597,21 @@ std::string LvSelectable::get_selected_text() {
|
||||
return this->options_[selected];
|
||||
}
|
||||
|
||||
static std::string join_string(std::vector<std::string> options) {
|
||||
static std::string join_string(const FixedVector<const char *> &options) {
|
||||
return std::accumulate(
|
||||
options.begin(), options.end(), std::string(),
|
||||
[](const std::string &a, const std::string &b) -> std::string { return a + (!a.empty() ? "\n" : "") + b; });
|
||||
[](const std::string &a, const char *b) -> std::string { return a + (!a.empty() ? "\n" : "") + b; });
|
||||
}
|
||||
|
||||
void LvSelectable::set_selected_text(const std::string &text, lv_anim_enable_t anim) {
|
||||
auto index = std::find(this->options_.begin(), this->options_.end(), text);
|
||||
auto *index = std::find(this->options_.begin(), this->options_.end(), text);
|
||||
if (index != this->options_.end()) {
|
||||
this->set_selected_index(index - this->options_.begin(), anim);
|
||||
lv_obj_send_event(this->obj, lv_update_event, nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
void LvSelectable::set_options(std::vector<std::string> options) {
|
||||
void LvSelectable::set_options(FixedVector<const char *> options) {
|
||||
auto index = this->get_selected_index();
|
||||
if (index >= options.size())
|
||||
index = options.size() - 1;
|
||||
|
||||
@@ -58,6 +58,10 @@ lv_obj_t *lv_container_create(lv_obj_t *parent);
|
||||
void lv_scale_draw_event_cb(lv_event_t *e, int16_t range_start, int16_t range_end, lv_color_t color_start,
|
||||
lv_color_t color_end, int width, bool local);
|
||||
#endif
|
||||
#ifdef USE_LVGL_TABLE
|
||||
uint32_t lv_table_get_selected_row(lv_obj_t *obj);
|
||||
uint32_t lv_table_get_selected_column(lv_obj_t *obj);
|
||||
#endif
|
||||
#if LV_COLOR_DEPTH == 16
|
||||
static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BITNESS_565;
|
||||
#elif LV_COLOR_DEPTH == 32
|
||||
@@ -511,6 +515,27 @@ class LvLineType : public LvCompound {
|
||||
FixedVector<lv_point_precise_t> points_{};
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_LVGL_TABLE
|
||||
// Unlike most size properties, lv_table_set_column_width() only accepts a literal pixel
|
||||
// count, so percentage column widths must be recomputed by hand whenever the table's own
|
||||
// content width changes.
|
||||
class LvTableType : public LvCompound {
|
||||
public:
|
||||
void set_obj(lv_obj_t *lv_obj) override;
|
||||
// count is the number of percentage-width columns, known at code-generation time.
|
||||
void init_column_pct(size_t count) { this->column_pct_.init(count); }
|
||||
void add_column_width_pct(uint32_t col, uint8_t pct);
|
||||
|
||||
protected:
|
||||
void update_column_widths_();
|
||||
|
||||
struct ColumnPct {
|
||||
uint32_t col;
|
||||
uint8_t pct;
|
||||
};
|
||||
FixedVector<ColumnPct> column_pct_{};
|
||||
};
|
||||
#endif // USE_LVGL_TABLE
|
||||
#if defined(USE_LVGL_DROPDOWN) || defined(LV_USE_ROLLER)
|
||||
class LvSelectable : public LvCompound {
|
||||
public:
|
||||
@@ -518,12 +543,12 @@ class LvSelectable : public LvCompound {
|
||||
virtual void set_selected_index(size_t index, lv_anim_enable_t anim) = 0;
|
||||
void set_selected_text(const std::string &text, lv_anim_enable_t anim);
|
||||
std::string get_selected_text();
|
||||
const std::vector<std::string> &get_options() { return this->options_; }
|
||||
void set_options(std::vector<std::string> options);
|
||||
const FixedVector<const char *> &get_options() { return this->options_; }
|
||||
void set_options(FixedVector<const char *> options);
|
||||
|
||||
protected:
|
||||
virtual void set_option_string(const char *options) = 0;
|
||||
std::vector<std::string> options_{};
|
||||
FixedVector<const char *> options_{};
|
||||
};
|
||||
|
||||
#ifdef USE_LVGL_DROPDOWN
|
||||
|
||||
@@ -50,19 +50,10 @@ class LVGLSelect final : public select::Select, public Component {
|
||||
protected:
|
||||
void control(size_t index) override {
|
||||
this->widget_->set_selected_index(index, this->anim_);
|
||||
this->publish();
|
||||
}
|
||||
void set_options_() {
|
||||
// Widget uses std::vector<std::string>, SelectTraits uses FixedVector<const char*>
|
||||
// Convert by extracting c_str() pointers
|
||||
const auto &opts = this->widget_->get_options();
|
||||
FixedVector<const char *> opt_ptrs;
|
||||
opt_ptrs.init(opts.size());
|
||||
for (const auto &opt : opts) {
|
||||
opt_ptrs.push_back(opt.c_str());
|
||||
}
|
||||
this->traits.set_options(opt_ptrs);
|
||||
// The update event fires the widget's on_value/on_update triggers
|
||||
lv_obj_send_event(this->widget_->obj, lv_update_event, nullptr);
|
||||
}
|
||||
void set_options_() { this->traits.set_options(this->widget_->get_options()); }
|
||||
|
||||
LvSelectable *widget_;
|
||||
lv_anim_enable_t anim_;
|
||||
|
||||
@@ -3,6 +3,8 @@ from esphome.const import CONF_TEXT, CONF_VALUE
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.cpp_types import Component, esphome_ns
|
||||
|
||||
from .defines import CONF_SELECTED_INDEX
|
||||
|
||||
|
||||
class LvType(cg.MockObjClass):
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -112,3 +114,4 @@ class LvSelect(LvType):
|
||||
parents=parens,
|
||||
**kwargs,
|
||||
)
|
||||
self.value_property = CONF_SELECTED_INDEX
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
from contextlib import ExitStack
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_ROWS
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_ITEMS, CONF_ROW, CONF_TEXT, CONF_WIDTH
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT
|
||||
from esphome.types import ConfigFragmentType, ConfigType, SafeExpType
|
||||
|
||||
from ..automation import action_to_code
|
||||
from ..defines import CONF_COLUMN, CONF_MAIN, LValidator, literal
|
||||
from ..lv_validation import lv_int, lv_text, pixels_or_percent, pixels_validator
|
||||
from ..lvcode import LocalVariable, lv, lv_add, lv_expr
|
||||
from ..types import LvCompound, LvType, ObjUpdateAction, lv_coord_t
|
||||
from . import Widget, WidgetType, get_widgets
|
||||
from .label import CONF_LABEL
|
||||
|
||||
CONF_TABLE = "table"
|
||||
CONF_CELLS = "cells"
|
||||
CONF_COLUMNS = "columns"
|
||||
CONF_ROW_COUNT = "row_count"
|
||||
CONF_COLUMN_COUNT = "column_count"
|
||||
CONF_MERGE_RIGHT = "merge_right"
|
||||
CONF_TEXT_CROP = "text_crop"
|
||||
CONF_SELECTED_ROW = "selected_row"
|
||||
CONF_SELECTED_COLUMN = "selected_column"
|
||||
|
||||
CELL_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_TEXT, default=""): lv_text,
|
||||
# Not templatable: the value selects between two different LVGL calls
|
||||
# (set/clear cell ctrl), so a runtime lambda can't be mapped to a single call.
|
||||
cv.Optional(CONF_MERGE_RIGHT): cv.boolean,
|
||||
cv.Optional(CONF_TEXT_CROP): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
# A cell can be given as a bare piece of text, or a dict for more control
|
||||
TABLE_CELL_SCHEMA = cv.maybe_simple_value(CELL_SCHEMA, key=CONF_TEXT)
|
||||
|
||||
# A row can be given as a bare list of cells, or a dict for future extension
|
||||
ROW_SCHEMA = cv.maybe_simple_value(
|
||||
cv.Schema({cv.Required(CONF_CELLS): cv.ensure_list(TABLE_CELL_SCHEMA)}),
|
||||
key=CONF_CELLS,
|
||||
)
|
||||
|
||||
|
||||
def _column_width_validator(value: ConfigFragmentType) -> int | float | list[str]:
|
||||
"""Like pixels_or_percent, but rejects negative widths, which would
|
||||
defeat the 100%-total check and wrap around in the generated uint8_t pct."""
|
||||
if value == SCHEMA_EXTRACT:
|
||||
return ["pixels", "..%"]
|
||||
return cv.Any(pixels_validator, cv.percentage)(value)
|
||||
|
||||
|
||||
column_width = LValidator(
|
||||
_column_width_validator,
|
||||
lv_coord_t,
|
||||
retmapper=pixels_or_percent.retmapper,
|
||||
animatable=True,
|
||||
)
|
||||
|
||||
COLUMN_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_WIDTH): column_width,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_table(config: ConfigType) -> ConfigType:
|
||||
rows = config.get(CONF_ROWS)
|
||||
min_row_count = len(rows) if rows else 0
|
||||
min_column_count = max(len(row[CONF_CELLS]) for row in rows) if rows else 0
|
||||
row_count = config.get(CONF_ROW_COUNT)
|
||||
if row_count is not None and row_count < min_row_count:
|
||||
raise cv.Invalid(
|
||||
f"{CONF_ROW_COUNT} must be at least {min_row_count} to hold all the given rows",
|
||||
path=[CONF_ROW_COUNT],
|
||||
)
|
||||
column_count = config.get(CONF_COLUMN_COUNT)
|
||||
if column_count is not None and column_count < min_column_count:
|
||||
raise cv.Invalid(
|
||||
f"{CONF_COLUMN_COUNT} must be at least {min_column_count} to hold all the cells in a row",
|
||||
path=[CONF_COLUMN_COUNT],
|
||||
)
|
||||
column_count = column_count if column_count is not None else min_column_count
|
||||
columns = config.get(CONF_COLUMNS)
|
||||
if columns and column_count and len(columns) > column_count:
|
||||
raise cv.Invalid(
|
||||
f"{CONF_COLUMNS} defines {len(columns)} columns, but the table has only {column_count}",
|
||||
path=[CONF_COLUMNS],
|
||||
)
|
||||
total_pct = sum(
|
||||
width
|
||||
for column in columns or ()
|
||||
if isinstance((width := column.get(CONF_WIDTH)), float)
|
||||
)
|
||||
if total_pct > 1.0:
|
||||
raise cv.Invalid(
|
||||
f"{CONF_COLUMNS} percentage widths add up to {total_pct * 100:.0f}%, which exceeds 100%",
|
||||
path=[CONF_COLUMNS],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
TABLE_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_ROWS): cv.ensure_list(ROW_SCHEMA),
|
||||
cv.Optional(CONF_ROW_COUNT): cv.positive_int,
|
||||
cv.Optional(CONF_COLUMN_COUNT): cv.positive_int,
|
||||
cv.Optional(CONF_COLUMNS): cv.ensure_list(COLUMN_SCHEMA),
|
||||
cv.Optional(CONF_SELECTED_ROW): lv_int,
|
||||
cv.Optional(CONF_SELECTED_COLUMN): lv_int,
|
||||
}
|
||||
).add_extra(_validate_table)
|
||||
|
||||
lv_table_t = LvType(
|
||||
"LvTableType",
|
||||
parents=(LvCompound,),
|
||||
largs=[(cg.uint32, "row"), (cg.uint32, "column")],
|
||||
lvalue=lambda w: [
|
||||
lv_expr.table_get_selected_row(w.obj),
|
||||
lv_expr.table_get_selected_column(w.obj),
|
||||
],
|
||||
has_on_value=True,
|
||||
)
|
||||
|
||||
|
||||
async def set_cell_ctrl(
|
||||
w: Widget, row: SafeExpType, column: SafeExpType, cell: ConfigType
|
||||
) -> None:
|
||||
for key, ctrl in (
|
||||
(CONF_MERGE_RIGHT, "LV_TABLE_CELL_CTRL_MERGE_RIGHT"),
|
||||
(CONF_TEXT_CROP, "LV_TABLE_CELL_CTRL_TEXT_CROP"),
|
||||
):
|
||||
if key not in cell:
|
||||
continue
|
||||
if cell[key]:
|
||||
lv.table_set_cell_ctrl(w.obj, row, column, literal(ctrl))
|
||||
else:
|
||||
lv.table_clear_cell_ctrl(w.obj, row, column, literal(ctrl))
|
||||
|
||||
|
||||
async def set_selected_cell(w: Widget, config: ConfigType) -> None:
|
||||
selected_row = config.get(CONF_SELECTED_ROW)
|
||||
selected_column = config.get(CONF_SELECTED_COLUMN)
|
||||
if selected_row is None and selected_column is None:
|
||||
return
|
||||
# LV_TABLE_CELL_NONE selects the whole column/row when only one index is given
|
||||
row_value = (
|
||||
await lv_int.process(selected_row)
|
||||
if selected_row is not None
|
||||
else literal("LV_TABLE_CELL_NONE")
|
||||
)
|
||||
column_value = (
|
||||
await lv_int.process(selected_column)
|
||||
if selected_column is not None
|
||||
else literal("LV_TABLE_CELL_NONE")
|
||||
)
|
||||
lv.table_set_selected_cell(w.obj, row_value, column_value)
|
||||
|
||||
|
||||
TABLE_MODIFY_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_SELECTED_ROW): lv_int,
|
||||
cv.Optional(CONF_SELECTED_COLUMN): lv_int,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TableType(WidgetType):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
CONF_TABLE,
|
||||
lv_table_t,
|
||||
(CONF_MAIN, CONF_ITEMS),
|
||||
TABLE_SCHEMA,
|
||||
modify_schema=TABLE_MODIFY_SCHEMA,
|
||||
)
|
||||
|
||||
def get_uses(self) -> tuple[str]:
|
||||
return (CONF_LABEL,)
|
||||
|
||||
async def to_code(self, w: Widget, config: dict) -> None:
|
||||
rows = config.get(CONF_ROWS)
|
||||
row_count = config.get(CONF_ROW_COUNT)
|
||||
column_count = config.get(CONF_COLUMN_COUNT)
|
||||
if rows is not None:
|
||||
if row_count is None:
|
||||
row_count = len(rows)
|
||||
if column_count is None:
|
||||
column_count = max((len(row[CONF_CELLS]) for row in rows), default=0)
|
||||
if row_count is not None:
|
||||
lv.table_set_row_count(w.obj, row_count)
|
||||
if column_count is not None:
|
||||
lv.table_set_column_count(w.obj, column_count)
|
||||
columns = config.get(CONF_COLUMNS, ())
|
||||
pct_column_count = sum(
|
||||
1 for column in columns if isinstance(column.get(CONF_WIDTH), float)
|
||||
)
|
||||
if pct_column_count:
|
||||
lv_add(w.var.init_column_pct(pct_column_count))
|
||||
for index, column in enumerate(columns):
|
||||
if (width := column.get(CONF_WIDTH)) is None:
|
||||
continue
|
||||
if isinstance(width, float):
|
||||
# A percentage: column_width validation leaves it as a 0.0-1.0
|
||||
# fraction. LVGL's table widget only accepts a literal pixel width, so
|
||||
# the actual width is recomputed at runtime from the table's own size.
|
||||
lv_add(w.var.add_column_width_pct(index, round(width * 100)))
|
||||
else:
|
||||
lv.table_set_column_width(
|
||||
w.obj, index, await column_width.process(width)
|
||||
)
|
||||
for row_index, row in enumerate(rows or ()):
|
||||
for column_index, cell in enumerate(row[CONF_CELLS]):
|
||||
lv.table_set_cell_value(
|
||||
w.obj,
|
||||
row_index,
|
||||
column_index,
|
||||
await lv_text.process(cell[CONF_TEXT]),
|
||||
)
|
||||
await set_cell_ctrl(w, row_index, column_index, cell)
|
||||
await set_selected_cell(w, config)
|
||||
|
||||
|
||||
table_spec = TableType()
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"lvgl.table.cell.update",
|
||||
ObjUpdateAction,
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(lv_table_t),
|
||||
cv.Required(CONF_ROW): lv_int,
|
||||
cv.Required(CONF_COLUMN): lv_int,
|
||||
cv.Optional(CONF_TEXT): lv_text,
|
||||
cv.Optional(CONF_MERGE_RIGHT): cv.boolean,
|
||||
cv.Optional(CONF_TEXT_CROP): cv.boolean,
|
||||
}
|
||||
).add_extra(cv.has_at_least_one_key(CONF_TEXT, CONF_MERGE_RIGHT, CONF_TEXT_CROP)),
|
||||
synchronous=True,
|
||||
)
|
||||
async def table_cell_update_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
widgets = await get_widgets(config)
|
||||
|
||||
async def do_update(w: Widget):
|
||||
row = await lv_int.process(config[CONF_ROW])
|
||||
column = await lv_int.process(config[CONF_COLUMN])
|
||||
fields_set = sum(
|
||||
key in config for key in (CONF_TEXT, CONF_MERGE_RIGHT, CONF_TEXT_CROP)
|
||||
)
|
||||
with ExitStack() as stack:
|
||||
if fields_set > 1:
|
||||
# row/column feed more than one generated call below: cache them in
|
||||
# local variables so a !lambda value is only evaluated once.
|
||||
row = stack.enter_context(
|
||||
LocalVariable("row", cg.int_, row, modifier="")
|
||||
)
|
||||
column = stack.enter_context(
|
||||
LocalVariable("column", cg.int_, column, modifier="")
|
||||
)
|
||||
if CONF_TEXT in config:
|
||||
lv.table_set_cell_value(
|
||||
w.obj, row, column, await lv_text.process(config[CONF_TEXT])
|
||||
)
|
||||
await set_cell_ctrl(w, row, column, config)
|
||||
|
||||
return await action_to_code(
|
||||
widgets, do_update, action_id, template_arg, args, config
|
||||
)
|
||||
@@ -41,7 +41,19 @@ static void register_esp8266(MDNSComponent *, StaticVector<MDNSService, MDNS_SER
|
||||
#ifdef USE_MDNS_EVENT_DRIVEN_POLLING
|
||||
void MDNSComponent::start_polling_window_() {
|
||||
// uint32_t-ID set_interval/set_timeout already does atomic cancel-and-add.
|
||||
this->set_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); });
|
||||
this->set_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() {
|
||||
#ifdef USE_MDNS_WIFI_LISTENER
|
||||
// MDNS.update() can suspend the loop in UdpContext::sendTimeout() while a send is
|
||||
// failing (radio off-channel during a roam scan, or mid reconnect); an incoming
|
||||
// packet then re-enters LEAmDNS from lwIP and corrupts shared UdpContext state.
|
||||
// Skip the tick while the radio cannot transmit (#18760), but keep polling while
|
||||
// the AP is serving clients (AP-only or fallback AP with the STA down).
|
||||
auto *wifi = wifi::global_wifi_component;
|
||||
if (wifi->is_roaming() || (!wifi->is_connected() && !wifi->is_ap_active()))
|
||||
return;
|
||||
#endif
|
||||
MDNS.update();
|
||||
});
|
||||
this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); });
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -266,8 +266,6 @@ DriverChip(
|
||||
"JC3636W518V2",
|
||||
height=360,
|
||||
width=360,
|
||||
offset_height=1,
|
||||
draw_rounding=1,
|
||||
cs_pin=10,
|
||||
reset_pin=47,
|
||||
invert_colors=True,
|
||||
|
||||
@@ -3,6 +3,9 @@ import esphome.codegen as cg
|
||||
modbus_ns = cg.esphome_ns.namespace("modbus")
|
||||
modbus_helpers_ns = modbus_ns.namespace("helpers")
|
||||
|
||||
RegisterValues = modbus_ns.class_("RegisterValues")
|
||||
PduBuffer = modbus_helpers_ns.class_("PduBuffer")
|
||||
|
||||
FunctionCode_ns = modbus_ns.namespace("FunctionCode")
|
||||
FunctionCode = FunctionCode_ns.enum("FunctionCode")
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ ModbusItemBaseSchema = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
def validate_modbus_register(config):
|
||||
def validate_modbus_register(config: ConfigType) -> ConfigType:
|
||||
# custom_command is the deprecated alias for custom_pdu (migrated later in final validate); treat
|
||||
# either as "a custom frame is configured" so the address/register_type rules match.
|
||||
has_custom = CONF_CUSTOM_PDU in config or CONF_CUSTOM_COMMAND in config
|
||||
@@ -278,7 +278,7 @@ def _final_validate(config: ConfigType) -> None:
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
def modbus_calc_properties(config):
|
||||
def modbus_calc_properties(config: ConfigType) -> tuple[int, int]:
|
||||
byte_offset = 0
|
||||
reg_count = 0
|
||||
if CONF_OFFSET in config:
|
||||
@@ -307,8 +307,12 @@ def modbus_calc_properties(config):
|
||||
|
||||
|
||||
async def add_modbus_base_properties(
|
||||
var, config, sensor_type, lambda_param_type=cg.float_, lambda_return_type=float
|
||||
):
|
||||
var: cg.MockObj,
|
||||
config: ConfigType,
|
||||
sensor_type: cg.MockObjClass,
|
||||
lambda_param_type: cg.MockObj = cg.float_,
|
||||
lambda_return_type: Any = float,
|
||||
) -> None:
|
||||
if CONF_CUSTOM_PDU in config:
|
||||
cg.add(var.set_custom_pdu(config[CONF_CUSTOM_PDU]))
|
||||
|
||||
@@ -347,8 +351,11 @@ _CALLBACK_AUTOMATIONS = (
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
# Await the hub first, so no entity can bind to a controller that doesn't have one yet.
|
||||
hub = await cg.get_variable(config[modbus.CONF_MODBUS_ID])
|
||||
var = cg.new_Pvariable(config[CONF_ID], hub, config[CONF_ADDRESS])
|
||||
await cg.register_component(var, config)
|
||||
cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES]))
|
||||
cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES]))
|
||||
cg.add(
|
||||
@@ -356,17 +363,22 @@ async def to_code(config):
|
||||
modbus.command_options_expression(config, direction="read")
|
||||
)
|
||||
)
|
||||
await register_modbus_device(var, config)
|
||||
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
|
||||
|
||||
|
||||
async def register_modbus_device(var, config):
|
||||
async def register_modbus_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj:
|
||||
# Remove before 2027.3.0
|
||||
_LOGGER.warning(
|
||||
"'modbus_controller.register_modbus_device' is deprecated, use "
|
||||
"'modbus.register_modbus_client_device' and set the address on your own "
|
||||
"class instead. Will be removed in 2027.3.0"
|
||||
)
|
||||
cg.add(var.set_address(config[CONF_ADDRESS]))
|
||||
await cg.register_component(var, config)
|
||||
return await modbus.register_modbus_client_device(var, config)
|
||||
|
||||
|
||||
def function_code_to_register(function_code):
|
||||
def function_code_to_register(function_code: str) -> cg.MockObj:
|
||||
FUNCTION_CODE_TYPE_MAP = {
|
||||
"read_coils": EntityType.COIL,
|
||||
"read_discrete_inputs": EntityType.DISCRETE_INPUT,
|
||||
|
||||
@@ -10,6 +10,73 @@ static const char *const TAG = "modbus_controller";
|
||||
|
||||
void ModbusController::setup() { this->create_polling_commands_(); }
|
||||
|
||||
void WriterDevice::warn_write_buffer_deprecated(const LogString *platform, uint16_t address) {
|
||||
if (this->write_buffer_deprecated_warned_)
|
||||
return;
|
||||
this->write_buffer_deprecated_warned_ = true;
|
||||
ESP_LOGW(TAG,
|
||||
"Modbus %s (address 0x%X): filling the write_lambda buffer parameter is deprecated; call a write helper / "
|
||||
"queue_pdu() on the entity (item) instead. The buffer parameter is removed in 2027.3.0",
|
||||
LOG_STR_ARG(platform), address);
|
||||
}
|
||||
|
||||
bool WriterDevice::send_raw_frame_deprecated(std::span<const uint8_t> frame) {
|
||||
if (frame.empty())
|
||||
return false;
|
||||
this->dispatched_ = true;
|
||||
return this->parent_->queue_pdu(frame[0], frame.subspan(1), this);
|
||||
}
|
||||
|
||||
void WriterDevice::set_controller(ModbusController *controller) {
|
||||
this->controller_ = controller;
|
||||
this->set_parent(controller->hub());
|
||||
this->set_address(controller->device_address());
|
||||
}
|
||||
|
||||
void WriterDevice::notify_online_(std::span<const uint8_t> request_pdu) {
|
||||
if (this->controller_ != nullptr)
|
||||
this->controller_->set_online(true, fc_of(request_pdu), addr_of(request_pdu));
|
||||
}
|
||||
|
||||
void WriterDevice::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
|
||||
this->notify_online_(request_pdu);
|
||||
this->dispatch_response_(request_pdu, response_pdu, std::nullopt);
|
||||
}
|
||||
|
||||
void WriterDevice::on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) {
|
||||
ESP_LOGW(TAG, "Modbus error function code: 0x%X register 0x%X exception: %d", fc_of(request_pdu),
|
||||
addr_of(request_pdu), static_cast<uint8_t>(exception_code));
|
||||
this->notify_online_(request_pdu); // an exception is still a legitimate reply -> device is online
|
||||
this->dispatch_response_(request_pdu, {}, exception_code);
|
||||
}
|
||||
|
||||
// Fired once per wire transmission (including hub re-queues from a retry), so the on_command_sent trigger
|
||||
// reflects when the frame actually went out, not when it was queued.
|
||||
void WriterDevice::on_sent(std::span<const uint8_t> request_pdu) {
|
||||
if (this->controller_ != nullptr)
|
||||
this->controller_->command_sent(fc_of(request_pdu), addr_of(request_pdu));
|
||||
}
|
||||
|
||||
void WriterDevice::on_not_sent(std::span<const uint8_t> request_pdu) {
|
||||
// Only the offline teardown reaches this (a supersede retires silently), so the frame is genuinely
|
||||
// lost; a dropped write was already published optimistically, so surface it.
|
||||
if (modbus::helpers::is_function_code_write(fc_of(request_pdu))) {
|
||||
ESP_LOGW(TAG, "Write not sent: function 0x%X register 0x%X", fc_of(request_pdu), addr_of(request_pdu));
|
||||
} else {
|
||||
ESP_LOGD(TAG, "Request not sent: function 0x%X register 0x%X", fc_of(request_pdu), addr_of(request_pdu));
|
||||
}
|
||||
}
|
||||
|
||||
bool WriterDevice::on_no_response(std::span<const uint8_t> request_pdu) {
|
||||
if (this->controller_ == nullptr)
|
||||
return false;
|
||||
this->controller_->increment_non_response_count();
|
||||
if (this->controller_->can_send())
|
||||
return true; // the hub re-queues the frame it is holding; on_sent fires again on the retry
|
||||
this->controller_->set_online(false, fc_of(request_pdu), addr_of(request_pdu));
|
||||
return false;
|
||||
}
|
||||
|
||||
ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address,
|
||||
RegisterRange &&range)
|
||||
: modbus::ModbusClientDevice(parent, address),
|
||||
|
||||
@@ -232,6 +232,115 @@ struct RegisterRange {
|
||||
SensorSet sensors; // all sensors of this range
|
||||
};
|
||||
|
||||
/// A hub device owned by a writer entity (switch/number/select/output) through WriterEntity.
|
||||
/// Centralises the feedback to the controller - online/offline tracking, retry counting and the
|
||||
/// on_command_sent trigger - and records every dispatch, so a write lambda can tell "I sent it myself"
|
||||
/// from "use the default write". The hub base is inherited protected, so the public members below are
|
||||
/// the entity's whole request API and nothing can bypass the recording or re-target the device.
|
||||
class WriterDevice final : protected modbus::ModbusClientDevice {
|
||||
protected:
|
||||
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override;
|
||||
void on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) override;
|
||||
void on_sent(std::span<const uint8_t> request_pdu) override;
|
||||
void on_not_sent(std::span<const uint8_t> request_pdu) override;
|
||||
bool on_no_response(std::span<const uint8_t> request_pdu) override;
|
||||
|
||||
void notify_online_(std::span<const uint8_t> request_pdu);
|
||||
/// Function code / register address decoded from a request PDU ([fc, addr_hi, addr_lo, ...]).
|
||||
static int fc_of(std::span<const uint8_t> pdu) { return pdu.empty() ? 0 : (pdu[0] & modbus::FUNCTION_CODE_MASK); }
|
||||
static int addr_of(std::span<const uint8_t> pdu) {
|
||||
return pdu.size() >= 3 ? modbus::helpers::get_data<uint16_t>(pdu.data(), 1) : 0;
|
||||
}
|
||||
|
||||
/// Declared before controller_ so they land in the padding after ModbusClientDevice::custom_response_warned_
|
||||
/// instead of adding a word to every entity that owns a device.
|
||||
/// dispatched_: a frame was queued since the last clear_dispatched_().
|
||||
/// write_buffer_deprecated_warned_: warn-once for the legacy write_lambda buffer parameter.
|
||||
bool dispatched_{false};
|
||||
bool write_buffer_deprecated_warned_{false};
|
||||
ModbusController *controller_{nullptr};
|
||||
|
||||
public:
|
||||
/// Whether a frame was queued to the hub since the last clear_dispatched_().
|
||||
bool dispatched() const { return this->dispatched_; }
|
||||
|
||||
bool write_single_register(uint16_t address, uint16_t value) {
|
||||
this->dispatched_ = true;
|
||||
return modbus::ModbusClientDevice::write_single_register(address, value);
|
||||
}
|
||||
bool write_single_coil(uint16_t address, bool value) {
|
||||
this->dispatched_ = true;
|
||||
return modbus::ModbusClientDevice::write_single_coil(address, value);
|
||||
}
|
||||
bool write_multiple_registers(uint16_t address, std::span<const uint16_t> values) {
|
||||
this->dispatched_ = true;
|
||||
return modbus::ModbusClientDevice::write_multiple_registers(address, values);
|
||||
}
|
||||
bool write_multiple_coils(uint16_t address, std::span<const bool> values) {
|
||||
this->dispatched_ = true;
|
||||
return modbus::ModbusClientDevice::write_multiple_coils(address, values);
|
||||
}
|
||||
bool write_multiple_coils(uint16_t address, modbus::PackedBits bits) {
|
||||
this->dispatched_ = true;
|
||||
return modbus::ModbusClientDevice::write_multiple_coils(address, bits);
|
||||
}
|
||||
bool queue_pdu(std::span<const uint8_t> pdu, modbus::CommandOptions options = {}) {
|
||||
this->dispatched_ = true;
|
||||
return modbus::ModbusClientDevice::queue_pdu(pdu, options);
|
||||
}
|
||||
/// Send a legacy raw frame (address + function code + data) to the frame's own address.
|
||||
/// Serves only the deprecated write_lambda buffer path. Remove before 2027.3.0.
|
||||
bool send_raw_frame_deprecated(std::span<const uint8_t> frame);
|
||||
|
||||
void clear_tx_queue_for_device() { modbus::ModbusClientDevice::clear_tx_queue_for_device(); }
|
||||
|
||||
// Entity plumbing, public because the owning WriterEntity holds the only reachable instance (device_ is
|
||||
// protected there and the hub sees just the masked base) - reachability is the access gate, not a friend.
|
||||
void set_controller(ModbusController *controller);
|
||||
void clear_dispatched() { this->dispatched_ = false; }
|
||||
/// Warn once per entity that filling the write_lambda buffer parameter is deprecated (the entity is now the
|
||||
/// command - call a write helper / queue_pdu() on `item` instead). The buffer parameter is removed in 2027.3.0.
|
||||
void warn_write_buffer_deprecated(const LogString *platform, uint16_t address);
|
||||
};
|
||||
|
||||
/// Gives a writer entity the write API of the WriterDevice it owns. The device is a member, not a base:
|
||||
/// the mixin declares no virtual function, so an entity mixing it in gains no second vtable and all the
|
||||
/// writer platforms share the single WriterDevice vtable instead of each emitting its own copy.
|
||||
/// The forwarders keep `item->write_*()` working unchanged inside a write_lambda.
|
||||
class WriterEntity {
|
||||
public:
|
||||
bool dispatched() const { return this->device_.dispatched(); }
|
||||
bool write_single_register(uint16_t address, uint16_t value) {
|
||||
return this->device_.write_single_register(address, value);
|
||||
}
|
||||
bool write_single_coil(uint16_t address, bool value) { return this->device_.write_single_coil(address, value); }
|
||||
bool write_multiple_registers(uint16_t address, std::span<const uint16_t> values) {
|
||||
return this->device_.write_multiple_registers(address, values);
|
||||
}
|
||||
bool write_multiple_coils(uint16_t address, std::span<const bool> values) {
|
||||
return this->device_.write_multiple_coils(address, values);
|
||||
}
|
||||
bool write_multiple_coils(uint16_t address, modbus::PackedBits bits) {
|
||||
return this->device_.write_multiple_coils(address, bits);
|
||||
}
|
||||
bool queue_pdu(std::span<const uint8_t> pdu, modbus::CommandOptions options = {}) {
|
||||
return this->device_.queue_pdu(pdu, options);
|
||||
}
|
||||
void clear_tx_queue_for_device() { this->device_.clear_tx_queue_for_device(); }
|
||||
|
||||
protected:
|
||||
bool send_raw_frame_deprecated_(std::span<const uint8_t> frame) {
|
||||
return this->device_.send_raw_frame_deprecated(frame);
|
||||
}
|
||||
void set_controller_(ModbusController *controller) { this->device_.set_controller(controller); }
|
||||
void clear_dispatched_() { this->device_.clear_dispatched(); }
|
||||
void warn_write_buffer_deprecated_(const LogString *platform, uint16_t address) {
|
||||
this->device_.warn_write_buffer_deprecated(platform, address);
|
||||
}
|
||||
|
||||
WriterDevice device_;
|
||||
};
|
||||
|
||||
/// A single modbus command. Each command is its own ModbusClientDevice: it sends its frame to the hub
|
||||
/// and the hub routes the response back to this object's on_modbus_* callbacks, so the controller no
|
||||
/// longer has to match responses to a FIFO queue.
|
||||
@@ -398,17 +507,16 @@ inline bool offline_retry_due(uint16_t update_counter, uint16_t module_offline_a
|
||||
|
||||
class ModbusController final : public PollingComponent {
|
||||
public:
|
||||
// The controller is not itself a modbus device - its commands and writer entities send as their own
|
||||
// devices, built against this hub + address.
|
||||
ModbusController(modbus::ModbusClientHub *hub, uint8_t address) : hub_(hub), address_(address) {}
|
||||
|
||||
void dump_config() override;
|
||||
// No loop() override: the hub owns transmit/receive timing and each command routes its own
|
||||
// response, so the controller never joins the looping components at all.
|
||||
void setup() override;
|
||||
void update() override;
|
||||
|
||||
// The controller is not itself a modbus device - its commands and writer entities send as their own
|
||||
// devices. It only owns the hub + address so those senders can be built against them.
|
||||
void set_parent(modbus::ModbusClientHub *hub) { this->hub_ = hub; }
|
||||
void set_address(uint8_t address) { this->address_ = address; }
|
||||
|
||||
/// The hub and modbus address this controller talks to. Used to build commands/entities that send as
|
||||
/// their own device.
|
||||
modbus::ModbusClientHub *hub() const { return this->hub_; }
|
||||
|
||||
@@ -3,6 +3,7 @@ from esphome.components import number
|
||||
from esphome.components.modbus.helpers import (
|
||||
MODBUS_WRITE_REGISTER_TYPE,
|
||||
SENSOR_VALUE_TYPE,
|
||||
RegisterValues,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -13,6 +14,7 @@ from esphome.const import (
|
||||
CONF_MULTIPLY,
|
||||
CONF_STEP,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
ModbusItemBaseSchema,
|
||||
@@ -43,7 +45,7 @@ ModbusNumber = modbus_controller_ns.class_(
|
||||
)
|
||||
|
||||
|
||||
def validate_min_max(config):
|
||||
def validate_min_max(config: ConfigType) -> ConfigType:
|
||||
if config[CONF_MAX_VALUE] <= config[CONF_MIN_VALUE]:
|
||||
raise cv.Invalid("max_value must be greater than min_value")
|
||||
if config[CONF_MIN_VALUE] < -16777215:
|
||||
@@ -53,7 +55,7 @@ def validate_min_max(config):
|
||||
return config
|
||||
|
||||
|
||||
def validate_modbus_number(config):
|
||||
def validate_modbus_number(config: ConfigType) -> ConfigType:
|
||||
# custom_command is the deprecated alias for custom_pdu (migrated later in final validate).
|
||||
has_custom = CONF_CUSTOM_PDU in config or CONF_CUSTOM_COMMAND in config
|
||||
if not has_custom and CONF_ADDRESS not in config:
|
||||
@@ -89,7 +91,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
byte_offset, reg_count = modbus_calc_properties(config)
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
@@ -124,7 +126,7 @@ async def to_code(config):
|
||||
[
|
||||
(ModbusNumber.operator("ptr"), "item"),
|
||||
(cg.float_, "x"),
|
||||
(cg.std_vector.template(cg.uint16).operator("ref"), "payload"),
|
||||
(RegisterValues.operator("ref"), "payload"),
|
||||
],
|
||||
return_type=cg.optional.template(float),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#include <vector>
|
||||
#include "modbus_number.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
@@ -29,62 +28,73 @@ void ModbusNumber::parse_and_publish(std::span<const uint8_t> data) {
|
||||
}
|
||||
|
||||
void ModbusNumber::control(float value) {
|
||||
optional<ModbusCommandItem> write_cmd;
|
||||
std::vector<uint16_t> data;
|
||||
this->clear_dispatched_();
|
||||
// A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one)
|
||||
// so a rapidly-changing value writes the latest, not every intermediate.
|
||||
this->clear_tx_queue_for_device();
|
||||
modbus::RegisterValues data;
|
||||
float write_value = value;
|
||||
// Is there are lambda configured?
|
||||
if (this->write_transform_func_.has_value()) {
|
||||
// data is passed by reference
|
||||
// the lambda can fill the empty vector directly
|
||||
// in that case the return value is ignored
|
||||
// The lambda may drive the write itself via item->write_*(), override the value (return a value), or
|
||||
// (deprecated) fill `data` with the register words to write.
|
||||
auto val = (*this->write_transform_func_)(this, value, data);
|
||||
if (val.has_value()) {
|
||||
ESP_LOGV(TAG, "Value overwritten by lambda");
|
||||
write_value = val.value();
|
||||
} else {
|
||||
if (this->dispatched()) {
|
||||
this->publish_state(value);
|
||||
return;
|
||||
}
|
||||
if (!data.empty()) {
|
||||
// Deprecated buffer path (frozen): the lambda filled a legacy raw frame as words; pack it big-endian.
|
||||
this->warn_write_buffer_deprecated_(LOG_STR("number"), this->start_address);
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
char hex_buf[format_hex_pretty_uint16_size(MODBUS_NUMBER_MAX_LOG_REGISTERS)];
|
||||
#endif
|
||||
ESP_LOGV(TAG, "Modbus Number write raw: %s",
|
||||
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
|
||||
// Sized to hold RegisterValues at capacity, so a full buffer can never truncate into a valid frame.
|
||||
StaticVector<uint8_t, modbus::MAX_NUM_OF_REGISTERS_TO_READ * 2> bytes;
|
||||
for (uint16_t word : data) {
|
||||
const auto word_bytes = decode_value(word);
|
||||
bytes.push_back(word_bytes[0]);
|
||||
bytes.push_back(word_bytes[1]);
|
||||
}
|
||||
if (!this->send_raw_frame_deprecated_(std::span<const uint8_t>(bytes.data(), bytes.size()))) {
|
||||
ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str());
|
||||
return;
|
||||
}
|
||||
this->publish_state(value);
|
||||
return;
|
||||
}
|
||||
if (!val.has_value()) {
|
||||
ESP_LOGV(TAG, "Communication handled by lambda - exiting control");
|
||||
return;
|
||||
}
|
||||
ESP_LOGV(TAG, "Value overwritten by lambda");
|
||||
write_value = val.value();
|
||||
} else {
|
||||
write_value = this->multiply_by_ * write_value;
|
||||
}
|
||||
|
||||
if (!data.empty()) {
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
char hex_buf[format_hex_pretty_uint16_size(MODBUS_NUMBER_MAX_LOG_REGISTERS)];
|
||||
#endif
|
||||
ESP_LOGV(TAG, "Modbus Number write raw: %s",
|
||||
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
|
||||
write_cmd.emplace(ModbusCommandItem::create_custom_command(
|
||||
this->parent_, data,
|
||||
[this](modbus::EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
|
||||
this->parent_->on_write_register_response(register_type, this->start_address, data);
|
||||
}));
|
||||
} else {
|
||||
std::vector<uint16_t> payload;
|
||||
modbus::helpers::float_to_payload(payload, write_value, this->sensor_value_type);
|
||||
modbus::helpers::float_to_payload(data, write_value, this->sensor_value_type);
|
||||
// float_to_payload() appends nothing for RAW, so an empty payload must be caught before data[0] below.
|
||||
if (data.empty()) {
|
||||
ESP_LOGW(TAG, "No payload was created for updating number");
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGD(TAG,
|
||||
"Updating register: connected Sensor=%s start address=0x%X register count=%d new value=%.02f (val=%.02f)",
|
||||
this->get_name().c_str(), this->start_address, this->register_count, value, write_value);
|
||||
ESP_LOGD(TAG,
|
||||
"Updating register: connected Sensor=%s start address=0x%X register count=%d new value=%.02f (val=%.02f)",
|
||||
this->get_name().c_str(), this->start_address, this->register_count, value, write_value);
|
||||
|
||||
// Create and send the write command
|
||||
if (this->register_count == 1 && !this->use_write_multiple_) {
|
||||
write_cmd.emplace(
|
||||
ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(), payload[0]));
|
||||
} else {
|
||||
write_cmd.emplace(ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(),
|
||||
this->register_count, payload));
|
||||
}
|
||||
// publish new value
|
||||
write_cmd->on_data_func = [this, value](modbus::EntityType register_type, uint16_t start_address,
|
||||
std::span<const uint8_t> data) {
|
||||
// gets called when the write command is ack'd from the device
|
||||
this->parent_->on_write_register_response(register_type, start_address, data);
|
||||
this->publish_state(value);
|
||||
};
|
||||
bool queued;
|
||||
if (this->register_count == 1 && !this->use_write_multiple_) {
|
||||
queued = this->write_single_register(this->write_address(), data[0]);
|
||||
} else {
|
||||
queued = this->write_multiple_registers(this->write_address(), data);
|
||||
}
|
||||
if (!queued) {
|
||||
ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str());
|
||||
return;
|
||||
}
|
||||
this->parent_->queue_command(std::move(*write_cmd));
|
||||
this->publish_state(value);
|
||||
}
|
||||
void ModbusNumber::dump_config() { LOG_NUMBER(TAG, "Modbus Number", this); }
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace esphome::modbus_controller {
|
||||
|
||||
using value_to_data_t = std::function<float>(float);
|
||||
|
||||
class ModbusNumber final : public number::Number, public Component, public SensorItem {
|
||||
class ModbusNumber final : public number::Number, public Component, public SensorItem, public WriterEntity {
|
||||
public:
|
||||
ModbusNumber(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
|
||||
SensorValueType value_type, int register_count, bool force_new_range) {
|
||||
@@ -26,11 +26,11 @@ class ModbusNumber final : public number::Number, public Component, public Senso
|
||||
void dump_config() override;
|
||||
void parse_and_publish(std::span<const uint8_t> data) override;
|
||||
float get_setup_priority() const override { return setup_priority::HARDWARE; }
|
||||
void set_parent(ModbusController *parent) { this->parent_ = parent; }
|
||||
void set_parent(ModbusController *parent) { this->set_controller_(parent); }
|
||||
void set_write_multiply(float factor) { this->multiply_by_ = factor; }
|
||||
|
||||
using transform_func_t = optional<float> (*)(ModbusNumber *, float, std::span<const uint8_t>);
|
||||
using write_transform_func_t = optional<float> (*)(ModbusNumber *, float, std::vector<uint16_t> &);
|
||||
using write_transform_func_t = optional<float> (*)(ModbusNumber *, float, modbus::RegisterValues &);
|
||||
void set_template(transform_func_t f) { this->transform_func_ = f; }
|
||||
void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; }
|
||||
void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; }
|
||||
@@ -39,7 +39,6 @@ class ModbusNumber final : public number::Number, public Component, public Senso
|
||||
void control(float value) override;
|
||||
optional<transform_func_t> transform_func_{nullopt};
|
||||
optional<write_transform_func_t> write_transform_func_{nullopt};
|
||||
ModbusController *parent_{nullptr};
|
||||
float multiply_by_{1.0};
|
||||
bool use_write_multiple_{false};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import output
|
||||
from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE
|
||||
from esphome.components.modbus.helpers import (
|
||||
SENSOR_VALUE_TYPE,
|
||||
PduBuffer,
|
||||
RegisterValues,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_MULTIPLY
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
ModbusItemBaseSchema,
|
||||
@@ -73,7 +78,7 @@ CONFIG_SCHEMA = cv.typed_schema(
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
byte_offset, reg_count = modbus_calc_properties(config)
|
||||
# Binary Output
|
||||
write_template = None
|
||||
@@ -89,7 +94,7 @@ async def to_code(config):
|
||||
[
|
||||
(ModbusBinaryOutput.operator("ptr"), "item"),
|
||||
(cg.bool_, "x"),
|
||||
(cg.std_vector.template(cg.uint8).operator("ref"), "payload"),
|
||||
(PduBuffer.operator("ref"), "payload"),
|
||||
],
|
||||
return_type=cg.optional.template(bool),
|
||||
)
|
||||
@@ -109,7 +114,7 @@ async def to_code(config):
|
||||
[
|
||||
(ModbusFloatOutput.operator("ptr"), "item"),
|
||||
(cg.float_, "x"),
|
||||
(cg.std_vector.template(cg.uint16).operator("ref"), "payload"),
|
||||
(RegisterValues.operator("ref"), "payload"),
|
||||
],
|
||||
return_type=cg.optional.template(float),
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <array>
|
||||
|
||||
namespace esphome::modbus_controller {
|
||||
|
||||
static const char *const TAG = "modbus_controller.output";
|
||||
@@ -13,25 +15,33 @@ static constexpr size_t MODBUS_OUTPUT_MAX_LOG_BYTES = 64;
|
||||
*
|
||||
*/
|
||||
void ModbusFloatOutput::write_state(float value) {
|
||||
std::vector<uint16_t> data;
|
||||
this->clear_dispatched_();
|
||||
// A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one)
|
||||
// so a rapidly-changing value writes the latest, not every intermediate.
|
||||
this->clear_tx_queue_for_device();
|
||||
modbus::RegisterValues data;
|
||||
auto original_value = value;
|
||||
// Is there are lambda configured?
|
||||
if (this->write_transform_func_.has_value()) {
|
||||
// data is passed by reference
|
||||
// the lambda can fill the empty vector directly
|
||||
// in that case the return value is ignored
|
||||
// The lambda may drive the write itself via item->write_*(), override the value (return a value), or
|
||||
// (deprecated) fill `data` with the register words to write.
|
||||
auto val = (*this->write_transform_func_)(this, value, data);
|
||||
if (val.has_value()) {
|
||||
ESP_LOGV(TAG, "Value overwritten by lambda");
|
||||
value = val.value();
|
||||
} else {
|
||||
if (this->dispatched()) {
|
||||
return;
|
||||
}
|
||||
if (!data.empty()) {
|
||||
// Deprecated buffer path (frozen): the lambda supplied the register words for the shared write below.
|
||||
this->warn_write_buffer_deprecated_(LOG_STR("float output"), this->start_address);
|
||||
} else if (!val.has_value()) {
|
||||
ESP_LOGV(TAG, "Communication handled by lambda - exiting control");
|
||||
return;
|
||||
} else {
|
||||
ESP_LOGV(TAG, "Value overwritten by lambda");
|
||||
value = val.value();
|
||||
}
|
||||
} else {
|
||||
value = this->multiply_by_ * value;
|
||||
}
|
||||
// lambda didn't set payload
|
||||
|
||||
if (data.empty()) {
|
||||
modbus::helpers::float_to_payload(data, value, this->sensor_value_type);
|
||||
}
|
||||
@@ -57,16 +67,15 @@ void ModbusFloatOutput::write_state(float value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create and send the write command
|
||||
optional<ModbusCommandItem> write_cmd;
|
||||
bool queued;
|
||||
if (this->register_count == 1 && !this->use_write_multiple_) {
|
||||
write_cmd.emplace(
|
||||
ModbusCommandItem::create_write_single_command(this->parent_, this->start_address + this->offset, data[0]));
|
||||
queued = this->write_single_register(this->write_address(), data[0]);
|
||||
} else {
|
||||
write_cmd.emplace(ModbusCommandItem::create_write_multiple_command(
|
||||
this->parent_, this->start_address + this->offset, data.size(), data));
|
||||
queued = this->write_multiple_registers(this->write_address(), data);
|
||||
}
|
||||
if (!queued) {
|
||||
ESP_LOGW(TAG, "Modbus output write (address 0x%X) was refused by the hub", this->write_address());
|
||||
}
|
||||
this->parent_->queue_command(std::move(*write_cmd));
|
||||
}
|
||||
|
||||
void ModbusFloatOutput::dump_config() {
|
||||
@@ -81,50 +90,52 @@ void ModbusFloatOutput::dump_config() {
|
||||
|
||||
// ModbusBinaryOutput
|
||||
void ModbusBinaryOutput::write_state(bool state) {
|
||||
// This will be called every time the user requests a state change.
|
||||
optional<ModbusCommandItem> cmd;
|
||||
std::vector<uint8_t> data;
|
||||
this->clear_dispatched_();
|
||||
// A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one)
|
||||
// so a rapidly-changing value writes the latest, not every intermediate.
|
||||
this->clear_tx_queue_for_device();
|
||||
modbus::helpers::PduBuffer data;
|
||||
|
||||
// Is there are lambda configured?
|
||||
if (this->write_transform_func_.has_value()) {
|
||||
// data is passed by reference
|
||||
// the lambda can fill the empty vector directly
|
||||
// in that case the return value is ignored
|
||||
// The lambda may drive the write itself via item->write_*/queue_pdu(), override the value (return a value),
|
||||
// or (deprecated) fill `data` with a custom PDU.
|
||||
auto val = (*this->write_transform_func_)(this, state, data);
|
||||
if (val.has_value()) {
|
||||
ESP_LOGV(TAG, "Value overwritten by lambda");
|
||||
state = val.value();
|
||||
} else {
|
||||
if (this->dispatched()) {
|
||||
return;
|
||||
}
|
||||
if (!data.empty()) {
|
||||
this->warn_write_buffer_deprecated_(LOG_STR("binary output"), this->start_address);
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
char hex_buf[format_hex_pretty_size(MODBUS_OUTPUT_MAX_LOG_BYTES)];
|
||||
#endif
|
||||
ESP_LOGV(TAG, "Modbus binary output write raw: %s",
|
||||
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
|
||||
// The lambda filled a legacy raw frame (device address + function code + data).
|
||||
if (!this->send_raw_frame_deprecated_(data)) {
|
||||
ESP_LOGW(TAG, "Modbus output write (address 0x%X) was refused by the hub", this->write_address());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!val.has_value()) {
|
||||
ESP_LOGV(TAG, "Communication handled by lambda - exiting control");
|
||||
return;
|
||||
}
|
||||
ESP_LOGV(TAG, "Value overwritten by lambda");
|
||||
state = val.value();
|
||||
}
|
||||
if (!data.empty()) {
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
char hex_buf[format_hex_pretty_size(MODBUS_OUTPUT_MAX_LOG_BYTES)];
|
||||
#endif
|
||||
ESP_LOGV(TAG, "Modbus binary output write raw: %s",
|
||||
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
|
||||
cmd.emplace(ModbusCommandItem::create_custom_command(
|
||||
this->parent_, data,
|
||||
[this](modbus::EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
|
||||
this->parent_->on_write_register_response(register_type, this->start_address, data);
|
||||
}));
|
||||
ESP_LOGV(TAG, "Write new state: value is %s, type is %d address = %X, offset = %x", ONOFF(state),
|
||||
(int) this->register_type, this->start_address, this->offset);
|
||||
// offset for coil and discrete inputs is the coil/register number not bytes
|
||||
bool queued;
|
||||
if (this->use_write_multiple_) {
|
||||
std::array<bool, 1> states{state};
|
||||
queued = this->write_multiple_coils(this->write_address(), states);
|
||||
} else {
|
||||
ESP_LOGV(TAG, "Write new state: value is %s, type is %d address = %X, offset = %x", ONOFF(state),
|
||||
(int) this->register_type, this->start_address, this->offset);
|
||||
|
||||
// offset for coil and discrete inputs is the coil/register number not bytes
|
||||
if (this->use_write_multiple_) {
|
||||
std::vector<bool> states{state};
|
||||
cmd.emplace(
|
||||
ModbusCommandItem::create_write_multiple_coils(this->parent_, this->start_address + this->offset, states));
|
||||
} else {
|
||||
cmd.emplace(
|
||||
ModbusCommandItem::create_write_single_coil(this->parent_, this->start_address + this->offset, state));
|
||||
}
|
||||
queued = this->write_single_coil(this->write_address(), state);
|
||||
}
|
||||
if (!queued) {
|
||||
ESP_LOGW(TAG, "Modbus output write (address 0x%X) was refused by the hub", this->write_address());
|
||||
}
|
||||
this->parent_->queue_command(std::move(*cmd));
|
||||
}
|
||||
|
||||
void ModbusBinaryOutput::dump_config() {
|
||||
|
||||
@@ -8,26 +8,24 @@
|
||||
|
||||
namespace esphome::modbus_controller {
|
||||
|
||||
class ModbusFloatOutput final : public output::FloatOutput, public Component, public SensorItem {
|
||||
class ModbusFloatOutput final : public output::FloatOutput, public Component, public SensorItem, public WriterEntity {
|
||||
public:
|
||||
ModbusFloatOutput(uint16_t start_address, uint8_t offset, SensorValueType value_type, int register_count) {
|
||||
this->register_type = modbus::EntityType::HOLDING;
|
||||
this->set_address(start_address);
|
||||
this->set_offset_from_start_address(offset);
|
||||
this->set_address(start_address + offset);
|
||||
this->set_offset_from_start_address(0);
|
||||
this->bitmask = 0xFFFFFFFF;
|
||||
this->register_count = register_count;
|
||||
this->sensor_value_type = value_type;
|
||||
this->set_address(this->start_address + offset);
|
||||
this->set_offset_from_start_address(0);
|
||||
}
|
||||
void dump_config() override;
|
||||
|
||||
void set_parent(ModbusController *parent) { this->parent_ = parent; }
|
||||
void set_parent(ModbusController *parent) { this->set_controller_(parent); }
|
||||
void set_write_multiply(float factor) { this->multiply_by_ = factor; }
|
||||
// Do nothing
|
||||
void parse_and_publish(std::span<const uint8_t> data) override{};
|
||||
|
||||
using write_transform_func_t = optional<float> (*)(ModbusFloatOutput *, float, std::vector<uint16_t> &);
|
||||
using write_transform_func_t = optional<float> (*)(ModbusFloatOutput *, float, modbus::RegisterValues &);
|
||||
void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; }
|
||||
void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; }
|
||||
|
||||
@@ -35,29 +33,28 @@ class ModbusFloatOutput final : public output::FloatOutput, public Component, pu
|
||||
void write_state(float value) override;
|
||||
optional<write_transform_func_t> write_transform_func_{nullopt};
|
||||
|
||||
ModbusController *parent_{nullptr};
|
||||
float multiply_by_{1.0};
|
||||
bool use_write_multiple_{false};
|
||||
};
|
||||
|
||||
class ModbusBinaryOutput final : public output::BinaryOutput, public Component, public SensorItem {
|
||||
class ModbusBinaryOutput final : public output::BinaryOutput, public Component, public SensorItem, public WriterEntity {
|
||||
public:
|
||||
ModbusBinaryOutput(uint16_t start_address, uint8_t offset) {
|
||||
this->register_type = modbus::EntityType::COIL;
|
||||
this->set_address(start_address);
|
||||
// A coil offset is a coil count; fold it into the address.
|
||||
this->set_address(start_address + offset);
|
||||
this->bitmask = 0xFFFFFFFF;
|
||||
this->sensor_value_type = SensorValueType::BIT;
|
||||
this->register_count = 1;
|
||||
this->set_address(this->start_address + offset);
|
||||
this->set_offset_from_start_address(0);
|
||||
}
|
||||
void dump_config() override;
|
||||
|
||||
void set_parent(ModbusController *parent) { this->parent_ = parent; }
|
||||
void set_parent(ModbusController *parent) { this->set_controller_(parent); }
|
||||
// Do nothing
|
||||
void parse_and_publish(std::span<const uint8_t> data) override{};
|
||||
|
||||
using write_transform_func_t = optional<bool> (*)(ModbusBinaryOutput *, bool, std::vector<uint8_t> &);
|
||||
using write_transform_func_t = optional<bool> (*)(ModbusBinaryOutput *, bool, modbus::helpers::PduBuffer &);
|
||||
void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; }
|
||||
void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; }
|
||||
|
||||
@@ -65,7 +62,6 @@ class ModbusBinaryOutput final : public output::BinaryOutput, public Component,
|
||||
void write_state(bool state) override;
|
||||
optional<write_transform_func_t> write_transform_func_{nullopt};
|
||||
|
||||
ModbusController *parent_{nullptr};
|
||||
bool use_write_multiple_{false};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import select
|
||||
from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE, TYPE_REGISTER_MAP
|
||||
from esphome.components.modbus.helpers import (
|
||||
SENSOR_VALUE_TYPE,
|
||||
TYPE_REGISTER_MAP,
|
||||
RegisterValues,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
ModbusController,
|
||||
@@ -29,8 +37,8 @@ ModbusSelect = modbus_controller_ns.class_(
|
||||
)
|
||||
|
||||
|
||||
def ensure_option_map():
|
||||
def validator(value):
|
||||
def ensure_option_map() -> Callable[[Any], dict[str, int]]:
|
||||
def validator(value: Any) -> dict[str, int]:
|
||||
cv.check_not_templatable(value)
|
||||
option = cv.All(cv.string_strict)
|
||||
mapping = cv.All(cv.int_range(-(2**63), 2**63 - 1))
|
||||
@@ -47,7 +55,7 @@ def ensure_option_map():
|
||||
return validator
|
||||
|
||||
|
||||
def register_count_value_type_min(value):
|
||||
def register_count_value_type_min(value: ConfigType) -> ConfigType:
|
||||
reg_count = value.get(CONF_REGISTER_COUNT)
|
||||
if reg_count is not None:
|
||||
value_type = value[CONF_VALUE_TYPE]
|
||||
@@ -87,7 +95,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
value_type = config[CONF_VALUE_TYPE]
|
||||
reg_count = config.get(CONF_REGISTER_COUNT)
|
||||
if reg_count is None:
|
||||
@@ -132,7 +140,7 @@ async def to_code(config):
|
||||
(ModbusSelect.operator("const_ptr"), "item"),
|
||||
(cg.std_string.operator("const").operator("ref"), "x"),
|
||||
(cg.int64, "value"),
|
||||
(cg.std_vector.template(cg.uint16).operator("ref"), "payload"),
|
||||
(RegisterValues.operator("ref"), "payload"),
|
||||
],
|
||||
return_type=cg.optional.template(cg.int64),
|
||||
)
|
||||
|
||||
@@ -46,35 +46,43 @@ void ModbusSelect::control(size_t index) {
|
||||
const char *option = this->option_at(index);
|
||||
ESP_LOGD(TAG, "Found value %lld for option '%s'", *mapval, option);
|
||||
|
||||
std::vector<uint16_t> data;
|
||||
this->clear_dispatched_();
|
||||
// A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one)
|
||||
// so a rapidly-changing value writes the latest, not every intermediate.
|
||||
this->clear_tx_queue_for_device();
|
||||
modbus::RegisterValues data;
|
||||
|
||||
if (this->write_transform_func_.has_value()) {
|
||||
// Transform func requires string parameter for backward compatibility
|
||||
// The lambda may drive the write itself via item->write_*(), override the mapping value (return a value),
|
||||
// or (deprecated) fill `data` with the register words to write. Transform func requires string parameter
|
||||
// for backward compatibility.
|
||||
auto val = (*this->write_transform_func_)(this, std::string(option), *mapval, data);
|
||||
if (val.has_value()) {
|
||||
mapval = val;
|
||||
ESP_LOGV(TAG, "write_lambda returned mapping value %lld", *mapval);
|
||||
} else {
|
||||
if (this->dispatched()) {
|
||||
if (this->optimistic_)
|
||||
this->publish_state(index);
|
||||
return;
|
||||
}
|
||||
if (!data.empty()) {
|
||||
// Deprecated buffer path (frozen): the lambda supplied the register words for the shared write below.
|
||||
this->warn_write_buffer_deprecated_(LOG_STR("select"), this->start_address);
|
||||
} else if (!val.has_value()) {
|
||||
ESP_LOGD(TAG, "Communication handled by write_lambda - exiting control");
|
||||
return;
|
||||
} else {
|
||||
mapval = val;
|
||||
ESP_LOGV(TAG, "write_lambda returned mapping value %lld", *mapval);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.empty()) {
|
||||
modbus::helpers::number_to_payload(data, *mapval, this->sensor_value_type);
|
||||
} else {
|
||||
ESP_LOGV(TAG, "Using payload from write lambda");
|
||||
// number_to_payload() appends nothing for RAW.
|
||||
if (data.empty()) {
|
||||
ESP_LOGW(TAG, "No payload was created for updating select");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.empty()) {
|
||||
ESP_LOGW(TAG, "No payload was created for updating select");
|
||||
return;
|
||||
}
|
||||
|
||||
// The command declares register_count registers, so the payload must be exactly that many words:
|
||||
// a value type narrower than the declared width is zero-padded (the config deliberately allows
|
||||
// register_count larger than the value type). Anything else would put a byte count on the wire
|
||||
// that disagrees with the quantity field, which conformant devices reject.
|
||||
// register_count declares the READ range width - it may pull neighboring registers into one poll -
|
||||
// so a write covers exactly the registers the value occupies: the quantity comes from the payload,
|
||||
// never from register_count (padding to it would zero registers the user only declared for reading).
|
||||
@@ -86,16 +94,17 @@ void ModbusSelect::control(size_t index) {
|
||||
}
|
||||
|
||||
const uint16_t write_address = this->write_address();
|
||||
optional<ModbusCommandItem> write_cmd;
|
||||
bool queued;
|
||||
if ((this->register_count == 1) && (!this->use_write_multiple_)) {
|
||||
write_cmd.emplace(ModbusCommandItem::create_write_single_command(this->parent_, write_address, data[0]));
|
||||
queued = this->write_single_register(write_address, data[0]);
|
||||
} else {
|
||||
write_cmd.emplace(
|
||||
ModbusCommandItem::create_write_multiple_command(this->parent_, write_address, data.size(), data));
|
||||
queued = this->write_multiple_registers(write_address, data);
|
||||
}
|
||||
|
||||
this->parent_->queue_command(std::move(*write_cmd));
|
||||
|
||||
if (!queued) {
|
||||
ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str());
|
||||
return;
|
||||
}
|
||||
if (this->optimistic_)
|
||||
this->publish_state(index);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
namespace esphome::modbus_controller {
|
||||
|
||||
class ModbusSelect final : public Component, public select::Select, public SensorItem {
|
||||
class ModbusSelect final : public Component, public select::Select, public SensorItem, public WriterEntity {
|
||||
public:
|
||||
ModbusSelect(SensorValueType sensor_value_type, uint16_t start_address, uint8_t register_count, bool force_new_range,
|
||||
std::vector<int64_t> mapping) {
|
||||
@@ -26,9 +26,9 @@ class ModbusSelect final : public Component, public select::Select, public Senso
|
||||
|
||||
using transform_func_t = optional<std::string> (*)(ModbusSelect *const, int64_t, std::span<const uint8_t>);
|
||||
using write_transform_func_t = optional<int64_t> (*)(ModbusSelect *const, const std::string &, int64_t,
|
||||
std::vector<uint16_t> &);
|
||||
modbus::RegisterValues &);
|
||||
|
||||
void set_parent(ModbusController *const parent) { this->parent_ = parent; }
|
||||
void set_parent(ModbusController *const parent) { this->set_controller_(parent); }
|
||||
void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; }
|
||||
void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; }
|
||||
void set_template(transform_func_t f) { this->transform_func_ = f; }
|
||||
@@ -40,7 +40,6 @@ class ModbusSelect final : public Component, public select::Select, public Senso
|
||||
|
||||
protected:
|
||||
std::vector<int64_t> mapping_{};
|
||||
ModbusController *parent_{nullptr};
|
||||
bool use_write_multiple_{false};
|
||||
bool optimistic_{false};
|
||||
optional<transform_func_t> transform_func_{nullopt};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import switch
|
||||
from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE
|
||||
from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE, PduBuffer
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ADDRESS, CONF_ASSUMED_STATE, CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
ModbusItemBaseSchema,
|
||||
@@ -48,7 +49,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
byte_offset, _ = modbus_calc_properties(config)
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
@@ -74,7 +75,7 @@ async def to_code(config):
|
||||
[
|
||||
(ModbusSwitch.operator("ptr"), "item"),
|
||||
(cg.bool_, "x"),
|
||||
(cg.std_vector.template(cg.uint8).operator("ref"), "payload"),
|
||||
(PduBuffer.operator("ref"), "payload"),
|
||||
],
|
||||
return_type=cg.optional.template(bool),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <array>
|
||||
|
||||
namespace esphome::modbus_controller {
|
||||
|
||||
static const char *const TAG = "modbus_controller.switch";
|
||||
@@ -58,57 +60,64 @@ void ModbusSwitch::parse_and_publish(std::span<const uint8_t> data) {
|
||||
}
|
||||
|
||||
void ModbusSwitch::write_state(bool state) {
|
||||
// This will be called every time the user requests a state change.
|
||||
optional<ModbusCommandItem> cmd;
|
||||
std::vector<uint8_t> data;
|
||||
// Is there are lambda configured?
|
||||
this->clear_dispatched_();
|
||||
// A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one)
|
||||
// so a rapidly-changing value writes the latest, not every intermediate.
|
||||
this->clear_tx_queue_for_device();
|
||||
modbus::helpers::PduBuffer data;
|
||||
if (this->write_transform_func_.has_value()) {
|
||||
// data is passed by reference
|
||||
// the lambda can fill the empty vector directly
|
||||
// in that case the return value is ignored
|
||||
// The lambda may drive the write itself via item->write_*/queue_pdu(), override the written value (return a
|
||||
// value), or (deprecated) fill `data` with a custom PDU.
|
||||
auto val = (*this->write_transform_func_)(this, state, data);
|
||||
if (val.has_value()) {
|
||||
ESP_LOGV(TAG, "Value overwritten by lambda");
|
||||
state = val.value();
|
||||
} else {
|
||||
if (this->dispatched()) {
|
||||
this->publish_state(state);
|
||||
return;
|
||||
}
|
||||
if (!data.empty()) {
|
||||
this->warn_write_buffer_deprecated_(LOG_STR("switch"), this->start_address);
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
char hex_buf[format_hex_pretty_size(MODBUS_SWITCH_MAX_LOG_BYTES)];
|
||||
#endif
|
||||
ESP_LOGV(TAG, "Modbus Switch write raw: %s",
|
||||
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
|
||||
// The lambda filled a legacy raw frame (device address + function code + data).
|
||||
if (!this->send_raw_frame_deprecated_(data)) {
|
||||
ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str());
|
||||
return;
|
||||
}
|
||||
this->publish_state(state);
|
||||
return;
|
||||
}
|
||||
if (!val.has_value()) {
|
||||
ESP_LOGV(TAG, "Communication handled by lambda - exiting control");
|
||||
return;
|
||||
}
|
||||
ESP_LOGV(TAG, "Value overwritten by lambda");
|
||||
state = val.value();
|
||||
}
|
||||
if (!data.empty()) {
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
char hex_buf[format_hex_pretty_size(MODBUS_SWITCH_MAX_LOG_BYTES)];
|
||||
#endif
|
||||
ESP_LOGV(TAG, "Modbus Switch write raw: %s",
|
||||
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
|
||||
cmd.emplace(ModbusCommandItem::create_custom_command(
|
||||
this->parent_, data,
|
||||
[this](modbus::EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
|
||||
this->parent_->on_write_register_response(register_type, this->start_address, data);
|
||||
}));
|
||||
} else {
|
||||
ESP_LOGV(TAG, "write_state '%s': new value = %s type = %d address = %X offset = %x", this->get_name().c_str(),
|
||||
ONOFF(state), (int) this->register_type, this->start_address, this->offset);
|
||||
if (this->register_type == modbus::EntityType::COIL) {
|
||||
// offset for coil and discrete inputs is the coil/register number not bytes
|
||||
if (this->use_write_multiple_) {
|
||||
std::vector<bool> states{state};
|
||||
cmd.emplace(ModbusCommandItem::create_write_multiple_coils(this->parent_, this->write_address(), states));
|
||||
} else {
|
||||
cmd.emplace(ModbusCommandItem::create_write_single_coil(this->parent_, this->write_address(), state));
|
||||
}
|
||||
ESP_LOGV(TAG, "write_state '%s': new value = %s type = %d address = %X offset = %x", this->get_name().c_str(),
|
||||
ONOFF(state), (int) this->register_type, this->start_address, this->offset);
|
||||
bool queued;
|
||||
if (this->register_type == EntityType::COIL) {
|
||||
// offset for coil and discrete inputs is the coil/register number not bytes
|
||||
if (this->use_write_multiple_) {
|
||||
std::array<bool, 1> states{state};
|
||||
queued = this->write_multiple_coils(this->write_address(), states);
|
||||
} else {
|
||||
if (this->use_write_multiple_) {
|
||||
std::vector<uint16_t> bool_states(1, state ? (0xFFFF & this->bitmask) : 0);
|
||||
cmd.emplace(
|
||||
ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(), 1, bool_states));
|
||||
} else {
|
||||
cmd.emplace(ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(),
|
||||
state ? 0xFFFF & this->bitmask : 0u));
|
||||
}
|
||||
queued = this->write_single_coil(this->write_address(), state);
|
||||
}
|
||||
} else {
|
||||
if (this->use_write_multiple_) {
|
||||
std::array<uint16_t, 1> states{static_cast<uint16_t>(state ? (0xFFFF & this->bitmask) : 0)};
|
||||
queued = this->write_multiple_registers(this->write_address(), states);
|
||||
} else {
|
||||
queued = this->write_single_register(this->write_address(), state ? 0xFFFF & this->bitmask : 0u);
|
||||
}
|
||||
}
|
||||
this->parent_->queue_command(std::move(*cmd));
|
||||
if (!queued) {
|
||||
ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str());
|
||||
return;
|
||||
}
|
||||
this->publish_state(state);
|
||||
}
|
||||
// ModbusSwitch end
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
namespace esphome::modbus_controller {
|
||||
|
||||
class ModbusSwitch final : public Component, public switch_::Switch, public SensorItem {
|
||||
class ModbusSwitch final : public Component, public switch_::Switch, public SensorItem, public WriterEntity {
|
||||
public:
|
||||
ModbusSwitch(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
|
||||
bool force_new_range) {
|
||||
@@ -30,17 +30,16 @@ class ModbusSwitch final : public Component, public switch_::Switch, public Sens
|
||||
void set_assumed_state(bool assumed_state);
|
||||
void set_state(bool state) { this->state = state; }
|
||||
void parse_and_publish(std::span<const uint8_t> data) override;
|
||||
void set_parent(ModbusController *parent) { this->parent_ = parent; }
|
||||
void set_parent(ModbusController *parent) { this->set_controller_(parent); }
|
||||
|
||||
using transform_func_t = optional<bool> (*)(ModbusSwitch *, bool, std::span<const uint8_t>);
|
||||
using write_transform_func_t = optional<bool> (*)(ModbusSwitch *, bool, std::vector<uint8_t> &);
|
||||
using write_transform_func_t = optional<bool> (*)(ModbusSwitch *, bool, modbus::helpers::PduBuffer &);
|
||||
void set_template(transform_func_t f) { this->publish_transform_func_ = f; }
|
||||
void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; }
|
||||
void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; }
|
||||
|
||||
protected:
|
||||
bool assumed_state() override;
|
||||
ModbusController *parent_{nullptr};
|
||||
bool use_write_multiple_{false};
|
||||
optional<transform_func_t> publish_transform_func_{nullopt};
|
||||
optional<write_transform_func_t> write_transform_func_{nullopt};
|
||||
|
||||
@@ -125,10 +125,8 @@ def set_core_data(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
def _resolve_toolchain(config: ConfigType) -> ConfigType:
|
||||
if CORE.toolchain is None:
|
||||
CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.SDK_NRF)
|
||||
return config
|
||||
_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.SDK_NRF)
|
||||
_resolve_toolchain = cv.resolve_toolchain("nRF52", _TOOLCHAINS, Toolchain.SDK_NRF)
|
||||
|
||||
|
||||
def set_framework(config: ConfigType) -> ConfigType:
|
||||
@@ -170,10 +168,7 @@ BOOTLOADERS = [
|
||||
]
|
||||
|
||||
|
||||
def _validate_toolchain(value) -> Toolchain:
|
||||
return Toolchain(
|
||||
cv.one_of(Toolchain.PLATFORMIO, Toolchain.SDK_NRF, lower=True)(value)
|
||||
)
|
||||
_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS)
|
||||
|
||||
|
||||
def _detect_bootloader(config: ConfigType) -> ConfigType:
|
||||
|
||||
@@ -6,8 +6,7 @@ import platform
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import platformdirs
|
||||
|
||||
from esphome.build_helpers.tools_cache import SDK_NRF_TOOLS_CACHE, tools_cache_path
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
|
||||
from esphome.core import CORE, EsphomeError
|
||||
@@ -19,7 +18,6 @@ from esphome.framework_helpers import (
|
||||
run_command_ok,
|
||||
str_to_lst_of_str,
|
||||
)
|
||||
from esphome.helpers import get_str_env
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -49,15 +47,9 @@ SDK_NG_MINIMAL_MIRRORS = str_to_lst_of_str(
|
||||
|
||||
|
||||
def get_sdk_nrf_tools_path() -> Path:
|
||||
# A blank ESPHOME_SDK_NRF_PREFIX must be treated as unset: Path("")
|
||||
# resolves to the CWD, which clean-all would then delete.
|
||||
if prefix := get_str_env("ESPHOME_SDK_NRF_PREFIX", "").strip():
|
||||
path = Path(prefix).expanduser()
|
||||
else:
|
||||
# Machine-global (OS user cache dir) so all projects share one install;
|
||||
# see espidf.framework.get_idf_tools_path for the location rationale.
|
||||
path = Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf"
|
||||
return path.resolve()
|
||||
# Machine-global (OS user cache dir) so all projects share one install;
|
||||
# see espidf.framework.get_idf_tools_path for the location rationale.
|
||||
return tools_cache_path(*SDK_NRF_TOOLS_CACHE)
|
||||
|
||||
|
||||
def _needs_venv_rebuild(
|
||||
|
||||
@@ -3,6 +3,12 @@ import logging
|
||||
from esphome import automation, pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32, esp32_rmt, remote_base
|
||||
from esphome.components.libretiny import get_libretiny_family
|
||||
from esphome.components.libretiny.const import (
|
||||
FAMILY_BK7231N,
|
||||
FAMILY_BK7238,
|
||||
FAMILY_RTL8720C,
|
||||
)
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -43,6 +49,21 @@ DigitalWriteAction = remote_transmitter_ns.class_(
|
||||
)
|
||||
|
||||
|
||||
_NON_BLOCKING_LIBRETINY_FAMILIES = (FAMILY_RTL8720C, FAMILY_BK7231N, FAMILY_BK7238)
|
||||
|
||||
|
||||
def _validate_non_blocking_platform(value: bool) -> bool:
|
||||
# non_blocking requires hardware transmission: RMT on ESP32, a hardware timer
|
||||
# envelope chain on the listed LibreTiny families. Reject elsewhere at config time.
|
||||
if CORE.is_esp32:
|
||||
return cv.boolean(value)
|
||||
if CORE.is_libretiny and get_libretiny_family() in _NON_BLOCKING_LIBRETINY_FAMILIES:
|
||||
return cv.boolean(value)
|
||||
raise cv.Invalid(
|
||||
"non_blocking is only supported on ESP32, RTL8720C, BK7231N and BK7238"
|
||||
)
|
||||
|
||||
|
||||
MULTI_CONF = True
|
||||
CONFIG_SCHEMA = (
|
||||
cv.Schema(
|
||||
@@ -76,7 +97,7 @@ CONFIG_SCHEMA = (
|
||||
esp32_s2=64,
|
||||
esp32_s3=48,
|
||||
): cv.All(cv.only_on_esp32, cv.int_range(min=2)),
|
||||
cv.Optional(CONF_NON_BLOCKING): cv.All(cv.only_on_esp32, cv.boolean),
|
||||
cv.Optional(CONF_NON_BLOCKING): _validate_non_blocking_platform,
|
||||
cv.Optional(CONF_ON_TRANSMIT): automation.validate_automation(single=True),
|
||||
cv.Optional(CONF_ON_COMPLETE): automation.validate_automation(single=True),
|
||||
}
|
||||
@@ -164,6 +185,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
)
|
||||
else:
|
||||
var = cg.new_Pvariable(config[CONF_ID], pin)
|
||||
if (non_blocking := config.get(CONF_NON_BLOCKING)) is not None:
|
||||
cg.add(var.set_non_blocking(non_blocking))
|
||||
await cg.register_component(var, config)
|
||||
|
||||
cg.add(var.set_carrier_duty_percent(config[CONF_CARRIER_DUTY_PERCENT]))
|
||||
@@ -188,6 +211,13 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
"remote_transmitter_rtl87xx.cpp": {
|
||||
PlatformFramework.RTL87XX_ARDUINO,
|
||||
},
|
||||
"remote_transmitter_bk72xx.cpp": {
|
||||
PlatformFramework.BK72XX_ARDUINO,
|
||||
},
|
||||
"remote_transmitter_libretiny_isr.cpp": {
|
||||
PlatformFramework.RTL87XX_ARDUINO,
|
||||
PlatformFramework.BK72XX_ARDUINO,
|
||||
},
|
||||
"remote_transmitter.cpp": {
|
||||
PlatformFramework.ESP32_ARDUINO,
|
||||
PlatformFramework.ESP32_IDF,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/application.h"
|
||||
|
||||
#if (defined(USE_LIBRETINY) && !defined(USE_RTL87XX)) || defined(USE_ESP8266) || defined(USE_RP2) || \
|
||||
(defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
|
||||
#if (defined(USE_LIBRETINY) && !defined(USE_RTL87XX) && !defined(REMOTE_TRANSMITTER_BK_PWM)) || \
|
||||
defined(USE_ESP8266) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
|
||||
|
||||
namespace esphome::remote_transmitter {
|
||||
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
#endif // SOC_RMT_SUPPORTED
|
||||
#endif // USE_ESP32
|
||||
|
||||
// The BK7231N-style PWM block (hardware shadow-load duty updates) enables the ISR-driven
|
||||
// transmitter on these families; family-level proxy for the SDK's CFG_SOC_NAME gate.
|
||||
// See remote_transmitter_bk72xx.cpp.
|
||||
#if defined(USE_LIBRETINY_VARIANT_BK7231N) || defined(USE_LIBRETINY_VARIANT_BK7238)
|
||||
#define REMOTE_TRANSMITTER_BK_PWM
|
||||
#endif
|
||||
|
||||
namespace esphome::remote_transmitter {
|
||||
|
||||
#if defined(USE_ESP32) && SOC_RMT_SUPPORTED
|
||||
@@ -56,19 +63,32 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa
|
||||
#if defined(USE_ESP32) && SOC_RMT_SUPPORTED
|
||||
void set_with_dma(bool with_dma) { this->with_dma_ = with_dma; }
|
||||
void set_eot_level(bool eot_level) { this->eot_level_ = eot_level; }
|
||||
#endif
|
||||
#if (defined(USE_ESP32) && SOC_RMT_SUPPORTED) || defined(USE_LIBRETINY_VARIANT_RTL8720C) || \
|
||||
defined(REMOTE_TRANSMITTER_BK_PWM)
|
||||
void set_non_blocking(bool non_blocking) { this->non_blocking_ = non_blocking; }
|
||||
#endif
|
||||
#if defined(USE_LIBRETINY_VARIANT_RTL8720C) || defined(REMOTE_TRANSMITTER_BK_PWM)
|
||||
void loop() override;
|
||||
// called from the envelope timer ISR trampoline; not part of the public API
|
||||
void advance_envelope_isr();
|
||||
// same, for trampolines whose SDK callback carries no user argument
|
||||
static void advance_active_isr();
|
||||
#endif
|
||||
|
||||
Trigger<> *get_transmit_trigger() { return &this->transmit_trigger_; }
|
||||
Trigger<> *get_complete_trigger() { return &this->complete_trigger_; }
|
||||
|
||||
protected:
|
||||
void send_internal(uint32_t send_times, uint32_t send_wait) override;
|
||||
#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
|
||||
#if defined(USE_ESP8266) || \
|
||||
(defined(USE_LIBRETINY) && !defined(USE_LIBRETINY_VARIANT_RTL8720C) && !defined(REMOTE_TRANSMITTER_BK_PWM)) || \
|
||||
defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
|
||||
void await_target_time_();
|
||||
uint32_t target_time_{0};
|
||||
#endif
|
||||
#if defined(USE_ESP8266) || (defined(USE_LIBRETINY) && !defined(USE_RTL87XX)) || defined(USE_RP2) || \
|
||||
#if defined(USE_ESP8266) || \
|
||||
(defined(USE_LIBRETINY) && !defined(USE_RTL87XX) && !defined(REMOTE_TRANSMITTER_BK_PWM)) || defined(USE_RP2) || \
|
||||
(defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
|
||||
void calculate_on_off_time_(uint32_t carrier_frequency, uint32_t *on_time_period, uint32_t *off_time_period);
|
||||
|
||||
@@ -81,6 +101,43 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa
|
||||
uint32_t current_carrier_frequency_{0};
|
||||
void *pwm_{nullptr}; // pwmout_t*, opaque here to keep the SDK header out of this shared header
|
||||
#endif
|
||||
#if defined(USE_LIBRETINY_VARIANT_RTL8720C) || defined(REMOTE_TRANSMITTER_BK_PWM)
|
||||
// Envelope chain, shared by every family that paces transmission from a hardware timer
|
||||
// (remote_transmitter_libretiny_isr.cpp)
|
||||
void start_isr_item_(size_t index);
|
||||
void arm_envelope_timer_(uint32_t duration_us);
|
||||
void abort_stalled_chain_();
|
||||
void deliver_completion_();
|
||||
void wait_until_idle_();
|
||||
void arm_chain_(uint32_t send_times, uint32_t send_wait);
|
||||
// Hooks implemented per family: everything the chain needs from the hardware
|
||||
bool envelope_ready_() const; // PWM claimed successfully in setup()
|
||||
void prepare_carrier_(uint32_t carrier_frequency); // retune period, stage mark/space levels
|
||||
void write_envelope_level_(bool mark); // drive carrier (mark) or idle (space)
|
||||
void arm_one_shot_(uint32_t duration_us); // fire advance_envelope_isr after duration_us
|
||||
void stop_envelope_timer_();
|
||||
std::vector<int32_t> isr_data_; // owned copy of the frame; temp_ may be re-encoded mid-flight
|
||||
volatile size_t isr_index_{0};
|
||||
volatile uint32_t isr_repeats_left_{0};
|
||||
uint32_t isr_send_wait_{0};
|
||||
volatile uint32_t isr_wait_remaining_{0}; // remainder of a duration chained across one-shots
|
||||
volatile bool isr_in_gap_{false};
|
||||
volatile bool transmitting_{false};
|
||||
bool non_blocking_{false};
|
||||
bool complete_pending_{false};
|
||||
bool stall_aborted_{false}; // this transmission ended via abort; blocks warning clear
|
||||
#endif
|
||||
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
|
||||
float isr_mark_duty_{0.0f};
|
||||
float isr_space_duty_{0.0f};
|
||||
#endif
|
||||
#ifdef REMOTE_TRANSMITTER_BK_PWM
|
||||
void write_pwm_t1_(uint32_t t1_counts);
|
||||
uint32_t isr_mark_t1_{0};
|
||||
uint32_t isr_space_t1_{0};
|
||||
uint32_t isr_period_t4_{684}; // 26MHz counts; ~38kHz default until a send sets the real carrier
|
||||
int8_t pwm_channel_{-1};
|
||||
#endif
|
||||
|
||||
#if defined(USE_ESP32) && SOC_RMT_SUPPORTED
|
||||
void configure_rmt_();
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
#include "remote_transmitter.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
// clang-tidy cannot parse the Beken SDK headers pulled in via ArduinoPrivate.h
|
||||
#if defined(USE_BK72XX) && !defined(CLANG_TIDY)
|
||||
|
||||
// ArduinoPrivate.h = Arduino.h + the BDK SDK headers (pwm_pub.h, bk_timer_pub.h, icu_pub.h)
|
||||
// with the core's fixes for type-name collisions between the two
|
||||
#include <ArduinoPrivate.h>
|
||||
|
||||
// Only the BK7231N-style PWM block (shadow registers with a hardware CFG_UPDATA load bit)
|
||||
// supports glitch-free per-edge duty updates; older SoCs compile the generic bit-bang
|
||||
// implementation (remote_transmitter.cpp) instead, and this file compiles to nothing.
|
||||
// REMOTE_TRANSMITTER_BK_PWM is set per-family in remote_transmitter.h.
|
||||
|
||||
namespace esphome::remote_transmitter {
|
||||
|
||||
static const char *const TAG = "remote_transmitter";
|
||||
|
||||
#ifdef REMOTE_TRANSMITTER_BK_PWM
|
||||
|
||||
// PWM peripheral carrier (26MHz block), envelope paced by a BKTIMER1 interrupt chain: each
|
||||
// interrupt writes the next duty through the shadow registers (T1..T4 + CFG_UPDATA hardware
|
||||
// load, glitch-free at the next carrier period). Direct register writes beat the driver's
|
||||
// pwm_update_param() (~19us vs ~26us edge error) and have no shared state to race against.
|
||||
// BKTIMER1 is the only free channel: TIMER0 = FreeRTOS tick, TIMER2 = SDK cal, TIMER4 = wdt.
|
||||
|
||||
static constexpr uint32_t REG_PWM_BASE = 0x00802B00UL;
|
||||
static constexpr uint32_t REG_PWM_GROUP_STRIDE = 0x40; // one register group per channel pair
|
||||
static constexpr uint32_t REG_PWM_T_REGS[2] = {0x04, 0x14}; // T1..T4 offsets within a group
|
||||
static constexpr uint32_t PWM_INT_STATUS_MASK = 3UL << 30; // write-1-clear -- always write as zero
|
||||
static constexpr uint8_t ENVELOPE_TIMER = BKTIMER1;
|
||||
|
||||
// The bk_timer handler receives only the channel number, so the chain resolves the instance
|
||||
// that owns the timer. No IRAM_ATTR: hal.h makes it a no-op on BK72xx (the SDK masks IRQs
|
||||
// around flash writes).
|
||||
static void envelope_timer_isr(UINT8 channel) { RemoteTransmitterComponent::advance_active_isr(); }
|
||||
|
||||
// Channel <-> pin comes from the board variant's own PIN_PWMn defines rather than a
|
||||
// family-wide assumption, so an unusual pinout maps correctly instead of silently
|
||||
// driving another pad
|
||||
struct PwmPinChannel {
|
||||
uint8_t pin;
|
||||
int8_t channel;
|
||||
};
|
||||
static constexpr PwmPinChannel PWM_PIN_CHANNELS[] = {
|
||||
#ifdef PIN_PWM0
|
||||
{PIN_PWM0, 0},
|
||||
#endif
|
||||
#ifdef PIN_PWM1
|
||||
{PIN_PWM1, 1},
|
||||
#endif
|
||||
#ifdef PIN_PWM2
|
||||
{PIN_PWM2, 2},
|
||||
#endif
|
||||
#ifdef PIN_PWM3
|
||||
{PIN_PWM3, 3},
|
||||
#endif
|
||||
#ifdef PIN_PWM4
|
||||
{PIN_PWM4, 4},
|
||||
#endif
|
||||
#ifdef PIN_PWM5
|
||||
{PIN_PWM5, 5},
|
||||
#endif
|
||||
};
|
||||
|
||||
static int8_t pwm_channel_for_pin(uint8_t pin) {
|
||||
for (const auto &entry : PWM_PIN_CHANNELS) {
|
||||
if (entry.pin == pin)
|
||||
return entry.channel;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::setup() {
|
||||
// Deliberately no pin_->setup(): the pin must belong to the PWM function, not GPIO
|
||||
const int8_t channel = pwm_channel_for_pin(this->pin_->get_pin());
|
||||
if (channel < 0) {
|
||||
ESP_LOGE(TAG, "Pin %u is not PWM-capable", this->pin_->get_pin());
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
this->pwm_channel_ = channel;
|
||||
const uint32_t idle_t1 = this->pin_->is_inverted() ? this->isr_period_t4_ : 0;
|
||||
pwm_param_st param{};
|
||||
param.chan = channel;
|
||||
param.t1 = idle_t1;
|
||||
param.t4 = this->isr_period_t4_;
|
||||
param.init_level = idle_t1 ? 1 : 0;
|
||||
if (pwm_init_param(¶m) != 0 || pwm_start(channel) != 0) {
|
||||
ESP_LOGE(TAG, "PWM init failed on pin %u", this->pin_->get_pin());
|
||||
this->pwm_channel_ = -1;
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
this->disable_loop(); // loop() is only needed while a non-blocking completion is pending
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Remote Transmitter:\n"
|
||||
" Carrier Duty: %u%%\n"
|
||||
" Non-blocking: %s",
|
||||
this->carrier_duty_percent_, YESNO(this->non_blocking_));
|
||||
LOG_PIN(" Pin: ", this->pin_);
|
||||
}
|
||||
|
||||
// Writes the duty compare registers and sets the hardware CFG_UPDATA shadow-load bit;
|
||||
// the new duty latches glitch-free at the next carrier period. ISR-safe: registers only.
|
||||
// The group control word is shared with the paired channel, but every SDK write to it runs
|
||||
// under GLOBAL_INT_DISABLE (bk_pwm), so it cannot be torn by this interrupt.
|
||||
void RemoteTransmitterComponent::write_pwm_t1_(uint32_t t1_counts) {
|
||||
const uint32_t group = this->pwm_channel_ / 2;
|
||||
const uint32_t post = this->pwm_channel_ % 2;
|
||||
const uint32_t group_base = REG_PWM_BASE + REG_PWM_GROUP_STRIDE * group;
|
||||
auto *t_regs = (volatile uint32_t *) (group_base + REG_PWM_T_REGS[post]);
|
||||
auto *ctrl = (volatile uint32_t *) group_base;
|
||||
const uint32_t init_level_bit = 1UL << (8 * post + 6); // output level while the counter is stopped
|
||||
const uint32_t cfg_updata_bit = 1UL << (8 * post + 7); // 0->1 latches T1..T4 at the next period
|
||||
t_regs[0] = t1_counts; // T1: high time
|
||||
t_regs[1] = 0; // T2
|
||||
t_regs[2] = 0; // T3
|
||||
t_regs[3] = this->isr_period_t4_; // T4: period
|
||||
uint32_t cfg = *ctrl;
|
||||
cfg &= ~(PWM_INT_STATUS_MASK | init_level_bit | cfg_updata_bit);
|
||||
if (t1_counts != 0)
|
||||
cfg |= init_level_bit;
|
||||
*ctrl = cfg;
|
||||
*ctrl = cfg | cfg_updata_bit;
|
||||
}
|
||||
|
||||
// --- envelope chain hooks (see remote_transmitter_libretiny_isr.cpp) ---
|
||||
|
||||
bool RemoteTransmitterComponent::envelope_ready_() const { return this->pwm_channel_ >= 0; }
|
||||
|
||||
// Recomputes the carrier period in 26MHz counts and stages the per-item duties;
|
||||
// unmodulated protocols drive the pin constantly during marks
|
||||
void RemoteTransmitterComponent::prepare_carrier_(uint32_t carrier_frequency) {
|
||||
if (carrier_frequency > 0) {
|
||||
this->isr_period_t4_ = std::max(uint32_t(2), (26000000UL + carrier_frequency / 2) / carrier_frequency);
|
||||
}
|
||||
uint32_t mark_t1 = (carrier_frequency > 0 && this->carrier_duty_percent_ < 100)
|
||||
? std::max(uint32_t(1), this->isr_period_t4_ * this->carrier_duty_percent_ / 100)
|
||||
: this->isr_period_t4_;
|
||||
uint32_t space_t1 = 0;
|
||||
if (this->pin_->is_inverted()) {
|
||||
mark_t1 = this->isr_period_t4_ - mark_t1;
|
||||
space_t1 = this->isr_period_t4_;
|
||||
}
|
||||
this->isr_mark_t1_ = mark_t1;
|
||||
this->isr_space_t1_ = space_t1;
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::write_envelope_level_(bool mark) {
|
||||
this->write_pwm_t1_(mark ? this->isr_mark_t1_ : this->isr_space_t1_);
|
||||
}
|
||||
|
||||
// The driver's microsecond init path is register writes under a nested interrupt guard,
|
||||
// so it is safe to call from the chain's own interrupt
|
||||
void RemoteTransmitterComponent::arm_one_shot_(uint32_t duration_us) {
|
||||
timer_param_t param{};
|
||||
param.channel = ENVELOPE_TIMER;
|
||||
param.div = 1;
|
||||
param.period = duration_us;
|
||||
param.t_Int_Handler = envelope_timer_isr;
|
||||
sddev_control((char *) TIMER_DEV_NAME, CMD_TIMER_INIT_PARAM_US, ¶m);
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::stop_envelope_timer_() {
|
||||
UINT32 channel = ENVELOPE_TIMER;
|
||||
sddev_control((char *) TIMER_DEV_NAME, CMD_TIMER_UNIT_DISABLE, &channel);
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::digital_write(bool value) {
|
||||
if (this->pwm_channel_ < 0)
|
||||
return;
|
||||
// serialize behind an in-flight chain, matching the ESP32/RMT non-blocking behavior
|
||||
this->wait_until_idle_();
|
||||
this->write_pwm_t1_((value != this->pin_->is_inverted()) ? this->isr_period_t4_ : 0);
|
||||
}
|
||||
|
||||
#endif // REMOTE_TRANSMITTER_BK_PWM
|
||||
|
||||
} // namespace esphome::remote_transmitter
|
||||
|
||||
#endif // USE_BK72XX && !CLANG_TIDY
|
||||
@@ -0,0 +1,224 @@
|
||||
#include "remote_transmitter.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
// Envelope chain shared by the LibreTiny families that pace transmission from a hardware
|
||||
// timer interrupt: RTL8720C (gtimer) and the BK7231N-style PWM block (BKTIMER1). Everything
|
||||
// platform-specific sits behind five hooks implemented in the per-family files -- carrier
|
||||
// setup, duty writes, one-shot arming and timer stop. Families without a usable timer keep
|
||||
// the generic bit-bang implementation and compile none of this.
|
||||
#if defined(USE_LIBRETINY_VARIANT_RTL8720C) || defined(REMOTE_TRANSMITTER_BK_PWM)
|
||||
|
||||
namespace esphome::remote_transmitter {
|
||||
|
||||
static const char *const TAG = "remote_transmitter";
|
||||
|
||||
// Margin past a transmission's expected duration before the chain is declared stalled
|
||||
static constexpr uint32_t STALL_MARGIN_MS = 1000;
|
||||
// Longest single one-shot armed; longer durations are chained. Both families need the cap:
|
||||
// the Beken driver computes period_us * 26 in 32 bits (overflows past ~165s) and the Realtek
|
||||
// us->tick conversion lives in mask ROM with unverified headroom.
|
||||
static constexpr uint32_t MAX_ONE_SHOT_US = 50000;
|
||||
|
||||
// One hardware timer is shared by all instances (MULTI_CONF), so they serialize on this
|
||||
// token; the deadline always describes whichever chain currently owns it.
|
||||
// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static RemoteTransmitterComponent *volatile s_active_transmitter = nullptr;
|
||||
static uint32_t s_expected_end_ms = 0;
|
||||
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
// Entry point for trampolines whose SDK callback carries no user argument
|
||||
void IRAM_ATTR RemoteTransmitterComponent::advance_active_isr() {
|
||||
auto *transmitter = s_active_transmitter;
|
||||
if (transmitter != nullptr)
|
||||
transmitter->advance_envelope_isr();
|
||||
}
|
||||
|
||||
// Arms the envelope timer, chaining durations longer than MAX_ONE_SHOT_US. ISR-safe.
|
||||
void IRAM_ATTR RemoteTransmitterComponent::arm_envelope_timer_(uint32_t duration_us) {
|
||||
// clamp to 1us (a zero-length one-shot never fires); the remainder must not underflow
|
||||
const uint32_t chunk = std::max(uint32_t(1), std::min(duration_us, MAX_ONE_SHOT_US));
|
||||
this->isr_wait_remaining_ = duration_us > chunk ? duration_us - chunk : 0;
|
||||
this->arm_one_shot_(chunk);
|
||||
}
|
||||
|
||||
// Writes the level for one envelope item and arms the timer for its duration.
|
||||
// Runs in ISR context (and once from arm_chain_ to kick the chain): no logging, no allocation.
|
||||
void IRAM_ATTR RemoteTransmitterComponent::start_isr_item_(size_t index) {
|
||||
const int32_t item = this->isr_data_[index];
|
||||
this->write_envelope_level_(item > 0);
|
||||
this->arm_envelope_timer_(uint32_t(item > 0 ? item : -item));
|
||||
}
|
||||
|
||||
void IRAM_ATTR RemoteTransmitterComponent::advance_envelope_isr() {
|
||||
if (!this->transmitting_)
|
||||
return; // chain was aborted; this is a stale one-shot that was already latched
|
||||
if (this->isr_wait_remaining_ > 0) {
|
||||
// continue a duration longer than one hardware one-shot
|
||||
this->arm_envelope_timer_(this->isr_wait_remaining_);
|
||||
return;
|
||||
}
|
||||
if (this->isr_in_gap_) {
|
||||
// inter-repeat gap elapsed; restart the item chain
|
||||
this->isr_in_gap_ = false;
|
||||
this->isr_index_ = 0;
|
||||
this->start_isr_item_(0);
|
||||
return;
|
||||
}
|
||||
this->isr_index_ = this->isr_index_ + 1;
|
||||
if (this->isr_index_ < this->isr_data_.size()) {
|
||||
this->start_isr_item_(this->isr_index_);
|
||||
return;
|
||||
}
|
||||
// end of one repetition
|
||||
this->write_envelope_level_(false);
|
||||
if (this->isr_repeats_left_ > 1) {
|
||||
this->isr_repeats_left_ = this->isr_repeats_left_ - 1;
|
||||
this->isr_index_ = 0;
|
||||
if (this->isr_send_wait_ > 0) {
|
||||
this->isr_in_gap_ = true;
|
||||
this->arm_envelope_timer_(this->isr_send_wait_);
|
||||
} else {
|
||||
this->start_isr_item_(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// required on Beken (its timer reloads); on Realtek this only clears the enable bit of a
|
||||
// one-shot that has already fired
|
||||
this->stop_envelope_timer_();
|
||||
this->transmitting_ = false;
|
||||
s_active_transmitter = nullptr;
|
||||
}
|
||||
|
||||
// Aborts a chain that stopped advancing: stop the timer, idle the pin, release the token.
|
||||
// Every step is a no-op if the chain completed meanwhile. Task context only.
|
||||
void RemoteTransmitterComponent::abort_stalled_chain_() {
|
||||
// cleared first so a straggler one-shot bails at the ISR entry check
|
||||
this->transmitting_ = false;
|
||||
this->stop_envelope_timer_();
|
||||
this->write_envelope_level_(false);
|
||||
s_active_transmitter = nullptr;
|
||||
this->stall_aborted_ = true;
|
||||
this->status_set_warning("envelope timer stalled");
|
||||
ESP_LOGE(TAG, "Envelope timer stalled; transmission aborted");
|
||||
delay(1); // let any already-latched interrupt land while the chain state is safe
|
||||
}
|
||||
|
||||
// Delivers one deferred completion with its status bookkeeping
|
||||
void RemoteTransmitterComponent::deliver_completion_() {
|
||||
if (!this->stall_aborted_)
|
||||
this->status_clear_warning();
|
||||
this->complete_pending_ = false;
|
||||
this->complete_trigger_.trigger();
|
||||
}
|
||||
|
||||
// Waits until no chain is in flight, delivering any deferred completions; a completion
|
||||
// automation may start a new send, so repeat until truly idle. Bounded by the stall deadline.
|
||||
void RemoteTransmitterComponent::wait_until_idle_() {
|
||||
while (true) {
|
||||
while (true) {
|
||||
// snapshot: the final ISR can clear the volatile pointer between a check and a use
|
||||
auto *active = s_active_transmitter;
|
||||
if (active == nullptr)
|
||||
break;
|
||||
if ((int32_t) (millis() - s_expected_end_ms) > 0) {
|
||||
active->abort_stalled_chain_();
|
||||
break;
|
||||
}
|
||||
App.feed_wdt();
|
||||
delay(1);
|
||||
}
|
||||
if (!this->complete_pending_)
|
||||
break;
|
||||
this->deliver_completion_();
|
||||
}
|
||||
}
|
||||
|
||||
// Stages the repeat schedule and stall deadline, then starts the interrupt chain
|
||||
void RemoteTransmitterComponent::arm_chain_(uint32_t send_times, uint32_t send_wait) {
|
||||
this->isr_repeats_left_ = send_times;
|
||||
this->isr_send_wait_ = send_wait;
|
||||
this->isr_index_ = 0;
|
||||
this->isr_in_gap_ = false;
|
||||
this->stall_aborted_ = false;
|
||||
uint64_t frame_us = 0;
|
||||
for (int32_t item : this->isr_data_)
|
||||
frame_us += uint32_t(item > 0 ? item : -item);
|
||||
const uint64_t total_us = frame_us * send_times + uint64_t(send_wait) * (send_times - 1);
|
||||
s_expected_end_ms = millis() + uint32_t(total_us / 1000) + STALL_MARGIN_MS;
|
||||
this->transmitting_ = true;
|
||||
s_active_transmitter = this;
|
||||
this->start_isr_item_(0);
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) {
|
||||
if (!this->envelope_ready_()) {
|
||||
// both triggers still fire, so an on_complete-sequenced automation does not stall
|
||||
ESP_LOGW(TAG, "Cannot send: PWM not initialized");
|
||||
this->transmit_trigger_.trigger();
|
||||
this->deliver_completion_();
|
||||
return;
|
||||
}
|
||||
this->wait_until_idle_();
|
||||
if (send_times == 0) {
|
||||
// parity with the loop-based implementations: transmit nothing, but both triggers
|
||||
// still fire so an on_complete-sequenced automation does not stall
|
||||
this->transmit_trigger_.trigger();
|
||||
this->deliver_completion_();
|
||||
return;
|
||||
}
|
||||
ESP_LOGD(TAG, "Sending remote code");
|
||||
this->prepare_carrier_(this->temp_.get_carrier_frequency());
|
||||
// own copy: with non_blocking the caller may re-encode temp_ while this frame is in flight
|
||||
this->isr_data_.assign(this->temp_.get_data().begin(), this->temp_.get_data().end());
|
||||
if (this->isr_data_.empty()) {
|
||||
ESP_LOGW(TAG, "Empty data");
|
||||
this->transmit_trigger_.trigger();
|
||||
this->deliver_completion_();
|
||||
return;
|
||||
}
|
||||
// trigger first: the deadline computed in arm_chain_ must not be charged for user code
|
||||
this->transmit_trigger_.trigger();
|
||||
// the automation may have started a send on another instance; let it finish before
|
||||
// claiming the shared timer (a same-instance send remains unsupported here)
|
||||
this->wait_until_idle_();
|
||||
this->arm_chain_(send_times, send_wait);
|
||||
if (this->non_blocking_) {
|
||||
this->complete_pending_ = true;
|
||||
this->enable_loop();
|
||||
return;
|
||||
}
|
||||
// blocking mode: wait out the chain, bounded by the stall deadline
|
||||
while (this->transmitting_) {
|
||||
if ((int32_t) (millis() - s_expected_end_ms) > 0) {
|
||||
this->abort_stalled_chain_();
|
||||
break;
|
||||
}
|
||||
App.feed_wdt();
|
||||
delay(1);
|
||||
}
|
||||
this->deliver_completion_();
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::loop() {
|
||||
if (!this->complete_pending_) {
|
||||
this->disable_loop();
|
||||
return;
|
||||
}
|
||||
if (this->transmitting_) {
|
||||
// non-blocking stall recovery: without this, a dead chain would leave the carrier
|
||||
// driven and on_complete unfired until the next send happened to abort it
|
||||
if ((int32_t) (millis() - s_expected_end_ms) <= 0)
|
||||
return;
|
||||
this->abort_stalled_chain_();
|
||||
}
|
||||
// release the loop before user code runs: the automation may start a new non-blocking
|
||||
// send, and its enable_loop() must be the last writer or its completion would strand
|
||||
this->disable_loop();
|
||||
this->deliver_completion_();
|
||||
}
|
||||
|
||||
} // namespace esphome::remote_transmitter
|
||||
|
||||
#endif // USE_LIBRETINY_VARIANT_RTL8720C || REMOTE_TRANSMITTER_BK_PWM
|
||||
@@ -5,31 +5,42 @@
|
||||
// clang-tidy cannot parse the Realtek SDK headers pulled in via ArduinoPrivate.h
|
||||
#if defined(USE_RTL87XX) && !defined(CLANG_TIDY)
|
||||
|
||||
// ArduinoPrivate.h = Arduino.h + the SDK's mbed HAL (pwmout etc.) with the core's fixes for
|
||||
// ArduinoPrivate.h = Arduino.h + the SDK's mbed HAL (pwmout, gtimer) with the core's fixes for
|
||||
// type-name collisions between the two (e.g. PinMode)
|
||||
#include <ArduinoPrivate.h>
|
||||
#ifndef USE_LIBRETINY_VARIANT_RTL8720C
|
||||
#include <FreeRTOS.h>
|
||||
#include <task.h>
|
||||
#endif
|
||||
|
||||
namespace esphome::remote_transmitter {
|
||||
|
||||
static const char *const TAG = "remote_transmitter";
|
||||
|
||||
// The carrier is generated by the PWM peripheral instead of bit-banging the pin: software carrier
|
||||
// generation requires disabling interrupts for the whole frame, but this core's micros() is derived
|
||||
// from the FreeRTOS tick and freezes while interrupts are off, so the timing loop never advances and
|
||||
// the watchdog resets the chip. With hardware PWM, software only times the mark/space envelope and
|
||||
// interrupts can stay enabled.
|
||||
//
|
||||
// The PWM is driven through the SDK's pwmout HAL directly rather than the Arduino wiring layer:
|
||||
// changing the carrier frequency via the wiring requires a GPIO/PWM pin mode round-trip, which
|
||||
// use-after-frees the core's per-pin state (pinRemoveMode() frees without nulling) and corrupts the
|
||||
// heap. pwmout_period_us() changes the frequency with no mode transitions.
|
||||
// PWM peripheral carrier, envelope paced by a gtimer interrupt chain. Bit-banging would need
|
||||
// interrupts disabled for the whole frame, but this core's micros() derives from the FreeRTOS
|
||||
// tick and freezes then. The SDK pwmout HAL is driven directly: the Arduino wiring layer's
|
||||
// GPIO/PWM mode round-trip use-after-frees LibreTiny's per-pin state.
|
||||
|
||||
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
|
||||
static constexpr uint32_t ENVELOPE_TIMER_ID = TIMER6; // GTimer7
|
||||
|
||||
// One envelope timer for all instances: a second gtimer_init on the same id fails silently,
|
||||
// so the chain serializes them (remote_transmitter_libretiny_isr.cpp)
|
||||
// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
static uint8_t s_pwm_tick_sources[] = {GTimer1, GTimer2, GTimer3, GTimer4, GTimer5, GTimer6, 0xff};
|
||||
static gtimer_t s_envelope_timer;
|
||||
static bool s_envelope_timer_ready = false;
|
||||
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
static void IRAM_ATTR envelope_timer_isr(uint32_t arg) {
|
||||
reinterpret_cast<RemoteTransmitterComponent *>(arg)->advance_envelope_isr();
|
||||
}
|
||||
#endif // USE_LIBRETINY_VARIANT_RTL8720C
|
||||
|
||||
void RemoteTransmitterComponent::setup() {
|
||||
// Deliberately no pin_->setup(): registering the pin as GPIO claims it in the SDK's pin
|
||||
// management, and the pad is then never handed over to the PWM peripheral -- pwmout_init()
|
||||
// must own the pin from the start.
|
||||
// no pin_->setup(): a GPIO claim in the SDK's pin management blocks pwmout_init from
|
||||
// owning the pad
|
||||
PinInfo *info = pinInfo(this->pin_->get_pin());
|
||||
if (info == nullptr || !pinSupported(info, PIN_PWM)) {
|
||||
// checked here because the AmebaZ (RTL8710B) SDK does not report PWM init failure
|
||||
@@ -40,7 +51,7 @@ void RemoteTransmitterComponent::setup() {
|
||||
auto *pwm = new pwmout_t();
|
||||
this->pwm_ = pwm;
|
||||
pwmout_init(pwm, static_cast<PinName>(info->gpio));
|
||||
#if LT_RTL8720C
|
||||
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
|
||||
// only the AmebaZ2 SDK's pwmout_s reports init success
|
||||
if (!pwm->is_init) {
|
||||
ESP_LOGE(TAG, "PWM init failed on pin %u", this->pin_->get_pin());
|
||||
@@ -49,9 +60,19 @@ void RemoteTransmitterComponent::setup() {
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
// Shrink the PWM tick-source pool before the period claim below so GTimer7 stays free
|
||||
// for the envelope; pwmout_init just registered the full pool.
|
||||
hal_pwm_comm_tick_source_list(s_pwm_tick_sources);
|
||||
#endif
|
||||
pwmout_period_us(pwm, 26); // placeholder; the real carrier period is set per transmission
|
||||
pwmout_write(pwm, this->pin_->is_inverted() ? 1.0f : 0.0f);
|
||||
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
|
||||
if (!s_envelope_timer_ready) {
|
||||
gtimer_init(&s_envelope_timer, ENVELOPE_TIMER_ID);
|
||||
s_envelope_timer_ready = true;
|
||||
}
|
||||
this->disable_loop(); // loop() is only needed while a non-blocking completion is pending
|
||||
#endif
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::dump_config() {
|
||||
@@ -59,9 +80,59 @@ void RemoteTransmitterComponent::dump_config() {
|
||||
"Remote Transmitter:\n"
|
||||
" Carrier Duty: %u%%",
|
||||
this->carrier_duty_percent_);
|
||||
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
|
||||
ESP_LOGCONFIG(TAG, " Non-blocking: %s", YESNO(this->non_blocking_));
|
||||
#endif
|
||||
LOG_PIN(" Pin: ", this->pin_);
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::digital_write(bool value) {
|
||||
if (this->pwm_ == nullptr)
|
||||
return;
|
||||
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
|
||||
// serialize behind an in-flight chain, matching the ESP32/RMT non-blocking behavior
|
||||
this->wait_until_idle_();
|
||||
#endif
|
||||
pwmout_write(static_cast<pwmout_t *>(this->pwm_), (value != this->pin_->is_inverted()) ? 1.0f : 0.0f);
|
||||
}
|
||||
|
||||
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
|
||||
// --- envelope chain hooks (see remote_transmitter_libretiny_isr.cpp) ---
|
||||
|
||||
bool RemoteTransmitterComponent::envelope_ready_() const { return this->pwm_ != nullptr; }
|
||||
|
||||
// Retunes the PWM period when the carrier changes and stages the per-item duties;
|
||||
// unmodulated protocols (no carrier or 100% duty) drive the pin constantly during marks
|
||||
void RemoteTransmitterComponent::prepare_carrier_(uint32_t carrier_frequency) {
|
||||
float mark_duty =
|
||||
(carrier_frequency > 0 && this->carrier_duty_percent_ < 100) ? this->carrier_duty_percent_ / 100.0f : 1.0f;
|
||||
float space_duty = 0.0f;
|
||||
if (this->pin_->is_inverted()) {
|
||||
mark_duty = 1.0f - mark_duty;
|
||||
space_duty = 1.0f;
|
||||
}
|
||||
this->isr_mark_duty_ = mark_duty;
|
||||
this->isr_space_duty_ = space_duty;
|
||||
if (carrier_frequency == 0 || carrier_frequency == this->current_carrier_frequency_)
|
||||
return;
|
||||
// round(1000000/freq), clamped so a bad lambda can't hand the SDK a zero period
|
||||
const uint32_t period = std::max(uint32_t(1), (1000000UL + carrier_frequency / 2) / carrier_frequency);
|
||||
pwmout_period_us(static_cast<pwmout_t *>(this->pwm_), period);
|
||||
this->current_carrier_frequency_ = carrier_frequency;
|
||||
}
|
||||
|
||||
void IRAM_ATTR RemoteTransmitterComponent::write_envelope_level_(bool mark) {
|
||||
pwmout_write(static_cast<pwmout_t *>(this->pwm_), mark ? this->isr_mark_duty_ : this->isr_space_duty_);
|
||||
}
|
||||
|
||||
void IRAM_ATTR RemoteTransmitterComponent::arm_one_shot_(uint32_t duration_us) {
|
||||
gtimer_start_one_shout(&s_envelope_timer, duration_us, (void *) envelope_timer_isr, (uint32_t) this);
|
||||
}
|
||||
|
||||
void IRAM_ATTR RemoteTransmitterComponent::stop_envelope_timer_() { gtimer_stop(&s_envelope_timer); }
|
||||
|
||||
#else // !USE_LIBRETINY_VARIANT_RTL8720C -- AmebaZ (RTL8710B): spin-based envelope, per-frame priority boost
|
||||
|
||||
void RemoteTransmitterComponent::await_target_time_() {
|
||||
const uint32_t current_time = micros();
|
||||
if (this->target_time_ == 0) {
|
||||
@@ -72,15 +143,8 @@ void RemoteTransmitterComponent::await_target_time_() {
|
||||
}
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::digital_write(bool value) {
|
||||
if (this->pwm_ == nullptr)
|
||||
return;
|
||||
pwmout_write(static_cast<pwmout_t *>(this->pwm_), (value != this->pin_->is_inverted()) ? 1.0f : 0.0f);
|
||||
}
|
||||
|
||||
void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) {
|
||||
auto *pwm = static_cast<pwmout_t *>(this->pwm_);
|
||||
if (pwm == nullptr) {
|
||||
if (this->pwm_ == nullptr) {
|
||||
ESP_LOGW(TAG, "Cannot send: PWM not initialized");
|
||||
return;
|
||||
}
|
||||
@@ -94,6 +158,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen
|
||||
mark_duty = 1.0f - mark_duty;
|
||||
space_duty = 1.0f;
|
||||
}
|
||||
auto *pwm = static_cast<pwmout_t *>(this->pwm_);
|
||||
if (carrier_frequency > 0 && carrier_frequency != this->current_carrier_frequency_) {
|
||||
// round(1000000/freq), clamped like the bit-bang path so a bad lambda can't hand the SDK a zero period
|
||||
const uint32_t period = std::max(uint32_t(1), (1000000UL + carrier_frequency / 2) / carrier_frequency);
|
||||
@@ -132,6 +197,8 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen
|
||||
this->complete_trigger_.trigger();
|
||||
}
|
||||
|
||||
#endif // USE_LIBRETINY_VARIANT_RTL8720C
|
||||
|
||||
} // namespace esphome::remote_transmitter
|
||||
|
||||
#endif // USE_RTL87XX && !CLANG_TIDY
|
||||
|
||||
@@ -312,6 +312,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_BOARD, CONF_VARIANT),
|
||||
_detect_variant,
|
||||
cv.require_platformio_toolchain("RP2"),
|
||||
set_core_data,
|
||||
)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from esphome.components.image import (
|
||||
validate_transparency,
|
||||
validate_type,
|
||||
)
|
||||
from esphome.config_helpers import filter_source_files_from_defines
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_FORMAT, CONF_ID, CONF_RESIZE, CONF_TYPE
|
||||
from esphome.core import CORE
|
||||
@@ -124,6 +125,15 @@ IMAGE_FORMATS = {
|
||||
"PNG": PNGFormat(),
|
||||
}
|
||||
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_defines(
|
||||
{
|
||||
"bmp_decoder.cpp": "USE_RUNTIME_IMAGE_BMP",
|
||||
"jpeg_decoder.cpp": "USE_RUNTIME_IMAGE_JPEG",
|
||||
"png_decoder.cpp": "USE_RUNTIME_IMAGE_PNG",
|
||||
"qoi_decoder.cpp": "USE_RUNTIME_IMAGE_QOI",
|
||||
}
|
||||
)
|
||||
|
||||
AUTO_FORMAT = AUTOFormat()
|
||||
|
||||
|
||||
|
||||
@@ -80,6 +80,10 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) {
|
||||
|
||||
this->width_ = encode_uint32(buffer[21], buffer[20], buffer[19], buffer[18]);
|
||||
this->height_ = encode_uint32(buffer[25], buffer[24], buffer[23], buffer[22]);
|
||||
if (this->width_ <= 0 || this->height_ <= 0) {
|
||||
ESP_LOGE(TAG, "Invalid image dimensions: (%zdx%zd)", this->width_, this->height_);
|
||||
return DECODE_ERROR_UNSUPPORTED_FORMAT;
|
||||
}
|
||||
this->bits_per_pixel_ = encode_uint16(buffer[29], buffer[28]);
|
||||
this->compression_method_ = encode_uint32(buffer[33], buffer[32], buffer[31], buffer[30]);
|
||||
this->image_data_size_ = encode_uint32(buffer[37], buffer[36], buffer[35], buffer[34]);
|
||||
|
||||
@@ -178,7 +178,8 @@ static int __attribute__((noinline)) days_from_year_start(int year, int month, i
|
||||
}
|
||||
|
||||
time_t __attribute__((noinline)) calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offset_seconds) {
|
||||
int month, day;
|
||||
int month = 1;
|
||||
int day = 1;
|
||||
|
||||
switch (rule.type) {
|
||||
case DSTRuleType::MONTH_WEEK_DAY: {
|
||||
|
||||
@@ -533,7 +533,7 @@ void WiFiComponent::log_discarded_scan_result_(const char *ssid, const uint8_t *
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
// Skip logging during roaming scans to avoid log buffer overflow
|
||||
// (roaming scans typically find many networks but only care about same-SSID APs)
|
||||
if (this->roaming_state_ == RoamingState::SCANNING) {
|
||||
if (this->is_roaming_scan_active()) {
|
||||
return;
|
||||
}
|
||||
char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
@@ -836,7 +836,7 @@ void WiFiComponent::loop() {
|
||||
|
||||
// Post-connect roaming: check for better AP
|
||||
if (this->post_connect_roaming_) {
|
||||
if (this->roaming_state_ == RoamingState::SCANNING) {
|
||||
if (this->is_roaming_scan_active()) {
|
||||
if (this->scan_done_) {
|
||||
this->process_roaming_scan_();
|
||||
}
|
||||
@@ -2143,7 +2143,7 @@ void WiFiComponent::retry_connect() {
|
||||
// Roam connection failed - transition to reconnecting
|
||||
ESP_LOGD(TAG, "Roam failed, reconnecting (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
|
||||
this->roaming_state_ = RoamingState::RECONNECTING;
|
||||
} else if (this->roaming_state_ == RoamingState::SCANNING) {
|
||||
} else if (this->is_roaming_scan_active()) {
|
||||
// Disconnected during roam scan - transition to RECONNECTING so the attempts
|
||||
// counter is preserved when reconnection succeeds (IDLE would reset it)
|
||||
ESP_LOGD(TAG, "Disconnected during roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
|
||||
|
||||
@@ -478,6 +478,13 @@ class WiFiComponent final : public Component {
|
||||
|
||||
bool is_connected() const { return this->connected_; }
|
||||
|
||||
/// True while a post-connect roaming scan holds the radio off-channel.
|
||||
bool is_roaming_scan_active() const { return this->roaming_state_ == RoamingState::SCANNING; }
|
||||
|
||||
/// True while a post-connect roam is in progress (scanning off-channel, reassociating,
|
||||
/// or recovering from a failed roam).
|
||||
bool is_roaming() const { return this->roaming_state_ != RoamingState::IDLE; }
|
||||
|
||||
#ifdef USE_ESP32
|
||||
/// esp_netif handle of the station interface, used by network for default-route
|
||||
/// arbitration. nullptr until wifi_lazy_init_() has run.
|
||||
|
||||
@@ -717,7 +717,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
|
||||
static constexpr uint32_t SCAN_ACTIVE_MAX_DEFAULT_MS = 500;
|
||||
static constexpr uint32_t SCAN_ACTIVE_MIN_ROAMING_MS = 100;
|
||||
static constexpr uint32_t SCAN_ACTIVE_MAX_ROAMING_MS = 300;
|
||||
bool roaming = this->roaming_state_ == RoamingState::SCANNING;
|
||||
bool roaming = this->is_roaming_scan_active();
|
||||
if (passive) {
|
||||
config.scan_time.passive = roaming ? SCAN_PASSIVE_ROAMING_MS : SCAN_PASSIVE_DEFAULT_MS;
|
||||
} else {
|
||||
|
||||
@@ -140,11 +140,6 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi
|
||||
}
|
||||
|
||||
void WiFiComponent::wifi_pre_setup_() {
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
if (has_custom_mac_address()) {
|
||||
get_mac_address_raw(mac);
|
||||
set_mac_address(mac);
|
||||
}
|
||||
// Network interface setup handled by network component
|
||||
s_wifi_event_group = xEventGroupCreate();
|
||||
if (s_wifi_event_group == nullptr) {
|
||||
@@ -1064,7 +1059,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
|
||||
// When scanning while connected (roaming), return to home channel between
|
||||
// each scanned channel to maintain the connection (helps with BLE/WiFi coexistence)
|
||||
#ifdef CONFIG_SOC_WIFI_SUPPORTED
|
||||
if (this->roaming_state_ == RoamingState::SCANNING) {
|
||||
if (this->is_roaming_scan_active()) {
|
||||
config.coex_background_scan = true;
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -53,6 +53,7 @@ from esphome.const import (
|
||||
CONF_SETUP_PRIORITY,
|
||||
CONF_STATE_TOPIC,
|
||||
CONF_SUBSCRIBE_QOS,
|
||||
CONF_TOOLCHAIN,
|
||||
CONF_TOPIC,
|
||||
CONF_TYPE,
|
||||
CONF_TYPE_ID,
|
||||
@@ -75,6 +76,7 @@ from esphome.const import (
|
||||
TYPE_GIT,
|
||||
TYPE_LOCAL,
|
||||
Framework,
|
||||
Toolchain,
|
||||
__version__ as ESPHOME_VERSION,
|
||||
)
|
||||
from esphome.core import (
|
||||
@@ -91,7 +93,13 @@ from esphome.core import (
|
||||
)
|
||||
from esphome.enum import StrEnum
|
||||
from esphome.expression import SUBSTITUTION_VARIABLE_PROG as VARIABLE_PROG
|
||||
from esphome.helpers import add_class_to_obj, docs_url, list_starts_with
|
||||
from esphome.helpers import (
|
||||
FALSY_BOOL_STRINGS,
|
||||
TRUTHY_BOOL_STRINGS,
|
||||
add_class_to_obj,
|
||||
docs_url,
|
||||
list_starts_with,
|
||||
)
|
||||
from esphome.schema_extractors import (
|
||||
SCHEMA_EXTRACT,
|
||||
schema_extractor,
|
||||
@@ -106,6 +114,9 @@ from esphome.util import parse_esphome_version # noqa: F401
|
||||
from esphome.voluptuous_schema import _Schema
|
||||
from esphome.yaml_util import SensitiveStr, make_data_base
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@@ -576,9 +587,9 @@ def boolean(value):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
value = value.lower()
|
||||
if value in ("true", "yes", "on", "enable"):
|
||||
if value in TRUTHY_BOOL_STRINGS:
|
||||
return True
|
||||
if value in ("false", "no", "off", "disable"):
|
||||
if value in FALSY_BOOL_STRINGS:
|
||||
return False
|
||||
raise Invalid(
|
||||
f"Expected boolean value, but cannot convert {value} to a boolean. Please use 'true' or 'false'"
|
||||
@@ -1871,13 +1882,46 @@ def lambda_(value):
|
||||
return value
|
||||
|
||||
|
||||
# 'return' at a statement boundary; only consulted when the source has no
|
||||
# semicolon, so ';' is not a boundary. Migration use only, see
|
||||
# looks_like_returning_lambda.
|
||||
LAMBDA_RETURN_STATEMENT_PROG = re.compile(r"(?:^|[:{})\n])\s*return\b")
|
||||
LAMBDA_RETURN_KEYWORD_PROG = re.compile(r"\breturn\b")
|
||||
# RESERVED_IDS subset that can begin a return expression; 'this'/'true' would
|
||||
# promote prose and infix 'and'/'or' cannot start an expression.
|
||||
_CPP_LEADING_WORD_OPERATORS = "not|new|sizeof|delete"
|
||||
# Two or more plain words: prose, not C++. A single word is indistinguishable
|
||||
# from 'return x'. Migration use only, see looks_like_returning_lambda.
|
||||
LAMBDA_PROSE_TAIL_PROG = re.compile(
|
||||
rf"(?!(?:{_CPP_LEADING_WORD_OPERATORS})\b)[A-Za-z']+(?:,?\s+[A-Za-z']+)+[.!?]?"
|
||||
)
|
||||
|
||||
|
||||
def looks_like_returning_lambda(value: str) -> bool:
|
||||
"""Check whether a string looks like C++ lambda source: a semicolon means
|
||||
code, so any return keyword counts; without one, a boundary return whose
|
||||
tail does not read as prose is a return statement missing its semicolon.
|
||||
|
||||
For migrating deprecated implicit lambdas only; new validators must
|
||||
require an explicit !lambda tag instead of guessing.
|
||||
"""
|
||||
src = Lambda.comment_remover(value)
|
||||
if ";" in src:
|
||||
return LAMBDA_RETURN_KEYWORD_PROG.search(src) is not None
|
||||
for match in LAMBDA_RETURN_STATEMENT_PROG.finditer(src):
|
||||
tail = src[match.end() :].split("\n", 1)[0].strip()
|
||||
if not LAMBDA_PROSE_TAIL_PROG.fullmatch(tail):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def returning_lambda(value):
|
||||
"""Coerce this configuration option to a lambda.
|
||||
|
||||
Additionally, make sure the lambda returns something.
|
||||
"""
|
||||
value = lambda_(value)
|
||||
if "return" not in value.value:
|
||||
if LAMBDA_RETURN_KEYWORD_PROG.search(Lambda.comment_remover(value.value)) is None:
|
||||
raise Invalid(
|
||||
"Lambda doesn't contain a 'return' statement, but the lambda "
|
||||
"is expected to return a value. \n"
|
||||
@@ -2532,6 +2576,63 @@ def platformio_version_constraint(value):
|
||||
return constraints
|
||||
|
||||
|
||||
def _check_supported_toolchain(
|
||||
platform_name: str, supported: tuple[Toolchain, ...]
|
||||
) -> None:
|
||||
"""Raise when the resolved ``CORE.toolchain`` is not in ``supported``
|
||||
(one message shape for every platform)."""
|
||||
toolchain = CORE.toolchain
|
||||
if toolchain is None:
|
||||
# A caller ran the check before resolving; an ordering bug, not a
|
||||
# user error
|
||||
raise Invalid(f"Toolchain was not resolved before {platform_name} validation")
|
||||
if toolchain not in supported:
|
||||
names = ", ".join(f"'{tc.value}'" for tc in supported)
|
||||
raise Invalid(
|
||||
f"Unsupported toolchain "
|
||||
f"'{toolchain.value}' for "
|
||||
f"{platform_name}. Supported: {names}."
|
||||
)
|
||||
|
||||
|
||||
def toolchain_enum(supported: tuple[Toolchain, ...]) -> Callable[[str], Toolchain]:
|
||||
"""Schema validator for a platform's ``toolchain`` config key."""
|
||||
|
||||
def validator(value: str) -> Toolchain:
|
||||
return Toolchain(one_of(*supported, lower=True)(value))
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def resolve_toolchain(
|
||||
platform_name: str, supported: tuple[Toolchain, ...], default: Toolchain
|
||||
) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Resolve ``CORE.toolchain`` (CLI > YAML > default) and reject one the
|
||||
platform cannot serve.
|
||||
|
||||
Add to the platform's validation chain before anything that reads
|
||||
``CORE.toolchain``.
|
||||
"""
|
||||
|
||||
def validator(config: ConfigType) -> ConfigType:
|
||||
if CORE.toolchain is None:
|
||||
CORE.toolchain = config.get(CONF_TOOLCHAIN, default)
|
||||
_check_supported_toolchain(platform_name, supported)
|
||||
return config
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def require_platformio_toolchain(
|
||||
platform_name: str,
|
||||
) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Reject a CLI-selected toolchain other than PlatformIO, for platforms
|
||||
with only the PlatformIO backend."""
|
||||
return resolve_toolchain(
|
||||
platform_name, (Toolchain.PLATFORMIO,), Toolchain.PLATFORMIO
|
||||
)
|
||||
|
||||
|
||||
def require_framework_version(
|
||||
*,
|
||||
max_version=False,
|
||||
|
||||
@@ -21,6 +21,14 @@ class Toolchain(StrEnum):
|
||||
PLATFORMIO = "platformio"
|
||||
ESP_IDF = "esp-idf"
|
||||
SDK_NRF = "sdk-nrf"
|
||||
# ESP8266: the Arduino core built directly (no PlatformIO)
|
||||
ARDUINO = "arduino"
|
||||
|
||||
|
||||
# Toolchains that drive their build natively and never read platformio.ini.
|
||||
# SDK_NRF is absent on purpose: the zephyr backend keeps consuming
|
||||
# platformio_options.
|
||||
NATIVE_TOOLCHAINS = frozenset({Toolchain.ESP_IDF, Toolchain.ARDUINO})
|
||||
|
||||
|
||||
class Platform(StrEnum):
|
||||
|
||||
@@ -21,6 +21,7 @@ from esphome.const import (
|
||||
KEY_CORE,
|
||||
KEY_TARGET_FRAMEWORK,
|
||||
KEY_TARGET_PLATFORM,
|
||||
NATIVE_TOOLCHAINS,
|
||||
PLATFORM_BK72XX,
|
||||
PLATFORM_ESP32,
|
||||
PLATFORM_ESP8266,
|
||||
@@ -338,7 +339,8 @@ class Lambda:
|
||||
self._requires_ids = None
|
||||
|
||||
# https://stackoverflow.com/a/241506/229052
|
||||
def comment_remover(self, text):
|
||||
@staticmethod
|
||||
def comment_remover(text):
|
||||
def replacer(match):
|
||||
s = match.group(0)
|
||||
if s.startswith("/"):
|
||||
@@ -982,6 +984,19 @@ class EsphomeCore:
|
||||
def using_toolchain_sdk_nrf(self):
|
||||
return self.toolchain == Toolchain.SDK_NRF
|
||||
|
||||
@property
|
||||
def using_toolchain_arduino(self):
|
||||
"""The native ESP8266 Arduino build toolchain (unlike
|
||||
``using_arduino``, which is the target framework)."""
|
||||
return self.toolchain == Toolchain.ARDUINO
|
||||
|
||||
@property
|
||||
def using_native_toolchain(self):
|
||||
"""Whether the selected toolchain builds natively, without reading
|
||||
``platformio.ini`` (see ``NATIVE_TOOLCHAINS`` in ``esphome.const``;
|
||||
keep its membership in sync with ``write_cpp_file``'s dispatch)."""
|
||||
return self.toolchain in NATIVE_TOOLCHAINS
|
||||
|
||||
@property
|
||||
def using_zephyr(self):
|
||||
return self.target_framework == "zephyr"
|
||||
@@ -1095,6 +1110,8 @@ class EsphomeCore:
|
||||
return build_flag
|
||||
|
||||
def add_build_unflag(self, build_unflag: str) -> None:
|
||||
# No warning for using_toolchain_arduino: the native ESP8266 build
|
||||
# honors build_unflags (token-level, matching PlatformIO).
|
||||
if self.using_toolchain_esp_idf:
|
||||
# The native ESP-IDF build generator does not consume build_unflags
|
||||
_LOGGER.warning(
|
||||
|
||||
+40
-10
@@ -555,12 +555,24 @@ def _add_library_str(lib: str) -> None:
|
||||
cg.add_library(lib, None)
|
||||
|
||||
|
||||
# platformio_options keys the native ESP8266 Arduino generator (a later PR
|
||||
# in this chain) will honor; its ignored-option warning will consume the same
|
||||
# list so the two cannot drift
|
||||
NATIVE_ARDUINO_PIO_OPTIONS = frozenset({"board_build.f_cpu", "board_build.ldscript"})
|
||||
# The full set that survives into CORE.platformio_options under the native
|
||||
# arduino toolchain: lib_ignore is the only specially-translated key below
|
||||
# that is stored rather than translated away. Consumed by the esp8266 native
|
||||
# backend (later in this chain) for its ignored-option warning; defined here
|
||||
# so it stays adjacent to the routing.
|
||||
NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS = NATIVE_ARDUINO_PIO_OPTIONS | {"lib_ignore"}
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> None:
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
# The native ESP-IDF build doesn't read platformio.ini; honor the
|
||||
# options with a native equivalent and warn about the rest, which
|
||||
# would otherwise be silently ignored.
|
||||
if CORE.using_native_toolchain:
|
||||
# The native builds don't read platformio.ini; honor the options
|
||||
# with a native equivalent and warn about the rest, which would
|
||||
# otherwise be silently ignored.
|
||||
for key, val in pio_options.items():
|
||||
vals = [val] if isinstance(val, str) else val
|
||||
if key == CONF_BUILD_FLAGS:
|
||||
@@ -573,23 +585,41 @@ async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> No
|
||||
)
|
||||
for flag in vals:
|
||||
cg.add_build_flag(flag)
|
||||
elif key == "build_unflags":
|
||||
# Native equivalent: add_build_unflag (honored token-level by
|
||||
# the arduino generator; the IDF generator warns there)
|
||||
for flag in vals:
|
||||
CORE.add_build_unflag(flag)
|
||||
elif key == "lib_deps":
|
||||
# Routed through the regular library mechanism so the libraries
|
||||
# are converted to IDF components like any other PIO library
|
||||
# Routed through the regular library mechanism so the
|
||||
# libraries reach the native backend's converter (IDF
|
||||
# components, or the ESP8266 native library resolution)
|
||||
for lib in vals:
|
||||
_add_library_str(lib)
|
||||
elif key == "lib_ignore":
|
||||
# Read by the PIO-library-to-IDF-component conversion
|
||||
# (generate_idf_components); filters both top-level libraries
|
||||
# and dependencies discovered during conversion
|
||||
# Read by the shared library conversion (lib_ignore_set in
|
||||
# platformio/library.py); filters top-level libraries and
|
||||
# discovered dependencies
|
||||
cg.add_platformio_option(key, vals)
|
||||
elif (
|
||||
key in NATIVE_ARDUINO_PIO_OPTIONS
|
||||
and CORE.using_toolchain_arduino
|
||||
and vals
|
||||
):
|
||||
# The esp8266 native generator reads these as scalars; the
|
||||
# schema also permits the list form, where the last value
|
||||
# wins like a later platformio.ini line (an empty list falls
|
||||
# through to the ignored-option warning). Other native
|
||||
# toolchains have no equivalent and fall through too.
|
||||
cg.add_platformio_option(key, vals[-1])
|
||||
elif key != "upload_speed":
|
||||
# upload_speed needs no handling: it is read from the raw
|
||||
# config at upload time (upload_using_esptool)
|
||||
_LOGGER.warning(
|
||||
"esphome->platformio_options->%s is ignored when building with "
|
||||
"the native ESP-IDF toolchain",
|
||||
"the native '%s' toolchain",
|
||||
key,
|
||||
CORE.toolchain.value,
|
||||
)
|
||||
return
|
||||
# Add includes at the very end, so that they override everything
|
||||
|
||||
@@ -2089,6 +2089,11 @@ const char *get_mac_address_pretty_into_buffer(std::span<char, MAC_ADDRESS_PRETT
|
||||
#ifdef USE_ESP32
|
||||
/// Set the MAC address to use from the provided byte array (6 bytes).
|
||||
void set_mac_address(uint8_t *mac);
|
||||
|
||||
/// Read the custom MAC address from eFuse into the provided byte array (6 bytes).
|
||||
/// Must not use the ESPHome logger (may run before it is initialized); IDF itself may still log.
|
||||
/// @return True if a valid custom MAC address was read; on false, the contents of mac are undefined.
|
||||
bool get_custom_mac_address(uint8_t *mac);
|
||||
#endif
|
||||
|
||||
/// Check if a custom MAC address is set (ESP32 & variants)
|
||||
|
||||
+54
-48
@@ -12,9 +12,13 @@ import re
|
||||
import shutil
|
||||
from typing import Any, NoReturn
|
||||
|
||||
import platformdirs
|
||||
|
||||
from esphome.core import CORE, Version
|
||||
from esphome.build_helpers.ccache import (
|
||||
ccache_defaults_env,
|
||||
parse_enable_env,
|
||||
resolve_ccache_path,
|
||||
)
|
||||
from esphome.build_helpers.tools_cache import IDF_TOOLS_CACHE, tools_cache_path
|
||||
from esphome.core import Version
|
||||
from esphome.framework_helpers import (
|
||||
PathType,
|
||||
create_venv,
|
||||
@@ -29,8 +33,9 @@ from esphome.framework_helpers import (
|
||||
run_command,
|
||||
run_command_ok,
|
||||
str_to_lst_of_str,
|
||||
tool_version_runs,
|
||||
)
|
||||
from esphome.helpers import get_bool_env, get_str_env, write_file_if_changed
|
||||
from esphome.helpers import write_file_if_changed
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -91,22 +96,10 @@ def get_idf_tools_path() -> Path:
|
||||
Returns:
|
||||
Path object pointing to the ESP-IDF tools directory
|
||||
"""
|
||||
# Treat an empty/whitespace ESPHOME_ESP_IDF_PREFIX as unset: Path("")
|
||||
# resolves to the CWD, which would install into (and let clean-all delete)
|
||||
# the working directory by accident.
|
||||
if prefix := get_str_env("ESPHOME_ESP_IDF_PREFIX", "").strip():
|
||||
path = Path(prefix).expanduser()
|
||||
else:
|
||||
# Machine-global so all projects share the multi-GB install instead of
|
||||
# a per-config-directory copy. The user cache dir (not ~/.esphome)
|
||||
# avoids colliding with data_dir when configs live in the home dir.
|
||||
# appauthor=False drops the redundant <author>\ segment on Windows
|
||||
# (which otherwise repeats "esphome\esphome\") to keep the path short.
|
||||
path = Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf"
|
||||
# Resolve so an unnormalized config path (e.g. compiling ``../config/x.yaml``)
|
||||
# doesn't leave ``..`` segments in the IDF_TOOLS_PATH handed to idf.py, which
|
||||
# otherwise warns that the venv interpreter path doesn't match the install.
|
||||
return path.resolve()
|
||||
# Machine-global so all projects share the multi-GB install instead of
|
||||
# a per-config-directory copy; see build_helpers.tools_cache.tools_cache_path
|
||||
# for the env-override and normalization rules.
|
||||
return tools_cache_path(*IDF_TOOLS_CACHE)
|
||||
|
||||
|
||||
# Windows' default MAX_PATH is 260 characters. ESP-IDF toolchains nest deeply
|
||||
@@ -1190,8 +1183,10 @@ def check_esp_idf_install(
|
||||
def _ccache_env() -> dict[str, str]:
|
||||
"""Return ccache settings for ESP-IDF compiles.
|
||||
|
||||
Enabled by default whenever the ``ccache`` binary is on PATH; set
|
||||
``IDF_CCACHE_ENABLE=0`` in the environment to opt out. The cache lives under
|
||||
Enabled by default whenever a runnable ``ccache`` binary is on PATH.
|
||||
``IDF_CCACHE_ENABLE=0`` opts out and ``=1`` forces it on; when that knob
|
||||
is unset the shared ``ESPHOME_CCACHE_ENABLE`` applies (same 0/1 forms,
|
||||
unrecognized values warn and count as unset). The cache lives under
|
||||
the IDF tools path (the machine-global cache dir, or
|
||||
``ESPHOME_ESP_IDF_PREFIX``), so it is shared across all projects and removed
|
||||
by ``esphome clean-all`` along with the framework.
|
||||
@@ -1206,33 +1201,44 @@ def _ccache_env() -> dict[str, str]:
|
||||
Only values the user has not already set in the environment are returned, so
|
||||
a custom ``CCACHE_DIR`` / ``CCACHE_MAXSIZE`` / etc. is respected.
|
||||
"""
|
||||
# Honor an explicit choice already in the environment (opt-out or opt-in).
|
||||
if "IDF_CCACHE_ENABLE" in os.environ:
|
||||
if not get_bool_env("IDF_CCACHE_ENABLE"):
|
||||
return {}
|
||||
elif shutil.which("ccache") is None:
|
||||
# ESP-IDF silently skips ccache without the binary; don't enable it.
|
||||
return {}
|
||||
# IDF_CCACHE_ENABLE (the backend-native knob) wins over the shared
|
||||
# ESPHOME_CCACHE_ENABLE.
|
||||
idf_knob = parse_enable_env("IDF_CCACHE_ENABLE")
|
||||
if idf_knob is False:
|
||||
# The raw value (e.g. "disable") is still inherited by idf.py via
|
||||
# os.environ, where a non-false-constant string reads as truthy;
|
||||
# export the canonical off spelling instead
|
||||
return {"IDF_CCACHE_ENABLE": "0"}
|
||||
if idf_knob is True:
|
||||
# Forced on ignores the runnability verdict, but the outcome is
|
||||
# worth saying out loud. Probed directly (not via the resolver,
|
||||
# whose failure message says "compiling without ccache" -- exactly
|
||||
# what forced-on does NOT do): only the truly-missing case means
|
||||
# idf.py compiles without ccache; a broken binary is still used,
|
||||
# since idf.py does its own PATH lookup.
|
||||
if (ccache := shutil.which("ccache")) is None:
|
||||
_LOGGER.warning(
|
||||
"IDF_CCACHE_ENABLE=1 but no ccache binary is on PATH; "
|
||||
"idf.py will compile without ccache"
|
||||
)
|
||||
else:
|
||||
# The probe warns with this message iff the binary fails
|
||||
tool_version_runs(
|
||||
ccache,
|
||||
"IDF_CCACHE_ENABLE=1 forces on the ccache at %s even though "
|
||||
"it failed to run; idf.py will use it anyway",
|
||||
)
|
||||
elif resolve_ccache_path() is None:
|
||||
# ESP-IDF silently skips ccache without the binary; export the
|
||||
# canonical off spelling so an unparsable inherited value (or a
|
||||
# probe-rejected ccache idf.py would still find) cannot enable it
|
||||
return {"IDF_CCACHE_ENABLE": "0"}
|
||||
|
||||
# ccache is enabled past here. build_path is set during preload for every
|
||||
# config-loading command, so it being unset means a caller built the IDF env
|
||||
# too early -- fail loudly rather than silently drop CCACHE_BASEDIR (which
|
||||
# would quietly cost cross-device cache hits).
|
||||
if CORE.build_path is None:
|
||||
raise ValueError(
|
||||
"CORE.build_path must be set before constructing the ESP-IDF build "
|
||||
"environment"
|
||||
)
|
||||
|
||||
defaults = {
|
||||
"IDF_CCACHE_ENABLE": "1",
|
||||
"CCACHE_DIR": str(get_idf_tools_path() / "ccache"),
|
||||
"CCACHE_NOHASHDIR": "true",
|
||||
"CCACHE_DEPEND": "1",
|
||||
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
|
||||
}
|
||||
# Don't override CCACHE_* values the user already set in their environment.
|
||||
return {k: v for k, v in defaults.items() if k not in os.environ}
|
||||
env = ccache_defaults_env(get_idf_tools_path() / "ccache")
|
||||
# Exactly one canonical spelling ever reaches idf.py, whatever the
|
||||
# accepted input spelling was ("enable", "yes", ...)
|
||||
env["IDF_CCACHE_ENABLE"] = "1"
|
||||
return env
|
||||
|
||||
|
||||
def get_framework_env(
|
||||
|
||||
@@ -204,6 +204,30 @@ def run_command(
|
||||
return False, None, None
|
||||
|
||||
|
||||
def tool_version_runs(binary: str, warning: str) -> bool:
|
||||
"""Probe ``binary --version``; on failure warn with ``warning`` % binary.
|
||||
|
||||
``shutil.which`` proves existence, not runnability (Windows .bat/.cmd
|
||||
shims, stale package-manager shims).
|
||||
"""
|
||||
try:
|
||||
subprocess.run(
|
||||
[binary, "--version"],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=15,
|
||||
# Repo-wide convention (posix_spawn fast path)
|
||||
close_fds=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as err:
|
||||
# The cause (permission denied, missing DLL, timeout) is the one
|
||||
# detail the user needs to fix it
|
||||
_LOGGER.warning("%s (%s)", warning % binary, err)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def run_command_ok(*args, **kwargs) -> bool:
|
||||
"""
|
||||
Execute a command and return only the success status.
|
||||
@@ -1284,3 +1308,37 @@ def download_from_mirrors(
|
||||
f"No mirror URL template matched the provided substitutions:{details}"
|
||||
)
|
||||
raise ValueError("download_from_mirrors called with an empty mirrors list")
|
||||
|
||||
|
||||
def strip_win_long_path_prefix(path: str) -> str:
|
||||
r"""Strip the Windows extended-length path prefix from ``path``.
|
||||
|
||||
Handles both forms documented at
|
||||
https://learn.microsoft.com/windows/win32/fileio/naming-a-file:
|
||||
|
||||
* ``\\?\C:\path\to\file`` -> ``C:\path\to\file``
|
||||
* ``\\?\UNC\server\share\path`` -> ``\\server\share\path``
|
||||
|
||||
The NSIS-installed ``esphome.exe`` launcher on Windows starts Python with
|
||||
``sys.executable`` already prefixed with ``\\?\``. That prefix propagates
|
||||
into PlatformIO's ``$PYTHONEXE`` (PlatformIO reads ``PYTHONEXEPATH`` from
|
||||
the environment, falling back to ``os.path.normpath(sys.executable)``)
|
||||
and ends up baked into SCons-emitted command lines for build steps such
|
||||
as the esp8266 ``elf2bin`` invocation. ``cmd.exe`` does not understand
|
||||
the ``\\?\`` prefix, so the build fails with
|
||||
"The system cannot find the path specified." Stripping the prefix early
|
||||
keeps the path shell-quotable.
|
||||
|
||||
Also applied to the ccache path exported by the ccache helpers, which
|
||||
``shutil.which`` can return with the same prefix.
|
||||
|
||||
No-op on non-Windows platforms.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return path
|
||||
if path.startswith("\\\\?\\UNC\\"):
|
||||
# \\?\UNC\server\share\... -> \\server\share\...
|
||||
return "\\\\" + path[len("\\\\?\\UNC\\") :]
|
||||
if path.startswith("\\\\?\\"):
|
||||
return path[len("\\\\?\\") :]
|
||||
return path
|
||||
|
||||
+2
-1
@@ -31,7 +31,8 @@ SockAddr = IPv4SockAddr | IPv6SockAddr
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# cv.boolean's closed spelling tables, shared with the env-knob parsing below
|
||||
# cv.boolean's closed spelling tables, shared with the strict env-knob
|
||||
# parser (build_helpers.ccache.parse_enable_env)
|
||||
TRUTHY_BOOL_STRINGS = frozenset({"true", "yes", "on", "enable"})
|
||||
FALSY_BOOL_STRINGS = frozenset({"false", "no", "off", "disable"})
|
||||
# cv.boolean's spelling tables plus the 1/0 env convention
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
"""Install packages from the PlatformIO registry without importing the
|
||||
platformio package (identical bits, esphome's own download machinery)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Collection
|
||||
from functools import cache, partial
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
from typing import NamedTuple
|
||||
|
||||
from esphome.core import EsphomeError
|
||||
from esphome.framework_helpers import (
|
||||
archive_extract_all,
|
||||
download_from_mirrors,
|
||||
download_with_resume,
|
||||
rmdir,
|
||||
run_batch_downloads,
|
||||
)
|
||||
from esphome.net_retry import fetch_with_retry, http_request
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_REGISTRY_URL = (
|
||||
"https://api.registry.platformio.org/v3/packages/platformio/tool/{package}"
|
||||
)
|
||||
|
||||
|
||||
def get_systype() -> str:
|
||||
"""The registry system tag for the current host.
|
||||
|
||||
Transliterates ``platformio.util.get_systype()`` (same
|
||||
``PLATFORMIO_SYSTEM_TYPE`` override). Deviation: windows-arm64 maps to
|
||||
``windows_amd64`` (no arm64 toolchains; x86 emulation).
|
||||
"""
|
||||
if systype := os.environ.get("PLATFORMIO_SYSTEM_TYPE"):
|
||||
return systype
|
||||
system = platform.system().lower()
|
||||
arch = platform.machine().lower()
|
||||
if system == "windows":
|
||||
if not arch: # same fallback as upstream (platformio issue #4353)
|
||||
arch = "x86_" + platform.architecture()[0]
|
||||
if "x86" in arch:
|
||||
arch = "amd64" if "64" in arch else "x86"
|
||||
elif arch == "arm64":
|
||||
arch = "amd64"
|
||||
if arch == "aarch64" and platform.architecture()[0] == "32bit":
|
||||
# 64-bit kernel with a 32-bit userland (e.g. 32-bit Raspberry Pi OS)
|
||||
arch = "armv7l"
|
||||
return f"{system}_{arch}" if arch else system
|
||||
|
||||
|
||||
@cache
|
||||
def registry_download(package: str, version: str) -> tuple[str, str, int | None]:
|
||||
"""Resolve a package's download URL, sha256, and size via the registry.
|
||||
|
||||
The metadata fetch goes through ``http_request``/``fetch_with_retry``
|
||||
(the consolidated HTTP path) so it shares the Happy Eyeballs patch and
|
||||
transient-retry policy of every other small fetch. Cached per process
|
||||
so the prefetch and the install resolve each package once (failures
|
||||
are not cached; the install retries them).
|
||||
"""
|
||||
url = _REGISTRY_URL.format(package=package)
|
||||
|
||||
def _fetch() -> str:
|
||||
resp = http_request("GET", url, timeout=30)
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
|
||||
import requests
|
||||
|
||||
try:
|
||||
body = fetch_with_retry(url, _fetch, what="Registry lookup")
|
||||
except requests.exceptions.RequestException as err:
|
||||
raise EsphomeError(
|
||||
f"Could not fetch registry metadata for {package}: {err}"
|
||||
) from err
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except ValueError as err:
|
||||
raise EsphomeError(
|
||||
f"The package registry returned invalid JSON for {package}: {err}"
|
||||
) from err
|
||||
if not isinstance(data, dict):
|
||||
raise EsphomeError(
|
||||
f"Unexpected package registry response for {package}: {str(data)[:200]}"
|
||||
)
|
||||
systype = get_systype()
|
||||
versions = data.get("versions")
|
||||
if not isinstance(versions, list):
|
||||
# A schema change or an error/captive-portal payload must not be
|
||||
# reported as "version not found"
|
||||
raise EsphomeError(
|
||||
f"Unexpected package registry response for {package}: {str(data)[:200]}"
|
||||
)
|
||||
for ver in versions:
|
||||
if not isinstance(ver, dict):
|
||||
raise EsphomeError(
|
||||
f"Unexpected package registry response for {package}: {str(data)[:200]}"
|
||||
)
|
||||
if ver.get("name") != version:
|
||||
continue
|
||||
files = ver.get("files")
|
||||
if not isinstance(files, list):
|
||||
raise EsphomeError(
|
||||
f"Unexpected package registry response for {package}: {str(ver)[:200]}"
|
||||
)
|
||||
for file in files:
|
||||
if not isinstance(file, dict):
|
||||
raise EsphomeError(
|
||||
f"Unexpected package registry response for {package}: "
|
||||
f"{str(ver)[:200]}"
|
||||
)
|
||||
# Only a missing key means "any system"; an empty list must not
|
||||
# match, and a bare string would make ``in`` a substring test.
|
||||
systems = file.get("system")
|
||||
if systems is None:
|
||||
systems = ["*"]
|
||||
elif isinstance(systems, str):
|
||||
systems = [systems]
|
||||
elif not isinstance(systems, list):
|
||||
# An int would make ``in`` a TypeError and a dict a key test
|
||||
raise EsphomeError(
|
||||
f"Unexpected package registry response for {package}: "
|
||||
f"{str(file)[:200]}"
|
||||
)
|
||||
if "*" in systems or systype in systems:
|
||||
sha256 = (file.get("checksum") or {}).get("sha256")
|
||||
if not sha256:
|
||||
# Never extract an unverified archive; the registry
|
||||
# publishes a checksum for every package file.
|
||||
raise EsphomeError(
|
||||
f"The package registry returned no sha256 for "
|
||||
f"{package} {version}; refusing the unverified download"
|
||||
)
|
||||
url = file.get("download_url")
|
||||
if not url:
|
||||
raise EsphomeError(
|
||||
f"The package registry returned no download URL for "
|
||||
f"{package} {version}"
|
||||
)
|
||||
return (url, sha256, file.get("size"))
|
||||
raise EsphomeError(
|
||||
f"No {package} {version} build for this platform ({systype})"
|
||||
)
|
||||
raise EsphomeError(f"{package} {version} not found in the package registry")
|
||||
|
||||
|
||||
def _check_layout(name: str, dest: Path, expect: Collection[str]) -> None:
|
||||
"""Raise when an install tree is missing an expected directory (runs on
|
||||
fresh extracts and on marker hits)."""
|
||||
for rel in expect:
|
||||
if not (dest / rel).is_dir():
|
||||
raise EsphomeError(
|
||||
f"{name} at {dest} is missing the expected {rel} "
|
||||
"directory; run 'esphome clean-all' and retry"
|
||||
)
|
||||
|
||||
|
||||
class _PendingArchive(NamedTuple):
|
||||
name: str
|
||||
version: str
|
||||
dest: Path
|
||||
url: str
|
||||
sha256: str
|
||||
size: int
|
||||
|
||||
|
||||
def _already_installed(dest: Path) -> bool:
|
||||
"""Whether ``dest`` holds a completed install (extraction marker)."""
|
||||
return (dest / ".esphome_extracted").is_file()
|
||||
|
||||
|
||||
def prefetch_packages(
|
||||
packages: list[tuple[str, str, Path, list[str]]], downloads_dir: Path
|
||||
) -> None:
|
||||
"""Download pending package archives in parallel under one combined bar.
|
||||
|
||||
``packages`` holds ``(name, version, dest, mirrors)`` per package. Purely
|
||||
an optimization: ``install_package`` verifies every archive and
|
||||
re-downloads anything this pass left unfinished. Mirror overrides and
|
||||
registry entries without a size stay on the sequential path so its
|
||||
per-file bars remain trustworthy. Each fetch holds the same per-dest
|
||||
lock as ``install_package``: the archive's ``.part`` file is shared, and
|
||||
two concurrent writers would truncate each other's bytes.
|
||||
"""
|
||||
from filelock import FileLock
|
||||
|
||||
pending: list[_PendingArchive] = []
|
||||
seen: set[str] = set()
|
||||
for name, version, dest, mirrors in packages:
|
||||
if mirrors or (dest / ".esphome_extracted").is_file():
|
||||
continue
|
||||
archive_name = f"{name}-{version}"
|
||||
if archive_name in seen:
|
||||
# A duplicate entry would race itself between two workers
|
||||
continue
|
||||
seen.add(archive_name)
|
||||
try:
|
||||
url, sha256, size = registry_download(name, version)
|
||||
except EsphomeError as err:
|
||||
# The sequential install reports the real failure with context
|
||||
_LOGGER.debug("Prefetch resolve for %s failed: %s", name, err)
|
||||
continue
|
||||
if not size:
|
||||
continue
|
||||
archive = downloads_dir / archive_name
|
||||
if archive.is_file() and archive.stat().st_size == size:
|
||||
continue
|
||||
pending.append(_PendingArchive(name, version, dest, url, sha256, size))
|
||||
if len(pending) < 2:
|
||||
return
|
||||
downloads_dir.mkdir(parents=True, exist_ok=True)
|
||||
_LOGGER.info(
|
||||
"Downloading %d package archive(s): %s",
|
||||
len(pending),
|
||||
", ".join(entry.name for entry in pending),
|
||||
)
|
||||
|
||||
def _fetch(entry: _PendingArchive, tracker: Callable[[int], None]) -> None:
|
||||
entry.dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
with FileLock(f"{entry.dest}.lock", fallback_to_soft=False):
|
||||
# Marker re-check: a concurrent build may have installed (and
|
||||
# deleted the archive of) this package while we waited;
|
||||
# re-downloading would orphan a fresh copy in downloads_dir
|
||||
# no branch: the thread tracer misses the skip edge; both
|
||||
# arms of _already_installed are pinned directly
|
||||
if not _already_installed(entry.dest): # pragma: no branch
|
||||
download_with_resume(
|
||||
entry.url,
|
||||
downloads_dir / f"{entry.name}-{entry.version}",
|
||||
sha256=entry.sha256,
|
||||
size=entry.size,
|
||||
progress=tracker,
|
||||
)
|
||||
|
||||
failures = run_batch_downloads(
|
||||
"Downloading packages",
|
||||
[(entry.name, entry.size, partial(_fetch, entry)) for entry in pending],
|
||||
)
|
||||
for name, err in failures:
|
||||
if isinstance(err, (EsphomeError, OSError)):
|
||||
# Expected download failures: install_package retries this one
|
||||
# itself, with a visible bar
|
||||
_LOGGER.debug("Prefetch of %s failed: %s", name, err)
|
||||
else:
|
||||
# Anything else is a programming error that would otherwise
|
||||
# become a permanent silent no-op
|
||||
_LOGGER.warning("Prefetch of %s failed: %r", name, err, exc_info=err)
|
||||
|
||||
|
||||
def install_package(
|
||||
name: str,
|
||||
version: str,
|
||||
dest: Path,
|
||||
mirrors: list[str],
|
||||
downloads_dir: Path,
|
||||
expect: Collection[str],
|
||||
) -> None:
|
||||
"""Download, verify, and extract one package if not already installed.
|
||||
|
||||
The registry path is integrity-checked against the sha256 the registry
|
||||
publishes; a mirror override (URL templates with ``{VERSION}``/``{SYSTEM}``
|
||||
substitution) is trusted as configured. ``downloads_dir`` holds the
|
||||
archive between runs so an interrupted download resumes.
|
||||
"""
|
||||
if not expect:
|
||||
# Layout validation before marker.touch() is the only guard against
|
||||
# caching a truncated mirror archive as a good install
|
||||
raise ValueError("install_package requires a non-empty expect")
|
||||
marker = dest / ".esphome_extracted"
|
||||
if marker.is_file():
|
||||
_check_layout(name, dest, expect)
|
||||
return
|
||||
from filelock import FileLock
|
||||
|
||||
# Serialize concurrent cold builds (same filelock pattern as git.py).
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
# A soft-lock fallback would turn a hard-killed run into a permanent
|
||||
# hang (see git.py).
|
||||
with FileLock(f"{dest}.lock", fallback_to_soft=False):
|
||||
if marker.is_file():
|
||||
# Another process finished the install while we waited
|
||||
return
|
||||
rmdir(dest, msg=f"Clean up incomplete {name} install")
|
||||
# Persistent location so an interrupted download resumes across runs.
|
||||
downloads_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive = downloads_dir / f"{name}-{version}"
|
||||
_LOGGER.info("Downloading %s %s ...", name, version)
|
||||
if mirrors:
|
||||
_LOGGER.warning(
|
||||
"Downloading %s from a mirror override; checksum verification "
|
||||
"is skipped for mirrors",
|
||||
name,
|
||||
)
|
||||
download_from_mirrors(
|
||||
mirrors, {"VERSION": version, "SYSTEM": get_systype()}, archive
|
||||
)
|
||||
else:
|
||||
url, sha256, size = registry_download(name, version)
|
||||
download_with_resume(url, archive, sha256=sha256, size=size)
|
||||
_LOGGER.info("Extracting %s ...", name)
|
||||
archive_extract_all(archive, dest, progress_header="Extracting")
|
||||
# Validate the layout before recording success, so an unexpected
|
||||
# package is never cached as a working install.
|
||||
_check_layout(name, dest, expect)
|
||||
marker.touch()
|
||||
archive.unlink(missing_ok=True)
|
||||
@@ -4,19 +4,18 @@ import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import platformdirs
|
||||
|
||||
from esphome.build_helpers.ccache import resolve_ccache_path
|
||||
from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.framework_helpers import strip_win_long_path_prefix
|
||||
from esphome.helpers import (
|
||||
add_git_ceiling_directory,
|
||||
copy_file_if_changed,
|
||||
get_bool_env,
|
||||
rmtree,
|
||||
write_file,
|
||||
)
|
||||
@@ -41,40 +40,6 @@ _PIO_PYTHON_STAMP_LOCK = ".esphome.pio.stamp.lock"
|
||||
_PIO_PYTHON_STAMP_SCHEMA = "0"
|
||||
|
||||
|
||||
def _strip_win_long_path_prefix(path: str) -> str:
|
||||
r"""Strip the Windows extended-length path prefix from ``path``.
|
||||
|
||||
Handles both forms documented at
|
||||
https://learn.microsoft.com/windows/win32/fileio/naming-a-file:
|
||||
|
||||
* ``\\?\C:\path\to\file`` -> ``C:\path\to\file``
|
||||
* ``\\?\UNC\server\share\path`` -> ``\\server\share\path``
|
||||
|
||||
The NSIS-installed ``esphome.exe`` launcher on Windows starts Python with
|
||||
``sys.executable`` already prefixed with ``\\?\``. That prefix propagates
|
||||
into PlatformIO's ``$PYTHONEXE`` (PlatformIO reads ``PYTHONEXEPATH`` from
|
||||
the environment, falling back to ``os.path.normpath(sys.executable)``)
|
||||
and ends up baked into SCons-emitted command lines for build steps such
|
||||
as the esp8266 ``elf2bin`` invocation. ``cmd.exe`` does not understand
|
||||
the ``\\?\`` prefix, so the build fails with
|
||||
"The system cannot find the path specified." Stripping the prefix early
|
||||
keeps the path shell-quotable.
|
||||
|
||||
Also applied to the ccache path exported by ``_ccache_env()``, which
|
||||
``shutil.which`` can return with the same prefix.
|
||||
|
||||
No-op on non-Windows platforms.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return path
|
||||
if path.startswith("\\\\?\\UNC\\"):
|
||||
# \\?\UNC\server\share\... -> \\server\share\...
|
||||
return "\\\\" + path[len("\\\\?\\UNC\\") :]
|
||||
if path.startswith("\\\\?\\"):
|
||||
return path[len("\\\\?\\") :]
|
||||
return path
|
||||
|
||||
|
||||
def get_platformio_config() -> "ProjectConfig | None":
|
||||
"""Return PlatformIO's ``ProjectConfig``, or None when PlatformIO is absent."""
|
||||
try:
|
||||
@@ -238,35 +203,6 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None:
|
||||
_write_pio_stamp_python(stamp_file, current)
|
||||
|
||||
|
||||
def _ccache_runs(ccache: str) -> bool:
|
||||
"""Return True when the ``ccache`` found on PATH actually runs.
|
||||
|
||||
``shutil.which`` proves existence, not runnability: on Windows it also
|
||||
matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose
|
||||
target is gone. Wrapping compiles around such a find fails every compile
|
||||
step with an opaque OS error, so probe once and fall back to compiling
|
||||
without ccache when the probe fails.
|
||||
"""
|
||||
try:
|
||||
subprocess.run(
|
||||
[ccache, "--version"],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=15,
|
||||
# Repo-wide convention (posix_spawn fast path); see the
|
||||
# close_fds=False call sites across esphome/ and script/helpers.py
|
||||
close_fds=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
_LOGGER.warning(
|
||||
"Ignoring ccache at %s because it failed to run; compiling without ccache",
|
||||
ccache,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _ccache_env() -> dict[str, str]:
|
||||
r"""Return ccache settings for PlatformIO builds.
|
||||
|
||||
@@ -285,7 +221,7 @@ def _ccache_env() -> dict[str, str]:
|
||||
runs fine through ``CreateProcess``, which is how ESP-IDF invokes it,
|
||||
but SCons runs every compile through ``cmd.exe``, which fails on it with
|
||||
"The system cannot find the path specified." (#18399), so the prefix is
|
||||
stripped here with ``_strip_win_long_path_prefix()`` before the
|
||||
stripped here with ``strip_win_long_path_prefix()`` before the
|
||||
runnability probe, which therefore validates the exact string the build
|
||||
will execute.
|
||||
``ESPHOME_CCACHE_PATH`` is an internal channel, not a user setting: the
|
||||
@@ -311,22 +247,8 @@ def _ccache_env() -> dict[str, str]:
|
||||
build dir. The other ``CCACHE_*`` values the user already set in the
|
||||
environment are respected.
|
||||
"""
|
||||
explicit = "ESPHOME_CCACHE_ENABLE" in os.environ
|
||||
if explicit and not get_bool_env("ESPHOME_CCACHE_ENABLE"):
|
||||
return {"ESPHOME_CCACHE_ENABLE": "0"}
|
||||
ccache_path = shutil.which("ccache")
|
||||
ccache_path = resolve_ccache_path()
|
||||
if ccache_path is None:
|
||||
if explicit:
|
||||
_LOGGER.warning(
|
||||
"ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; "
|
||||
"compiling without ccache"
|
||||
)
|
||||
return {"ESPHOME_CCACHE_ENABLE": "0"}
|
||||
# Strip before probing so the probe validates (and the failure warning
|
||||
# names) the exact string the build will execute through cmd.exe.
|
||||
ccache_path = _strip_win_long_path_prefix(ccache_path)
|
||||
# An explicit opt-in skips the runnability probe.
|
||||
if not explicit and not _ccache_runs(ccache_path):
|
||||
return {"ESPHOME_CCACHE_ENABLE": "0"}
|
||||
env = {
|
||||
"ESPHOME_CCACHE_ENABLE": "1",
|
||||
@@ -388,7 +310,7 @@ def run_platformio_cli(*args, **kwargs) -> str | int:
|
||||
# Strip the Windows extended-length path prefix from sys.executable so it
|
||||
# doesn't propagate into PlatformIO's $PYTHONEXE and break SCons-emitted
|
||||
# command lines run through cmd.exe.
|
||||
python_exe = _strip_win_long_path_prefix(sys.executable)
|
||||
python_exe = strip_win_long_path_prefix(sys.executable)
|
||||
if python_exe != sys.executable:
|
||||
# Only override PYTHONEXEPATH when we actually stripped a prefix.
|
||||
# PlatformIO's get_pythonexe_path() reads this and falls back to
|
||||
|
||||
+7
-4
@@ -706,14 +706,17 @@ def clean_all(configuration: list[str]):
|
||||
# the per-config loop above can't reach. Wipe the default cache root
|
||||
# (also catches leftovers from older install layouts), then the resolved
|
||||
# install paths for the ESPHOME_*_PREFIX overrides (docker/add-on/CI)
|
||||
# that live outside it.
|
||||
# that live outside it. Every backend's cache is listed in
|
||||
# TOOLS_CACHE_SPECS, so registering one there is the only step.
|
||||
import platformdirs
|
||||
|
||||
from esphome.components.nrf52.framework import get_sdk_nrf_tools_path
|
||||
from esphome.espidf.framework import get_idf_tools_path
|
||||
from esphome.build_helpers.tools_cache import TOOLS_CACHE_SPECS, tools_cache_path
|
||||
|
||||
cache_root = Path(platformdirs.user_cache_dir("esphome", appauthor=False)).resolve()
|
||||
for install_path in (cache_root, get_idf_tools_path(), get_sdk_nrf_tools_path()):
|
||||
install_paths = [cache_root] + [
|
||||
tools_cache_path(*spec) for spec in TOOLS_CACHE_SPECS
|
||||
]
|
||||
for install_path in install_paths:
|
||||
if install_path.is_dir():
|
||||
_LOGGER.info("Deleting %s", install_path)
|
||||
rmtree(install_path)
|
||||
|
||||
+3
-2
@@ -12,7 +12,7 @@ pyserial==3.5
|
||||
platformio==6.1.19
|
||||
esptool==5.3.1
|
||||
click==8.3.3
|
||||
aioesphomeapi==46.2.0
|
||||
aioesphomeapi==46.2.1
|
||||
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
|
||||
zeroconf==0.150.0
|
||||
puremagic==2.2.0
|
||||
@@ -28,7 +28,8 @@ smpclient==7.2.0
|
||||
requests==2.34.2
|
||||
py7zr==1.1.3
|
||||
platformdirs==4.11.3 # native esp-idf toolchain global cache dir
|
||||
filelock==3.32.3 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
|
||||
ninja==1.13.0 # native esp8266 arduino toolchain build driver
|
||||
filelock==3.32.4 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
|
||||
|
||||
# esp-idf >= 5.0 requires this
|
||||
pyparsing >= 3.3.2
|
||||
|
||||
@@ -475,6 +475,19 @@ TYPE_INFO: dict[int, TypeInfo] = {}
|
||||
# TYPE_DOUBLE = 1, TYPE_FIXED64 = 6, TYPE_SFIXED64 = 16, TYPE_SINT64 = 18
|
||||
UNSUPPORTED_TYPES = {1: "double", 6: "fixed64", 16: "sfixed64", 18: "sint64"}
|
||||
|
||||
# The plaintext frame header budgets 2 varint bytes for the message type
|
||||
# (APIPlaintextFrameHelper::HEADER_PADDING), which caps message IDs at 16383.
|
||||
MAX_MESSAGE_ID = 16383
|
||||
|
||||
|
||||
def validate_message_id(message_id: int, message_name: str) -> None:
|
||||
"""Reject message IDs whose plaintext type varint would not fit in 2 bytes."""
|
||||
if message_id > MAX_MESSAGE_ID:
|
||||
raise ValueError(
|
||||
f"Message ID {message_id} for {message_name} exceeds the plaintext "
|
||||
f"2-byte type varint maximum ({MAX_MESSAGE_ID})"
|
||||
)
|
||||
|
||||
|
||||
def validate_field_type(field_type: int, field_name: str = "") -> None:
|
||||
"""Validate that the field type is supported by ESPHome API.
|
||||
@@ -2549,14 +2562,10 @@ def build_message_type(
|
||||
|
||||
# Add MESSAGE_TYPE method if this is a service message
|
||||
if message_id is not None:
|
||||
# Validate that message_id fits in uint8_t
|
||||
if message_id > 255:
|
||||
raise ValueError(
|
||||
f"Message ID {message_id} for {desc.name} exceeds uint8_t maximum (255)"
|
||||
)
|
||||
validate_message_id(message_id, desc.name)
|
||||
|
||||
# Add static constexpr for message type
|
||||
public_content.append(f"static constexpr uint8_t MESSAGE_TYPE = {message_id};")
|
||||
public_content.append(f"static constexpr uint16_t MESSAGE_TYPE = {message_id};")
|
||||
|
||||
# Add estimated size constant
|
||||
estimated_size = calculate_message_estimated_size(desc)
|
||||
@@ -3212,8 +3221,12 @@ def main() -> None:
|
||||
#include "api_pb2_includes.h"
|
||||
"""
|
||||
|
||||
content += """
|
||||
namespace esphome::api {
|
||||
content += f"""
|
||||
namespace esphome::api {{
|
||||
|
||||
// Upper bound on message IDs, enforced by the code generator: the plaintext
|
||||
// frame header budgets 2 varint bytes for the type (HEADER_PADDING).
|
||||
static constexpr uint16_t MAX_MESSAGE_TYPE = {MAX_MESSAGE_ID};
|
||||
|
||||
"""
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Tests for variables handling in homeassistant.event and homeassistant.action."""
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
CONFIG = "tests/component_tests/api/test_homeassistant_variables.yaml"
|
||||
|
||||
|
||||
def test_plain_string_with_return_is_compiled_as_lambda_with_warning(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A plain string with a return statement compiles as a lambda and warns."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
main_cpp = generate_main(CONFIG)
|
||||
|
||||
assert main_cpp.count('add_variable(ESPHOME_F("lambda_var"), []() {') == 2
|
||||
assert "return millis();" in main_cpp
|
||||
# The source text must not be sent as a static string value.
|
||||
assert '"return millis();"' not in main_cpp
|
||||
assert "missing the !lambda tag" in caplog.text
|
||||
|
||||
|
||||
def test_static_string_is_kept_as_static_value(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A static string stays static, PROGMEM wrapped, with no warning."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
main_cpp = generate_main(CONFIG)
|
||||
|
||||
assert (
|
||||
main_cpp.count(
|
||||
'add_variable(ESPHOME_F("static_var"), ESPHOME_F("static value"));'
|
||||
)
|
||||
== 2
|
||||
)
|
||||
assert "static value" not in caplog.text
|
||||
|
||||
|
||||
def test_static_id_value_stays_literal_with_hint(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Lambda source without a return stays literal text but warns."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
main_cpp = generate_main(CONFIG)
|
||||
|
||||
assert 'ESPHOME_F("id(test_sensor).state")' in main_cpp
|
||||
assert "sent as literal text" in caplog.text
|
||||
|
||||
|
||||
def test_explicit_lambda_tag_is_compiled_as_lambda(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
) -> None:
|
||||
"""A !lambda value keeps working unchanged."""
|
||||
main_cpp = generate_main(CONFIG)
|
||||
|
||||
assert 'add_variable(ESPHOME_F("tagged_var"), []() {' in main_cpp
|
||||
assert "return App.get_name();" in main_cpp
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user