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

This commit is contained in:
J. Nick Koston
2026-10-01 15:23:04 -05:00
404 changed files with 18870 additions and 4152 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ runs:
# cached path cannot diverge.
echo "ESPHOME_ARDUINO8266_PREFIX=$HOME/.esphome-arduino8266" >> "$GITHUB_ENV"
. venv/bin/activate
key=$(python -c 'from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION as f; from esphome.arduino8266.framework import TOOLCHAIN_VERSION as t; print(f"{f}-{t}")')
key=$(python -c 'from esphome.components.esp8266 import RECOMMENDED_ARDUINO_FRAMEWORK_VERSION as f; from esphome.arduino8266.framework import FRAMEWORK_RELEASES, TOOLCHAIN_VERSION as t; print(f"{FRAMEWORK_RELEASES[f].tag}-{t}")')
[ -n "$key" ] || exit 1
echo "key=$key" >> "$GITHUB_OUTPUT"
- name: Cache the native toolchain (write on dev)
+1
View File
@@ -219,5 +219,6 @@ jobs:
run: |
docker run --rm \
-v "${{ github.workspace }}/docker/test_configs:/config" \
-e ESPHOME_LDGEN_STRICT=1 \
"ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \
compile "${{ matrix.id }}.yaml"
+20 -1
View File
@@ -1225,9 +1225,28 @@ jobs:
if [ -n "$compile_csv" ]; then
# Run compilation with grouping and isolation
python3 script/test_build_components.py -e compile -c "$compile_csv" -f --isolate "$directly_changed_csv"
# The bootloader has no ESPHome code and these builds never
# flash; the check_idf_py batch keeps the full build so the
# native sub-build and the equivalence check stay covered.
skip_flag="--skip-bootloader"
if [[ "${{ matrix.batch.check_idf_py }}" == "true" ]]; then
skip_flag=""
fi
python3 script/test_build_components.py -e compile -c "$compile_csv" -f --isolate "$directly_changed_csv" $skip_flag
if [[ "${{ matrix.batch.check_idf_py }}" == "true" ]]; then
# The real idf.py must find nothing to configure or build in a
# tree built above; catches drift on ESP-IDF bumps.
echo "Checking the native ESP-IDF build matches idf.py"
python3 script/check_idf_py_equivalence.py
fi
else
echo "All components in this batch are validate-only -- skipping compile stage."
if [[ "${{ matrix.batch.check_idf_py }}" == "true" ]]; then
# determine-jobs and this step disagree on what compiles; fail
# rather than let the check run nowhere.
echo "::error::This batch was picked for the idf.py check but compiled nothing"
exit 1
fi
fi
- name: Print ccache statistics
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
category: "/language:${{matrix.language}}"
+2 -2
View File
@@ -10,7 +10,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.16.8
rev: v0.16.9
hooks:
# Run the linter.
- id: ruff
@@ -18,7 +18,7 @@ repos:
# Run the formatter.
- id: ruff-format
- repo: https://github.com/PyCQA/flake8
rev: 7.3.0
rev: 7.4.1
hooks:
- id: flake8
additional_dependencies:
+2
View File
@@ -541,6 +541,7 @@ esphome/components/st7735/* @SenexCrenshaw
esphome/components/st7789v/* @kbx81
esphome/components/st7920/* @marsjan155
esphome/components/statsd/* @Links2004
esphome/components/stcc4/* @j9brown
esphome/components/stts22h/* @B48D81EFCC
esphome/components/substitutions/* @esphome/core
esphome/components/sun/* @OttoWinter
@@ -554,6 +555,7 @@ esphome/components/syslog/* @clydebarrow
esphome/components/systa_bus/* @Mat931
esphome/components/t6615/* @tylermenezes
esphome/components/tas2780/* @remcom
esphome/components/tas58xx/* @mrtoy-me @remcom
esphome/components/tc74/* @sethgirvan
esphome/components/tca9548a/* @andreashergert1984
esphome/components/tca9555/* @mobrembski
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.17.2
RUN uv pip install --no-cache-dir esphome-device-builder==1.19.0
RUN \
platformio settings set enable_telemetry No \
+2
View File
@@ -3,5 +3,7 @@ esphome:
esp8266:
board: d1_mini
# The PlatformIO path stays covered whatever the default is
toolchain: platformio
logger:
+53 -157
View File
@@ -18,7 +18,6 @@ from typing import TYPE_CHECKING, Protocol
from esphome import const, platform_hooks
from esphome.build_helpers.native import analysis_backend, native_backend
from esphome.const import (
ALLOWED_NAME_CHARS,
ARGUMENT_HELP_DEVICE,
BUNDLE_EXTENSION,
CONF_API,
@@ -36,7 +35,6 @@ from esphome.const import (
CONF_LOGGER,
CONF_MDNS,
CONF_MQTT,
CONF_NAME,
CONF_NAME_ADD_MAC_SUFFIX,
CONF_OTA,
CONF_PASSWORD,
@@ -62,6 +60,7 @@ from esphome.stacktrace import LogLineProcessor
from esphome.types import ConfigType
from esphome.upload_targets import PortType, get_port_type
from esphome.util import (
ESPHOME_COMMAND,
PICOTOOL_PACKAGE,
FlashImage,
detect_rp2040_bootsel,
@@ -85,7 +84,6 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
ESPHOME_COMMAND = [sys.executable, "-m", "esphome"]
# Maximum buffer size for serial log reading to prevent unbounded memory growth
SERIAL_BUFFER_MAX_SIZE = 65536
@@ -150,6 +148,7 @@ class ArgsProtocol(Protocol):
file: str | None
no_logs: bool
only_generate: bool
skip_bootloader: bool
show_secrets: bool
dashboard: bool
configuration: str
@@ -829,6 +828,14 @@ def write_cpp_file() -> int:
def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
if CORE.skip_bootloader and not (CORE.is_esp32 and CORE.using_toolchain_esp_idf):
# Info, not a warning: an orchestrator cannot see YAML toolchain
# overrides, this is its expected no-op, and a full build is safe.
_LOGGER.info(
"--skip-bootloader ignored: only supported on ESP32 with the "
"esp-idf toolchain"
)
CORE.skip_bootloader = False
# Keep this gate here, NOT in config validation: device-builder needs
# `esphome config` to keep succeeding with placeholders so onboarding can run.
if CONF_WIFI in config:
@@ -838,7 +845,7 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
# Keep this here, NOT in codegen: config-hash and --only-generate must keep
# working on machines that cannot run the toolchain.
if CORE.is_esp8266:
if CORE.is_esp8266 and CORE.using_toolchain_platformio:
from esphome.components.esp8266 import check_rosetta
check_rosetta()
@@ -859,7 +866,10 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
return rc
# Create factory.bin, ota.bin, and firmware.elf copy
toolchain.create_factory_bin()
if not toolchain.create_factory_bin():
# A build whose factory image could not be produced must not
# exit 0; downloads would serve an image from an older build.
return 1
toolchain.create_ota_bin()
toolchain.create_elf_copy()
from esphome.build_helpers.idedata import warn_if_idedata_missing
@@ -977,8 +987,9 @@ def upload_using_esptool(
# and partitions included where the target needs them)
image = native.get_factory_firmware_path()
if not image.is_file():
hint = getattr(native, "missing_image_hint", lambda: None)()
raise EsphomeError(
f"{image} does not exist; compile the configuration first"
hint or f"{image} does not exist; compile the configuration first"
)
flash_images = [FlashImage(path=image, offset="0x0")]
else:
@@ -1381,6 +1392,12 @@ def _upload_via_native_api(
ota_type = espota2.OTA_TYPE_UPDATE_PARTITION_TABLE
elif getattr(args, "bootloader", False):
check_partition_access("--bootloader")
if (
getattr(args, "file", None) is None
and (native := native_backend())
and (hint := getattr(native, "missing_image_hint", lambda: None)())
):
raise EsphomeError(hint)
binary = CORE.bootloader_bin
ota_type = espota2.OTA_TYPE_UPDATE_BOOTLOADER
if getattr(args, "file", None) is not None:
@@ -1756,6 +1773,18 @@ def command_logs(args: ArgsProtocol, config: ConfigType) -> int | None:
def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
if (
CORE.skip_bootloader
and CORE.is_esp32
and CORE.using_toolchain_esp_idf
and any(
get_port_type(device) == PortType.SERIAL for device in (args.device or [])
)
):
# Fail before the compile: the result could never flash over serial.
# Elsewhere the flag is ignored, so serial stays fine there.
_LOGGER.error("--skip-bootloader builds cannot be flashed over serial")
return 1
exit_code = write_cpp(config)
if exit_code != 0:
return exit_code
@@ -2104,155 +2133,9 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome import yaml_util
from esphome.cli.rename import command_rename as run
new_name = args.name
for c in new_name:
if c not in ALLOWED_NAME_CHARS:
safe_print(
color(
AnsiFore.BOLD_RED,
f"'{c}' is an invalid character for names. Valid characters are: "
f"{ALLOWED_NAME_CHARS} (lowercase, no spaces)",
)
)
return 1
# Load existing yaml file
raw_contents = CORE.config_path.read_text(encoding="utf-8")
yaml = yaml_util.load_yaml(CORE.config_path)
if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]:
safe_print(
color(
AnsiFore.BOLD_RED, "Complex YAML files cannot be automatically renamed."
)
)
return 1
old_name = yaml[CONF_ESPHOME][CONF_NAME]
match = re.match(r"^\$\{?([a-zA-Z0-9_]+)\}?$", old_name)
if match is None:
# Only swap the ``name:`` line that sits directly under the
# top-level ``esphome:`` block. A naked ``re.sub`` would
# also clobber any other ``name:`` line whose value happens
# to match (e.g. a sensor / output / wifi entry sharing the
# device's hostname), silently rewriting unrelated user
# configuration. The pattern anchors:
# - at the start of the line so ``friendly_name:``,
# ``device_name:`` etc. don't match the trailing ``name:``
# substring; and
# - at the end of the value (lookahead for whitespace +
# comment + EOL) so ``old_name`` doesn't match as a
# prefix of a longer value (``kitchen`` vs ``kitchen2``).
name_pattern = re.compile(
rf"^(\s*)name:\s+[\"']?{re.escape(old_name)}[\"']?(?=\s*(?:#|$))"
)
out_lines: list[str] = []
in_esphome_block = False
for line in raw_contents.splitlines(keepends=True):
if line and not line[0].isspace() and line.strip():
in_esphome_block = line.lstrip().startswith("esphome:")
out_lines.append(line)
continue
if in_esphome_block:
line = name_pattern.sub(rf'\1name: "{new_name}"', line, count=1)
out_lines.append(line)
new_raw = "".join(out_lines)
else:
old_name = yaml[CONF_SUBSTITUTIONS][match.group(1)]
if (
len(
re.findall(
rf"^\s+{match.group(1)}:\s+[\"']?{old_name}[\"']?",
raw_contents,
flags=re.MULTILINE,
)
)
> 1
):
safe_print(
color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename")
)
return 1
new_raw = re.sub(
rf"^(\s+{match.group(1)}):\s+[\"']?{old_name}[\"']?",
f'\\1: "{new_name}"',
raw_contents,
flags=re.MULTILINE,
)
# ``new_name == old_name`` (after substitution resolution) is
# a no-op rewrite that would still queue a pointless re-flash.
# Catch it before the path-equality check below — covers the
# case where the config filename doesn't match the device name
# (e.g. ``weird-file.yaml`` whose ``esphome.name`` is
# ``kitchen``; running ``esphome rename weird-file.yaml kitchen``
# would otherwise just re-flash the same hostname).
if new_name == old_name:
safe_print(
color(
AnsiFore.BOLD_RED,
f"'{new_name}' is already the device's name.",
)
)
return 1
new_path: Path = CORE.config_dir / (new_name + ".yaml")
if new_path.resolve() == CORE.config_path.resolve():
safe_print(
color(
AnsiFore.BOLD_RED,
f"'{new_name}' is already the device's name.",
)
)
return 1
if new_path.exists():
safe_print(
color(
AnsiFore.BOLD_RED,
f"Cannot rename: {new_path} already exists. "
"Refusing to overwrite an existing configuration.",
)
)
return 1
safe_print(
f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}"
)
print()
new_path.write_text(new_raw, encoding="utf-8")
rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path))
if rc != 0:
safe_print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
new_path.unlink()
return 1
cli_args = [
"run",
str(new_path),
"--no-logs",
"--device",
CORE.address,
]
if args.dashboard:
cli_args.insert(0, "--dashboard")
try:
rc = run_external_process(*ESPHOME_COMMAND, *cli_args)
except KeyboardInterrupt:
rc = 1
if rc != 0:
new_path.unlink()
return 1
if CORE.config_path != new_path:
CORE.config_path.unlink()
safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
print()
return 0
return run(args, config)
PRE_CONFIG_ACTIONS = {
@@ -2287,6 +2170,15 @@ SIMPLE_CONFIG_ACTIONS = [
]
def _add_skip_bootloader_arg(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--skip-bootloader",
help="Do not build the bootloader or the factory image; "
"the result can only be flashed over OTA.",
action="store_true",
)
def _add_states_args(parser: argparse.ArgumentParser) -> None:
"""Add mutually exclusive ``--states``/``--no-states`` flags to a parser.
@@ -2367,7 +2259,8 @@ def parse_args(argv):
metavar="{" + ",".join(t.value for t in Toolchain) + "}",
help=(
"Select toolchain for compiling. Overrides '<platform>.toolchain' in YAML. "
f"Default: {Toolchain.PLATFORMIO.value}."
"Default: the platform's native toolchain where it has one, else "
f"{Toolchain.PLATFORMIO.value}."
),
)
@@ -2427,6 +2320,7 @@ def parse_args(argv):
help="Only generate source code, do not compile.",
action="store_true",
)
_add_skip_bootloader_arg(parser_compile)
parser_upload = subparsers.add_parser(
"upload",
@@ -2490,7 +2384,7 @@ def parse_args(argv):
"-r",
action="store_true",
help="Reset the device before starting serial logs.",
default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"),
default=get_bool_env("ESPHOME_SERIAL_LOGGING_RESET"),
)
_add_states_args(parser_logs)
@@ -2523,6 +2417,7 @@ def parse_args(argv):
parser_run.add_argument(
"--no-logs", help="Disable starting logs.", action="store_true"
)
_add_skip_bootloader_arg(parser_run)
_add_states_args(parser_run)
@@ -2531,7 +2426,7 @@ def parse_args(argv):
"-r",
action="store_true",
help="Reset the device before starting serial logs.",
default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"),
default=get_bool_env("ESPHOME_SERIAL_LOGGING_RESET"),
)
parser_run.add_argument(
"--ota-platform",
@@ -2782,6 +2677,7 @@ def run_esphome(argv):
CORE.config_path = conf_path
CORE.dashboard = args.dashboard
CORE.skip_bootloader = getattr(args, "skip_bootloader", False)
if args.toolchain is not None:
# CLI toolchain wins over esp32.toolchain in YAML.
CORE.toolchain = args.toolchain
+9 -2
View File
@@ -148,6 +148,13 @@ class AddressCache:
continue
hostname, ips = arg.split("=", 1)
# Normalize hostname for consistent lookups
normalized = normalize_hostname(hostname)
cache[normalized] = [ip.strip() for ip in ips.split(",")]
normalized = normalize_hostname(hostname.strip())
addresses = [ip for value in ips.split(",") if (ip := value.strip())]
if not normalized or not addresses:
_LOGGER.warning(
"Invalid cache entry: %s (hostname and at least one address are required)",
arg,
)
continue
cache[normalized] = addresses
return cache
+1 -1
View File
@@ -37,7 +37,7 @@ def find_elf_path(build_path: Path) -> Path | None:
"""
name = build_path.name
for candidate in (
# Native ESP-IDF: idf.py writes build/<name>.elf, which ESPHome copies
# Native ESP-IDF: the build writes build/<name>.elf, which ESPHome copies
# to build/firmware.elf (see espidf.toolchain.create_elf_copy)
build_path / "build" / "firmware.elf",
# PlatformIO
+105 -45
View File
@@ -3,12 +3,12 @@
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)
<cache>/arduino8266/frameworks/<tag>/ the Arduino core
<cache>/arduino8266/toolchains/<tag>/ xtensa-lx106-elf 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.
Both come from esphome-libs releases pinned below;
``ESPHOME_ARDUINO8266_*_MIRRORS`` overrides the URLs, with ``{VERSION}``
standing for the release tag. ninja comes from PATH or the ninja PyPI wheel.
"""
from __future__ import annotations
@@ -22,13 +22,69 @@ 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
from esphome.platformio.registry import (
Download,
PackageSpec,
Resolver,
get_systype,
install_packages,
prefetch_packages,
)
FRAMEWORK_PACKAGE = "framework-arduinoespressif8266"
TOOLCHAIN_PACKAGE = "toolchain-xtensa"
FRAMEWORK_PACKAGE = "arduino-esp8266"
_FRAMEWORK_RELEASES = "https://github.com/esphome-libs/arduino-esp8266/releases/"
class FrameworkRelease(NamedTuple):
tag: str
sha256: str
size: int
def download(self) -> Download:
archive = f"{FRAMEWORK_PACKAGE}-{self.tag}.tar.gz"
url = f"{_FRAMEWORK_RELEASES}download/{self.tag}/{archive}"
return Download(url, self.sha256, self.size)
# Arduino core version -> its build in esphome-libs/arduino-esp8266
FRAMEWORK_RELEASES: dict[Version, FrameworkRelease] = {
Version(3, 1, 2): FrameworkRelease(
"3.1.2-esphome.1",
"e80751e3123676b967143e39c61f2d8693946db4c7806f2a83dcaaf797ecd582",
37189311,
),
}
TOOLCHAIN_PACKAGE = "toolchain-xtensa-lx106-elf"
# 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"
TOOLCHAIN_VERSION = "10.3.0-esphome.2"
_TOOLCHAIN_RELEASES = (
"https://github.com/esphome-libs/xtensa-lx106-elf-toolchain/releases/"
)
# Registry system tag -> (sha256, size) of that host's archive
TOOLCHAIN_BUILDS: dict[str, tuple[str, int]] = {
"darwin_arm64": (
"849cede44d4d5c6ea0f14099783239f559f46327bea314281814f2652b486201",
60830321,
),
"darwin_x86_64": (
"ca69904daabf0c5983b372423e5e62f49182a793e992c052e94666852470c897",
64149487,
),
"linux_aarch64": (
"60a49a4f082bf246544bd409a9517dbbcab19bb30ac9decbee544b896aaccbd6",
67573397,
),
"linux_x86_64": (
"1fba33ca1494ec79f2776e0e37eca93282d30f8bb9992f5f4f9a655d6fff1db4",
68431336,
),
"windows_amd64": (
"af9066b0e5bf036f04f2bd9d08b89b81a7f183c57dac0abcaff71dd861cf5f3b",
67664137,
),
}
ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS = str_to_lst_of_str(
os.environ.get("ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS", "")
@@ -44,36 +100,40 @@ def get_arduino8266_tools_path() -> Path:
return tools_cache_path(*ARDUINO8266_TOOLS_CACHE)
# 3.1.1 rather than 3.1.0: the registry has no packages for 3.0.0, 3.0.1 or 3.1.0
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 for 3.x cores; callers floor at MIN_FRAMEWORK_VERSION.
"""
if ver.major > 3:
def framework_release(version: Version) -> FrameworkRelease:
if (release := FRAMEWORK_RELEASES.get(version)) is None:
raise EsphomeError(
f"Arduino core {ver} is not supported yet; "
"the newest known core series is 3.x"
f"'toolchain: arduino' has no build of Arduino core {version}; "
f"available: {', '.join(str(v) for v in FRAMEWORK_RELEASES)}. "
"Use one of those or 'toolchain: platformio'"
)
if ver.major < 3:
raise EsphomeError(
f"Arduino core {ver} is not supported; ESPHome requires core 3.x"
)
return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
return release
def get_framework_path(package_version: str) -> Path:
return get_arduino8266_tools_path() / "frameworks" / package_version
def get_framework_path(tag: str) -> Path:
return get_arduino8266_tools_path() / "frameworks" / tag
def get_toolchain_path() -> Path:
return get_arduino8266_tools_path() / "toolchains" / TOOLCHAIN_VERSION
def toolchain_download() -> Download:
"""The toolchain archive for the current host."""
systype = get_systype()
if (build := TOOLCHAIN_BUILDS.get(systype)) is None:
raise EsphomeError(
f"There is no ESP8266 toolchain for this system ({systype}); "
f"supported systems are {', '.join(sorted(TOOLCHAIN_BUILDS))}. "
"Either set 'toolchain: platformio' under 'esp8266:', or point "
"ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS at a toolchain archive"
)
sha256, size = build
archive = f"{TOOLCHAIN_PACKAGE}-{TOOLCHAIN_VERSION}-{systype}.tar.gz"
url = f"{_TOOLCHAIN_RELEASES}download/{TOOLCHAIN_VERSION}/{archive}"
return Download(url, sha256, size)
class InstalledPaths(NamedTuple):
"""Locations of the installed framework, toolchain, and ninja binary."""
@@ -84,29 +144,22 @@ class InstalledPaths(NamedTuple):
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}"
)
release = framework_release(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)
framework_path = get_framework_path(release.tag)
downloads_dir = get_arduino8266_tools_path() / "downloads"
toolchain_path = get_toolchain_path()
# One spec per package: the prefetch and the installs must agree
specs = (
(
PackageSpec(
FRAMEWORK_PACKAGE,
package_version,
release.tag,
framework_path,
ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS,
("cores/esp8266", "tools/sdk", "libraries"),
),
(
PackageSpec(
TOOLCHAIN_PACKAGE,
TOOLCHAIN_VERSION,
toolchain_path,
@@ -115,10 +168,17 @@ def check_and_install(framework_version: Version) -> InstalledPaths:
("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)
# Resolved only when a download is needed, so an installed toolchain
# keeps working on a host without a build; a mirror override wins
resolvers: dict[str, Resolver] = {}
if not ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS:
resolvers[FRAMEWORK_PACKAGE] = release.download
if not ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS:
resolvers[TOOLCHAIN_PACKAGE] = toolchain_download
# Fetch both archives at once; the installs verify and extract them.
# One spec list for both, so the two phases cannot drift.
prefetch_packages(specs, downloads_dir, resolvers)
install_packages(specs, downloads_dir, resolvers)
return InstalledPaths(
framework=framework_path, toolchain=toolchain_path, ninja=ninja_path
)
+31 -8
View File
@@ -1053,12 +1053,9 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
+ ["stdc++-exc" if config.exceptions else "stdc++", "m", "c", "gcc"]
)
cxx = (toolchain_tool(paths.toolchain, "g++"),)
lines = [
*tool_lines(
(toolchain_tool(paths.toolchain, "gcc"),),
(toolchain_tool(paths.toolchain, "g++"),),
ccache,
),
*tool_lines((toolchain_tool(paths.toolchain, "gcc"),), cxx, ccache),
*compile_rule_lines(),
*ar_rule_lines(toolchain_tool(paths.toolchain, "ar")),
*pch_rule_lines(),
@@ -1068,10 +1065,10 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
" rspfile_content = $in_newline",
" description = LINK $out",
"rule elf2bin",
# --flash_size deliberately stays board-derived, as under
# PlatformIO (which reads upload.maximum_size, not the ldscript).
# --flash_size follows PlatformIO: ldscript filename first, then
# upload.maximum_size, so the header always matches the layout.
# -W: the framework's own elf2bin.py trips SyntaxWarning on 3.12+.
f" command = $python -W ignore::SyntaxWarning {_q(framework / 'tools' / 'elf2bin.py')} --eboot {_q(framework / 'bootloaders' / 'eboot' / 'eboot.elf')} --app $in --flash_mode {flash_mode} --flash_freq {_FLASH_FREQ_MHZ} --flash_size {_flash_size_str(BOARDS[board][KEY_FLASH_SIZE])} --path {_q(toolchain_bin)} --out $out",
f" command = $python -W ignore::SyntaxWarning {_q(framework / 'tools' / 'elf2bin.py')} --eboot {_q(framework / 'bootloaders' / 'eboot' / 'eboot.elf')} --app $in --flash_mode {flash_mode} --flash_freq {_FLASH_FREQ_MHZ} --flash_size {_elf2bin_flash_size(board, flash_ld_name)} --path {_q(toolchain_bin)} --out $out",
" description = BIN $out",
"rule copy",
" command = $python $buildtool copy $in $out",
@@ -1146,6 +1143,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
flag_sets.cxxflags,
src_other,
(str(paths.framework), str(paths.toolchain)),
cxx,
)
src_objs = compile_edges(
lines,
@@ -1191,3 +1189,28 @@ def _flash_size_str(flash_size: int) -> str:
"""Flash size argument for elf2bin (e.g. ``4M``, ``512K``)."""
mb = 1024 * 1024
return f"{flash_size // mb}M" if flash_size >= mb else f"{flash_size // 1024}K"
# Same pattern PlatformIO's _get_flash_size applies to the ldscript path
_LD_FLASH_SIZE_RE = re.compile(r"\.flash\.(\d+[mk]).*\.ld")
# The framework elf2bin.py's --flash_size choices
_ELF2BIN_FLASH_SIZES = frozenset({"256K", "512K", "1M", "2M", "4M", "8M", "16M"})
def _elf2bin_flash_size(board: str, flash_ld_name: str) -> str:
"""Image-header flash size as PlatformIO derives it: ldscript filename,
else board_upload.maximum_size, else the board table. The SDK clamps the
chip to the header size at boot, so a header smaller than the linked
layout breaks OTA writes on the running device."""
if match := _LD_FLASH_SIZE_RE.search(flash_ld_name):
token = match.group(1)
base = 1024 if token[-1] == "k" else 1024 * 1024
return _flash_size_str(int(token[:-1]) * base)
if max_size := _pio_option("board_upload.maximum_size", ""):
if (
not max_size.isdigit()
or (size := _flash_size_str(int(max_size))) not in _ELF2BIN_FLASH_SIZES
):
raise EsphomeError(f"Invalid board_upload.maximum_size value {max_size!r}")
return size
return _flash_size_str(BOARDS[board][KEY_FLASH_SIZE])
+157 -5
View File
@@ -3,6 +3,8 @@
import json
import logging
from pathlib import Path
import re
import textwrap
from esphome.build_helpers import pch
from esphome.components.esp32 import (
@@ -19,7 +21,7 @@ from esphome.framework_helpers import (
get_project_cxx_compile_flags,
get_project_link_flags,
)
from esphome.helpers import mkdir_p, write_file_if_changed
from esphome.helpers import get_bool_env, mkdir_p, write_file_if_changed
_LOGGER = logging.getLogger(__name__)
@@ -34,6 +36,46 @@ list(FILTER esphome_cxx_compile_options EXCLUDE REGEX "^-std=")
list(APPEND esphome_cxx_compile_options "-std={standard}")
idf_build_set_property(CXX_COMPILE_OPTIONS "${{esphome_cxx_compile_options}}")"""
# Drops the app archive from ldgen's inputs so app-only edits skip the
# sections.ld regeneration. Safe: no mapping fragment references it
# (run_compile re-checks each build). Filters only the top-level call;
# the prior definition stays reachable with an underscore prefix.
_LDGEN_OVERRIDE = """\
if(COMMAND __ldgen_get_lib_deps_of_target)
set_property(GLOBAL PROPERTY ESPHOME_LDGEN_ARMED 1)
function(__ldgen_get_lib_deps_of_target target out_list_var)
if(NOT COMMAND ___ldgen_get_lib_deps_of_target)
message(FATAL_ERROR "ESPHome ldgen override lost the original "
"implementation; set ESPHOME_LDGEN_FULL_DEPS=1 and rebuild.")
endif()
___ldgen_get_lib_deps_of_target(${target} ${out_list_var})
if(out_list_var STREQUAL "ldgen_libraries")
set_property(GLOBAL PROPERTY ESPHOME_LDGEN_FILTERED 1)
list(LENGTH ${out_list_var} esphome_ldgen_before)
list(REMOVE_ITEM ${out_list_var} idf::src __idf_src)
list(LENGTH ${out_list_var} esphome_ldgen_after)
if(esphome_ldgen_before EQUAL esphome_ldgen_after)
message(@SEVERITY@ "ESPHome ldgen app archive exclusion matched "
"nothing; app edits will regenerate sections.ld.")
endif()
endif()
set(${out_list_var} "${${out_list_var}}" PARENT_SCOPE)
endfunction()
else()
message(@MISSING@ "ESPHome ldgen override target not found; "
"app edits will regenerate sections.ld.")
endif()"""
# Runs after project() so the walk has happened; catches the remaining
# silent path where the top-level out-var was renamed.
_LDGEN_OVERRIDE_CHECK = """\
get_property(esphome_ldgen_armed GLOBAL PROPERTY ESPHOME_LDGEN_ARMED)
get_property(esphome_ldgen_filtered GLOBAL PROPERTY ESPHOME_LDGEN_FILTERED)
if(esphome_ldgen_armed AND NOT esphome_ldgen_filtered)
message(@SEVERITY@ "ESPHome ldgen override never filtered the app "
"archive; app edits will regenerate sections.ld.")
endif()"""
def get_available_components() -> list[str] | None:
"""List the built-in ESP-IDF components from ``project_description.json``.
@@ -79,6 +121,78 @@ def _cmake_quote(value: str) -> str:
return f'"{escaped}"'
# CONFIG_APP_BUILD_BOOTLOADER is hidden and force-selected, so it can only be
# cleared at the CMake level (the same state IDF's RAM-app build type uses).
# The macro is IDF's __build_process_project_includes plus two added lines;
# the flag is ignored and the bootloader builds as usual if IDF changes it.
IDF_BOOTLOADER_OVERRIDE = """\
# ESPHome bootloader skip switch; see esphome/espidf/toolchain.py.
if(ESPHOME_SKIP_BOOTLOADER)
macro(__build_process_project_includes)
idf_build_get_property(sdkconfig_cmake SDKCONFIG_CMAKE)
include(${sdkconfig_cmake})
set(CONFIG_APP_BUILD_BOOTLOADER "")
# bt's CMakeLists reads the lowercase idf_target that the (now
# skipped) bootloader project_include leaks; keep it defined, or
# its empty TARGET_SRC_NAME sends file(GLOB_RECURSE) across /.
idf_build_get_property(idf_target IDF_TARGET)
idf_build_get_property(build_properties __BUILD_PROPERTIES)
foreach(build_property ${build_properties})
idf_build_get_property(val ${build_property})
set(${build_property} "${val}")
endforeach()
idf_build_get_property(build_component_targets __BUILD_COMPONENT_TARGETS)
foreach(component_target ${build_component_targets})
__component_get_property(dir ${component_target} COMPONENT_DIR)
__component_get_property(_name ${component_target} COMPONENT_NAME)
set(COMPONENT_NAME ${_name})
set(COMPONENT_DIR ${dir})
set(COMPONENT_PATH ${dir})
if(EXISTS ${COMPONENT_DIR}/project_include.cmake)
include(${COMPONENT_DIR}/project_include.cmake)
endif()
endforeach()
endmacro()
endif()
"""
# The lines the override adds to IDF's macro; idf_macro_matches() below
# strips them before comparing with the live macro.
BOOTLOADER_OVERRIDE_ADDED_LINES = (
'set(CONFIG_APP_BUILD_BOOTLOADER "")',
"idf_build_get_property(idf_target IDF_TARGET)",
)
_MACRO = re.compile(
r"macro\(__build_process_project_includes\)(.*?)endmacro\(\)", re.DOTALL
)
def _normalized_macro(text: str) -> list[str] | None:
"""The macro body as comment-free, whitespace-collapsed lines."""
if (match := _MACRO.search(text)) is None:
return None
return [
re.sub(r"\s+", " ", line)
for raw in match.group(1).splitlines()
if (line := raw.split("#", 1)[0].strip())
]
_EXPECTED_MACRO = [
line
for line in _normalized_macro(IDF_BOOTLOADER_OVERRIDE)
if line not in BOOTLOADER_OVERRIDE_ADDED_LINES
]
def idf_macro_matches(idf_path: Path) -> bool:
"""Whether IDF's macro still matches the copy the override replays."""
build_cmake = idf_path / "tools" / "cmake" / "build.cmake"
live = _normalized_macro(build_cmake.read_text(encoding="utf-8"))
return live == _EXPECTED_MACRO
def get_project_cmakelists(
minimal: bool = False, builtin_components: list[str] | None = None
) -> str:
@@ -124,6 +238,22 @@ def get_project_cmakelists(
else ""
)
# Stops the ~3s sections.ld regeneration on app-only edits; see
# _LDGEN_OVERRIDE. ESPHOME_LDGEN_FULL_DEPS=1 restores stock behavior;
# ESPHOME_LDGEN_STRICT=1 (CI) fails the configure when an IDF bump
# breaks the override instead of degrading to stock deps.
if get_bool_env("ESPHOME_LDGEN_FULL_DEPS"):
ldgen_override = ""
ldgen_override_check = ""
else:
strict = get_bool_env("ESPHOME_LDGEN_STRICT")
severity = "FATAL_ERROR" if strict else "WARNING"
missing = "FATAL_ERROR" if strict else "STATUS"
ldgen_override = _LDGEN_OVERRIDE.replace("@SEVERITY@", severity).replace(
"@MISSING@", missing
)
ldgen_override_check = _LDGEN_OVERRIDE_CHECK.replace("@SEVERITY@", severity)
# CMake variables registered via cg.add_cmake_arg(). Emitted before
# include(project.cmake) so values like EXCLUDE_COMPONENTS are already
# set when project.cmake seeds the component list, and on minimal
@@ -201,6 +331,9 @@ set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src)
include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
{IDF_BOOTLOADER_OVERRIDE}
{ldgen_override}
{cpp_standard_options}
{cxx_compile_options}
@@ -213,6 +346,8 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
project({CORE.name})
{ldgen_override_check}
# Emit per-memory-type JSON size data for ESPHome to read post-build.
# json2 stays small; raw dumps every symbol (~2s on a large map) and
# this command runs inside the link edge, blocking everything downstream.
@@ -289,6 +424,8 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC
# Where CMake puts the .gch of the src component; ccache reads the checksum
# next to it in place of the .gch
_PCH_SUM_PATH = "build/esp-idf/src/CMakeFiles/__idf_src.dir/cmake_pch.hxx.gch.sum"
# Where the Windows gate records its choice
_PCH_CHOICE_VAR = "ESPHOME_PCH"
def _pch_cmake_block() -> str:
@@ -300,11 +437,21 @@ def _pch_cmake_block() -> str:
f' "$<$<COMPILE_LANGUAGE:CXX>:${{CMAKE_CURRENT_SOURCE_DIR}}/{header}>"'
for header in pch.PCH_DEFAULT_HEADERS
)
return f"""
# ESPHome precompiled header
target_precompile_headers(${{COMPONENT_LIB}} PRIVATE
block = f"""target_precompile_headers(${{COMPONENT_LIB}} PRIVATE
{headers}
)
)"""
if not pch.pch_needs_gcc_check():
return f"\n# ESPHome precompiled header\n{block}\n"
# Before the first configure only CMake knows the compiler version
return f"""
# ESPHome precompiled header, unless GCC bug 14940 keeps it from loading
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND ({pch.PCH_WINDOWS_CMAKE_OLD_GCC}))
message(STATUS "ESPHome: GCC ${{CMAKE_CXX_COMPILER_VERSION}} cannot load a precompiled header on Windows; compiling without it")
set({_PCH_CHOICE_VAR} OFF CACHE BOOL "ESPHome precompiled header in use" FORCE)
else()
set({_PCH_CHOICE_VAR} ON CACHE BOOL "ESPHome precompiled header in use" FORCE)
{textwrap.indent(block, " ")}
endif()
"""
@@ -315,8 +462,13 @@ def _read_if_exists(path: Path) -> str:
def write_pch_checksum() -> None:
"""Write the checksum ccache uses in place of the .gch: the core headers,
the framework version, the sdkconfig and the managed component versions."""
from esphome.espidf.toolchain import get_cmake_cache_value
if not pch.pch_enabled():
return
# The gate's choice, cached by configure
if pch.pch_needs_gcc_check() and get_cmake_cache_value(_PCH_CHOICE_VAR) != "ON":
return
pch.log_pch_in_use()
checksum = pch.pch_checksum(
CORE.relative_src_path(),
+1
View File
@@ -274,6 +274,7 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool:
[*compilers.cxx[1:], *cxxflags],
(),
(compilers.cxx[0], _compiler_version(compilers.cxx)) if pch_enabled() else (),
compilers.cxx,
)
src_objs = compile_edges(
lines,
+7 -5
View File
@@ -24,8 +24,8 @@ from esphome.build_helpers.pch import (
PCH_SUM_NAME,
log_pch_in_use,
pch_consumer_flags,
pch_enabled,
pch_identity,
pch_usable,
write_pch_headers,
)
from esphome.framework_helpers import strip_win_long_path_prefix
@@ -125,15 +125,17 @@ def pch_edges(
cxxflags: Sequence[str],
src_flags: Sequence[str],
identity: Sequence[str],
cxx: Sequence[Path | str],
) -> tuple[str, str] | None:
"""Emit the precompiled header for the C++ src edges.
``headers`` are folded into one prefix header, ``src_flags`` are the
flags every src edge carries and ``identity`` names what else the
compile depends on, the compiler included. Returns the ``cxx_override``
for ``compile_edges``, or None without a pch.
flags every src edge carries, ``identity`` names what else the compile
depends on, the compiler included, and ``cxx`` is what the host rule
asks. Returns the ``cxx_override`` for ``compile_edges``, or None
without a pch.
"""
if not pch_enabled():
if not pch_usable(cxx):
return None
if any(
tok == "-include" or tok.startswith("--include") or is_joined_include(tok)
+78 -10
View File
@@ -2,21 +2,18 @@
from __future__ import annotations
from collections.abc import Iterable
from collections.abc import Iterable, Sequence
import hashlib
import logging
import os
from pathlib import Path
import posixpath
import re
import subprocess
import sys
from esphome.build_helpers.ccache import effective_ccache_basedir, parse_enable_env
from esphome.const import (
PLATFORM_BK72XX,
PLATFORM_LN882X,
PLATFORM_NRF52,
PLATFORM_RTL87XX,
)
from esphome.const import PLATFORM_NRF52
from esphome.helpers import write_file_if_changed
_LOGGER = logging.getLogger(__name__)
@@ -39,8 +36,12 @@ PCH_GUARD_TEXT = f"""\
#endif
"""
# The cc1plus wrapper the PlatformIO script writes on arm64 macOS
PCH_CC1_DIR = "pch_cc1"
# What the PlatformIO script leaves in the project root, for cleanup
PCH_ARTIFACT_NAMES = (PCH_HEADER_NAME, PCH_GCH_NAME, PCH_SUM_NAME, PCH_SOURCE_NAME)
PCH_ARTIFACT_DIRS = (PCH_CC1_DIR,)
# The core headers every backend precompiles
PCH_DEFAULT_HEADERS = ("esphome/core/pch_prefix.h",)
@@ -48,10 +49,7 @@ PCH_DEFAULT_HEADERS = ("esphome/core/pch_prefix.h",)
# PlatformIO platforms that do not take the pch script
PCH_SCRIPT_EXCLUDED_PLATFORMS = frozenset(
{
PLATFORM_BK72XX,
PLATFORM_LN882X,
PLATFORM_NRF52,
PLATFORM_RTL87XX,
}
)
@@ -67,6 +65,76 @@ def pch_enabled() -> bool:
return parse_enable_env("ESPHOME_PCH_ENABLE") is not False
def pch_forced() -> bool:
"""``ESPHOME_PCH_ENABLE=1``: wanted even where the host rule says no."""
return parse_enable_env("ESPHOME_PCH_ENABLE") is True
# GCC bug 14940: before these releases the Windows loader maps a .gch only
# at its saved address. First fixed release per major, 16 on always fixed;
# PCH_WINDOWS_CMAKE_OLD_GCC and the pch_usable message spell the same table
PCH_WINDOWS_GCC_FIXED = {14: (14, 4), 15: (15, 3)}
PCH_WINDOWS_GCC_FIXED_DEFAULT = (16, 0)
# The same rule for CMake, which alone knows the version before configure
PCH_WINDOWS_CMAKE_OLD_GCC = (
"CMAKE_CXX_COMPILER_VERSION VERSION_LESS 14.4 OR "
"(CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 15 AND "
"CMAKE_CXX_COMPILER_VERSION VERSION_LESS 15.3)"
)
def gcc_relocates_pch_on_windows(version: Sequence[int]) -> bool:
"""Whether a GCC of this version loads a .gch on Windows."""
if not version:
return False
fixed = PCH_WINDOWS_GCC_FIXED.get(version[0], PCH_WINDOWS_GCC_FIXED_DEFAULT)
return tuple(version[:2]) >= fixed
# GCC ends the first --version line with its version; clang names itself
_VERSION_RE = re.compile(r"\d+(?:\.\d+)+")
def gcc_version(cxx: Sequence[Path | str]) -> tuple[int, ...] | None:
"""The GCC version from ``--version``: () when it cannot be read, None
for a compiler that is not GCC."""
try:
result = subprocess.run(
[*cxx, "--version"], capture_output=True, text=True, check=False
)
except OSError as err:
_LOGGER.debug("Cannot run %s: %s", cxx[0], err)
return ()
banner = result.stdout.partition("\n")[0]
if "clang" in banner.lower():
return None
found = _VERSION_RE.findall(banner)
return tuple(int(part) for part in found[-1].split(".")) if found else ()
def pch_needs_gcc_check() -> bool:
"""Windows host with the knob unset: the compiler version decides."""
return sys.platform == "win32" and parse_enable_env("ESPHOME_PCH_ENABLE") is None
def pch_usable(cxx: Sequence[Path | str]) -> bool:
"""The knob plus the host rule; ``ESPHOME_PCH_ENABLE=1`` skips the rule."""
if not pch_enabled():
return False
if not pch_needs_gcc_check():
return True
version = gcc_version(cxx)
if version is None or gcc_relocates_pch_on_windows(version):
return True
_LOGGER.info(
"GCC %s cannot load a precompiled header on Windows (GCC bug 14940, "
"fixed in 14.4, 15.3 and 16); compiling without it "
"(set ESPHOME_PCH_ENABLE=1 to force)",
".".join(map(str, version)) or "of unknown version",
)
return False
def pch_consumer_flags() -> list[str]:
"""Flags a C++ src compile loads the pch with. The -include stays
relative: an absolute path would enter the ccache key."""
+180
View File
@@ -0,0 +1,180 @@
"""Run a native build tool (cmake, ninja) and relay its output.
Output is read from a pipe so it can be filtered here: a child that inherits
our stdout writes straight to the file descriptor, past any Python wrapper.
"""
from __future__ import annotations
import codecs
from contextlib import suppress
import logging
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
from typing import Any, TextIO
from esphome.util import ANSI_ESCAPE, RedirectText, shlex_quote
_LOGGER = logging.getLogger(__name__)
# Windows code page identifier for UTF-8, as used by ``chcp 65001``.
UTF8_CODEPAGE = 65001
# Same pattern idf.py uses to spot ninja status lines (``is_progression``).
_PROGRESS = re.compile(r"^\[\d+/\d+\]|.*\(\d+ \%\)$")
_READ_SIZE = 65536
def _get_kernel32() -> Any | None:
"""Return the Windows kernel32 module, or None on any other platform."""
if sys.platform != "win32":
return None
import ctypes
return ctypes.windll.kernel32
class Utf8Console:
"""Keep an attached Windows console on UTF-8 while a build tool runs.
esp_idf_size draws its table with Unicode box characters, and CMake
re-decodes a child's output with the console code page, which garbles the
table on any page but UTF-8. A console already on UTF-8 is left alone so
an overlapping build never records UTF-8 as the page to go back to.
"""
def __init__(self, kernel32: Any | None) -> None:
self._kernel32 = kernel32
self._codepages: tuple[int, int] | None = None
def __enter__(self) -> None:
kernel32 = self._kernel32
if kernel32 is None:
return
old_in = kernel32.GetConsoleCP()
old_out = kernel32.GetConsoleOutputCP()
# Both calls return 0 when no console is attached.
if not old_in or not old_out:
return
if old_in == UTF8_CODEPAGE and old_out == UTF8_CODEPAGE:
return
# Record first so a switch that fails part way is still undone.
self._codepages = (old_in, old_out)
kernel32.SetConsoleCP(UTF8_CODEPAGE)
kernel32.SetConsoleOutputCP(UTF8_CODEPAGE)
def __exit__(self, *exc_info: object) -> None:
if self._codepages is None:
return
old_in, old_out = self._codepages
self._codepages = None
self._kernel32.SetConsoleCP(old_in)
self._kernel32.SetConsoleOutputCP(old_out)
def _fit_terminal(text: str) -> str:
"""Elide the middle of ``text`` to fit the terminal, as idf.py does.
A width of 0 (a pipe, the dashboard) leaves the text whole.
"""
width = shutil.get_terminal_size((0, 0)).columns
if not width:
return text
if width <= 3:
return "." * width
if len(text) >= width:
keep = (width - 3) // 2
return f"{text[:keep]}...{text[len(text) - keep :]}"
return text
class ToolOutput(RedirectText):
"""RedirectText that can collapse ninja status lines into one line.
With ``progress`` each ``[n/m]`` line overwrites the previous one, the
way idf.py shows a build.
"""
def __init__(
self, out: TextIO, filter_lines: list[str] | None, progress: bool
) -> None:
super().__init__(out, filter_lines=filter_lines)
self._progress = progress
self._on_progress_line = False
def _splits_lines(self) -> bool:
return self._progress or super()._splits_lines()
def _emit_line(self, line: str) -> None:
if self._progress and _PROGRESS.match(line):
if not self._is_filtered(line):
text = _fit_terminal(line.strip("\r\n"))
self._write_color_replace(f"\r{text}\x1b[K")
self._on_progress_line = True
return
self._end_progress_line()
super()._emit_line(line)
def _end_progress_line(self) -> None:
if self._on_progress_line:
self._on_progress_line = False
self._write_color_replace(os.linesep)
def drain(self) -> None:
super().drain()
# Called from cleanup, so a broken stream must not hide the exit code.
with suppress(OSError, ValueError):
self._end_progress_line()
self._out.flush()
def run_build_tool(
cmd: list[str],
*,
cwd: Path,
env: dict[str, str],
filter_lines: list[str] | None = None,
progress: bool = False,
log_path: Path | None = None,
) -> int:
"""Run ``cmd`` and relay stdout and stderr, merged, to our stdout.
``log_path`` also gets the full, unfiltered output without color codes, as
idf.py wrote its logs (its hint patterns expect plain text). Returns the
exit code.
"""
_LOGGER.debug("Running: %s", " ".join(shlex_quote(arg) for arg in cmd))
_LOGGER.debug(" in directory: %s", cwd)
output = ToolOutput(sys.stdout, filter_lines, progress)
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
if log_path is not None:
log_path.parent.mkdir(parents=True, exist_ok=True)
with (
Path(log_path or os.devnull).open("w", encoding="utf-8", newline="") as log,
Utf8Console(_get_kernel32()),
subprocess.Popen(
cmd,
cwd=cwd,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
close_fds=False,
) as proc,
):
try:
# read1 returns as soon as anything is available, so output
# streams while the tool runs.
while chunk := proc.stdout.read1(_READ_SIZE):
text = decoder.decode(chunk)
log.write(ANSI_ESCAPE.sub("", text))
output.write(text)
if tail := decoder.decode(b"", final=True):
log.write(ANSI_ESCAPE.sub("", tail))
output.write(tail)
finally:
output.drain()
return proc.returncode
+2 -2
View File
@@ -16,8 +16,8 @@ def tools_cache_path(env_var: str, subdir: str) -> Path:
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
# resolve(): a symlinked prefix would otherwise record a second
# spelling of the same paths in the build tree
return Path(prefix).expanduser().resolve()
# appauthor=False keeps the Windows path short (no vendor segment);
# deep IDF trees run into MAX_PATH otherwise
+2
View File
@@ -0,0 +1,2 @@
"""Commands of the esphome command line, one module each, imported by
__main__ only when they run so that startup stays light."""
+164
View File
@@ -0,0 +1,164 @@
"""``esphome rename``."""
from __future__ import annotations
import argparse
from pathlib import Path
import re
from esphome import yaml_edit, yaml_util
from esphome.const import (
ALLOWED_NAME_CHARS,
CONF_ESPHOME,
CONF_NAME,
CONF_SUBSTITUTIONS,
)
from esphome.core import CORE, EsphomeError
from esphome.log import AnsiFore, color
from esphome.types import ConfigType
from esphome.util import ESPHOME_COMMAND, run_external_process, safe_print
def _revert(new_path: Path, why: str) -> int:
"""Say why the rename stopped and take the new file back; an orphan the
next attempt would trip over is reported."""
safe_print(color(AnsiFore.BOLD_RED, f"Rename failed: {why}"))
try:
new_path.unlink(missing_ok=True)
except OSError as err:
safe_print(color(AnsiFore.BOLD_RED, f"Could not remove {new_path}: {err}"))
return 1
def command_rename(args: argparse.Namespace, config: ConfigType) -> int | None:
"""Rename the device: a new file with the name line rewritten, validated
and installed, then the old file removed."""
new_name = args.name
for c in new_name:
if c not in ALLOWED_NAME_CHARS:
safe_print(
color(
AnsiFore.BOLD_RED,
f"'{c}' is an invalid character for names. Valid characters are: "
f"{ALLOWED_NAME_CHARS} (lowercase, no spaces)",
)
)
return 1
yaml = yaml_util.load_yaml(CORE.config_path)
def name_edit() -> tuple[str, yaml_edit.LineEdit]:
"""The name and the line to rewrite: the name's own line, or the
substitution's line it comes from, as a plain value in this file."""
esphome_conf = yaml.get(CONF_ESPHOME)
if not isinstance(esphome_conf, dict) or CONF_NAME not in esphome_conf:
raise EsphomeError(f"no '{CONF_ESPHOME}: {CONF_NAME}:' in the file")
old_name = str(esphome_conf[CONF_NAME])
mapping, field = esphome_conf, CONF_NAME
if match := re.match(r"^\$\{?([a-zA-Z0-9_]+)\}?$", old_name):
mapping, field = yaml.get(CONF_SUBSTITUTIONS), match.group(1)
if not isinstance(mapping, dict) or field not in mapping:
raise EsphomeError(f"the substitution '{field}' is not in the file")
old_name = str(mapping[field])
# Only read here; the rewritten text goes to a new file, so the
# source may live anywhere the config path points to
source = yaml_edit.source_of(mapping, field)
if source is None or source[0].resolve() != CORE.config_path.resolve():
raise EsphomeError(f"'{field}' was not read from {CORE.config_path}")
doc, line_no = source
text = yaml_edit.line_at(doc, line_no)
if (line_match := yaml_edit.field_line_re(field, old_name).match(text)) is None:
raise EsphomeError(f"'{field}' is not a plain value on {doc}:{line_no + 1}")
# The new value is always quoted, whatever the old line had
return old_name, yaml_edit.LineEdit(
doc, line_no, text, yaml_edit.rewrite(line_match, new_name, '"')
)
try:
old_name, edit = name_edit()
except EsphomeError as err:
safe_print(
color(
AnsiFore.BOLD_RED,
f"Complex YAML files cannot be automatically renamed: {err}",
)
)
return 1
# ``new_name == old_name`` (after substitution resolution) is
# a no-op rewrite that would still queue a pointless re-flash.
# Catch it before the path-equality check below — covers the
# case where the config filename doesn't match the device name
# (e.g. ``weird-file.yaml`` whose ``esphome.name`` is
# ``kitchen``; running ``esphome rename weird-file.yaml kitchen``
# would otherwise just re-flash the same hostname).
if new_name == old_name:
safe_print(
color(
AnsiFore.BOLD_RED,
f"'{new_name}' is already the device's name.",
)
)
return 1
new_path: Path = CORE.config_dir / (new_name + ".yaml")
if new_path.resolve() == CORE.config_path.resolve():
safe_print(
color(
AnsiFore.BOLD_RED,
f"'{new_name}' is already the device's name.",
)
)
return 1
if new_path.exists():
safe_print(
color(
AnsiFore.BOLD_RED,
f"Cannot rename: {new_path} already exists. "
"Refusing to overwrite an existing configuration.",
)
)
return 1
safe_print(
f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}"
)
print()
try:
yaml_edit.write_keeping_mode(
new_path,
yaml_edit.rewritten_text(yaml_edit.read_text(CORE.config_path), [edit]),
like=CORE.config_path,
)
except EsphomeError as err:
return _revert(new_path, str(err))
if run_external_process(*ESPHOME_COMMAND, "config", str(new_path)) != 0:
return _revert(new_path, "the new configuration does not validate")
cli_args = [
"run",
str(new_path),
"--no-logs",
"--device",
CORE.address,
]
if args.dashboard:
cli_args.insert(0, "--dashboard")
try:
rc = run_external_process(*ESPHOME_COMMAND, *cli_args)
except KeyboardInterrupt:
rc = 1
if rc != 0:
return _revert(
new_path,
"the install did not finish; the device may already run the new name",
)
CORE.config_path.unlink()
safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
print()
return 0
+7 -23
View File
@@ -74,26 +74,10 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await ble_client.register_ble_node(var, config)
if flow_config := config.get(CONF_FLOW):
sens = await sensor.new_sensor(flow_config)
cg.add(var.set_flow_sensor(sens))
if head_config := config.get(CONF_HEAD):
sens = await sensor.new_sensor(head_config)
cg.add(var.set_head_sensor(sens))
if power_config := config.get(CONF_POWER):
sens = await sensor.new_sensor(power_config)
cg.add(var.set_power_sensor(sens))
if current_config := config.get(CONF_CURRENT):
sens = await sensor.new_sensor(current_config)
cg.add(var.set_current_sensor(sens))
if speed_config := config.get(CONF_SPEED):
sens = await sensor.new_sensor(speed_config)
cg.add(var.set_speed_sensor(sens))
if voltage_config := config.get(CONF_VOLTAGE):
sens = await sensor.new_sensor(voltage_config)
cg.add(var.set_voltage_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_FLOW, var.set_flow_sensor)
await sensors(CONF_HEAD, var.set_head_sensor)
await sensors(CONF_POWER, var.set_power_sensor)
await sensors(CONF_CURRENT, var.set_current_sensor)
await sensors(CONF_SPEED, var.set_speed_sensor)
await sensors(CONF_VOLTAGE, var.set_voltage_sensor)
+2
View File
@@ -13,6 +13,7 @@ from esphome.components.logger import request_log_listener
from esphome.components.noise import ( # noqa: F401
ENCRYPTION_SCHEMA,
decode_encryption_key,
enable_spare_ephemeral,
encryption_schema,
new_psk_progmem,
validate_encryption_key,
@@ -613,6 +614,7 @@ async def to_code(config: ConfigType) -> None:
# and plaintext disabled. Only a factory reset can remove it.
cg.add_define("USE_API_PLAINTEXT")
cg.add_define("USE_API_NOISE")
enable_spare_ephemeral()
else:
cg.add_define("USE_API_PLAINTEXT")
+6
View File
@@ -317,9 +317,15 @@ class APIConnection final : public APIServerConnectionBase {
void on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg);
#endif
// How long a new connection holds off the spare ephemeral refill
static constexpr uint32_t CONNECT_GRACE_MS = 1000;
bool is_authenticated() {
return static_cast<ConnectionState>(this->flags_.connection_state) == ConnectionState::AUTHENTICATED;
}
// An older unauthenticated connection is a stale half open client and does not count
bool is_still_connecting(uint32_t now) {
return !this->is_authenticated() && now - this->last_traffic_ < CONNECT_GRACE_MS;
}
bool is_connection_setup() {
return static_cast<ConnectionState>(this->flags_.connection_state) == ConnectionState::CONNECTED ||
this->is_authenticated();
+21 -2
View File
@@ -162,6 +162,13 @@ void APIServer::loop() {
this->accept_new_connections_();
}
const bool connected = network::is_connected();
#ifdef USE_NOISE_SPARE_EPHEMERAL
if (connected && !noise::has_spare_ephemeral()) {
this->refill_spare_ephemeral_();
}
#endif
if (this->api_connection_count_ == 0) {
// Check reboot timeout - done in loop to avoid scheduler heap churn
// (cancelled scheduler items sit in heap memory until their scheduled time).
@@ -178,8 +185,7 @@ void APIServer::loop() {
}
// Process clients and remove disconnected ones in a single pass
// Check network connectivity once for all clients
if (!network::is_connected()) {
if (!connected) {
// Network is down - disconnect all clients
for (auto &client : this->active_clients()) {
client->on_fatal_error();
@@ -207,6 +213,19 @@ void APIServer::loop() {
}
}
#ifdef USE_NOISE_SPARE_EPHEMERAL
// An OTA handshake is not visible here and just pays the refill it triggered
void APIServer::refill_spare_ephemeral_() {
const uint32_t now = App.get_loop_component_start_time();
for (auto &client : this->active_clients()) {
if (client->is_still_connecting(now)) {
return;
}
}
noise::prepare_spare_ephemeral();
}
#endif
void APIServer::remove_client_(uint8_t client_index) {
auto &client = this->clients_[client_index];
+4 -1
View File
@@ -5,7 +5,7 @@
#include "api_buffer.h"
// Must precede clients_ so APIConnection is complete for default_delete (libc++).
#include "api_connection.h"
#ifdef USE_API_NOISE
#if defined(USE_API_NOISE) || defined(USE_NOISE_SPARE_EPHEMERAL)
// Only present in the build when the noise component is loaded
#include "esphome/components/noise/noise.h"
#endif
@@ -367,6 +367,9 @@ class APIServer final : public Component
uint8_t provisioning_source_{0};
#endif
#ifdef USE_NOISE_SPARE_EPHEMERAL
void refill_spare_ephemeral_();
#endif
#ifdef USE_API_NOISE
noise::NoiseContext noise_ctx_;
#ifndef USE_API_NOISE_PSK_FROM_YAML
+5 -15
View File
@@ -87,18 +87,8 @@ async def to_code(config: ConfigType) -> None:
if out_of_range_mode_config := config.get(CONF_OUT_OF_RANGE_MODE):
cg.add(var.set_out_of_range_mode(out_of_range_mode_config))
if raw_position_config := config.get(CONF_RAW_POSITION):
sens = await sensor.new_sensor(raw_position_config)
cg.add(var.set_raw_position_sensor(sens))
if gain_config := config.get(CONF_GAIN):
sens = await sensor.new_sensor(gain_config)
cg.add(var.set_gain_sensor(sens))
if magnitude_config := config.get(CONF_MAGNITUDE):
sens = await sensor.new_sensor(magnitude_config)
cg.add(var.set_magnitude_sensor(sens))
if status_config := config.get(CONF_STATUS):
sens = await sensor.new_sensor(status_config)
cg.add(var.set_status_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_RAW_POSITION, var.set_raw_position_sensor)
await sensors(CONF_GAIN, var.set_gain_sensor)
await sensors(CONF_MAGNITUDE, var.set_magnitude_sensor)
await sensors(CONF_STATUS, var.set_status_sensor)
+4
View File
@@ -14,6 +14,10 @@ class AudioDac {
virtual bool is_muted() = 0;
virtual float volume() = 0;
/// Called from the main loop by the speaker driving this DAC once its audio clocks are running.
/// DACs that can only be configured while clocked (e.g. DSP state lost across clock stops) override this.
virtual void on_audio_started() {}
protected:
bool is_muted_{false};
};
+9 -18
View File
@@ -131,24 +131,15 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if temperature_config := config.get(CONF_TEMPERATURE):
sens = await sensor.new_sensor(temperature_config)
cg.add(var.set_temperature_sensor(sens))
cg.add(var.set_temperature_oversampling(temperature_config[CONF_OVERSAMPLING]))
if pressure_config := config.get(CONF_PRESSURE):
sens = await sensor.new_sensor(pressure_config)
cg.add(var.set_pressure_sensor(sens))
cg.add(var.set_pressure_oversampling(pressure_config[CONF_OVERSAMPLING]))
if humidity_config := config.get(CONF_HUMIDITY):
sens = await sensor.new_sensor(humidity_config)
cg.add(var.set_humidity_sensor(sens))
cg.add(var.set_humidity_oversampling(humidity_config[CONF_OVERSAMPLING]))
if gas_resistance_config := config.get(CONF_GAS_RESISTANCE):
sens = await sensor.new_sensor(gas_resistance_config)
cg.add(var.set_gas_resistance_sensor(sens))
sensors = sensor.sub_sensors(config)
if await sensors(CONF_TEMPERATURE, var.set_temperature_sensor):
oversampling = config[CONF_TEMPERATURE][CONF_OVERSAMPLING]
cg.add(var.set_temperature_oversampling(oversampling))
if await sensors(CONF_PRESSURE, var.set_pressure_sensor):
cg.add(var.set_pressure_oversampling(config[CONF_PRESSURE][CONF_OVERSAMPLING]))
if await sensors(CONF_HUMIDITY, var.set_humidity_sensor):
cg.add(var.set_humidity_oversampling(config[CONF_HUMIDITY][CONF_OVERSAMPLING]))
await sensors(CONF_GAS_RESISTANCE, var.set_gas_resistance_sensor)
cg.add(var.set_iir_filter(IIR_FILTER_OPTIONS[config[CONF_IIR_FILTER]]))
@@ -422,6 +422,21 @@ bool BTHomeMiThermometer::handle_service_data_(const ble_device_base::ServiceDat
}
break;
}
case 0x45: { // temperature (0.1C), e.g. Shelly BLU H&T
if (this->temperature_ != nullptr) {
const int16_t raw = encode_uint16(value[1], value[0]);
this->temperature_->publish_state(raw * 0.1f);
reported = true;
}
break;
}
case 0x2E: { // humidity (uint8, 1%), e.g. Shelly BLU H&T
if (this->humidity_ != nullptr) {
this->humidity_->publish_state(value[0]);
reported = true;
}
break;
}
default:
break;
}
+5 -10
View File
@@ -65,17 +65,12 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if eco2_config := config.get(CONF_ECO2):
sens = await sensor.new_sensor(eco2_config)
cg.add(var.set_co2(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_ECO2, var.set_co2)
await sensors(CONF_TVOC, var.set_tvoc)
if tvoc_config := config.get(CONF_TVOC):
sens = await sensor.new_sensor(tvoc_config)
cg.add(var.set_tvoc(sens))
if version_config := config.get(CONF_VERSION):
sens = await text_sensor.new_text_sensor(version_config)
cg.add(var.set_version(sens))
text_sensors = text_sensor.sub_text_sensors(config)
await text_sensors(CONF_VERSION, var.set_version)
if (baseline := config.get(CONF_BASELINE)) is not None:
cg.add(var.set_baseline(baseline))
+3 -11
View File
@@ -368,19 +368,11 @@ optional<ClimateDeviceRestoreState> Climate::restore_state_() {
}
void Climate::save_state_(const ClimateTraits &traits) {
#if (defined(USE_ESP32) || defined(USE_ESP8266)) && !defined(CLANG_TIDY)
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wclass-memaccess"
#define TEMP_IGNORE_MEMACCESS
#endif
ClimateDeviceRestoreState state{};
// initialize as zero (including padding) to prevent random data on stack triggering erase
// initialize as zero (including padding) to prevent random data on stack triggering erase;
// the void * cast tells GCC the bytewise clear of a non-trivial type is intentional
// NOLINTNEXTLINE(bugprone-raw-memory-call-on-non-trivial-type) -- intentional bytewise zero for RTC save
memset(&state, 0, sizeof(ClimateDeviceRestoreState));
#ifdef TEMP_IGNORE_MEMACCESS
#pragma GCC diagnostic pop
#undef TEMP_IGNORE_MEMACCESS
#endif
memset(static_cast<void *>(&state), 0, sizeof(ClimateDeviceRestoreState));
state.mode = this->mode;
if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
+12 -13
View File
@@ -187,19 +187,18 @@ async def dfrobot_sen0395_settings_to_code(
template_ = await cg.templatable(segments[7], args, cg.float_)
cg.add(var.set_det_max4(template_))
if CONF_OUTPUT_LATENCY in config:
template_ = await cg.templatable(
config[CONF_OUTPUT_LATENCY][CONF_DELAY_AFTER_DETECT], args, float
)
if isinstance(template_, cv.TimePeriod):
template_ = template_.total_milliseconds / 1000
cg.add(var.set_delay_after_detect(template_))
template_ = await cg.templatable(
config[CONF_OUTPUT_LATENCY][CONF_DELAY_AFTER_DISAPPEAR], args, float
)
if isinstance(template_, cv.TimePeriod):
template_ = template_.total_milliseconds / 1000
cg.add(var.set_delay_after_disappear(template_))
latency = config[CONF_OUTPUT_LATENCY]
for key, setter in (
(CONF_DELAY_AFTER_DETECT, var.set_delay_after_detect),
(CONF_DELAY_AFTER_DISAPPEAR, var.set_delay_after_disappear),
):
template_ = await cg.templatable(
latency[key],
args,
float,
to_exp=lambda period: period.total_milliseconds / 1000,
)
cg.add(setter(template_))
if CONF_SENSITIVITY in config:
template_ = await cg.templatable(config[CONF_SENSITIVITY], args, cg.int8)
cg.add(var.set_sensitivity(template_))
+4 -2
View File
@@ -287,13 +287,15 @@ template<typename T> class DisplayWriter {
// Type alias for Display writer - uses optimized DisplayWriter instead of std::function
using display_writer_t = DisplayWriter<Display>;
// Callers usually pass `this`; checking a copy keeps GCC's -Wnonnull-compare quiet.
#define LOG_DISPLAY(prefix, type, obj) \
if ((obj) != nullptr) { \
if (auto *log_display_obj = (obj); log_display_obj != nullptr) { \
ESP_LOGCONFIG(TAG, \
prefix type "\n" \
"%s Rotations: %d °\n" \
"%s Dimensions: %dpx x %dpx", \
prefix, (obj)->rotation_, prefix, (obj)->get_width(), (obj)->get_height()); \
prefix, log_display_obj->rotation_, prefix, log_display_obj->get_width(), \
log_display_obj->get_height()); \
}
/// Turn the pixel OFF.
+1 -1
View File
@@ -86,7 +86,7 @@ void DlmsMeterComponent::setup() { this->flush_rx_buffer_(); }
void DlmsMeterComponent::dump_config() {
ESP_LOGCONFIG(TAG, "DLMS Meter:");
ESP_LOGCONFIG(TAG, " Receive Timeout: %u ms", this->receive_timeout_ms_);
ESP_LOGCONFIG(TAG, " Receive Timeout: %" PRIu32 " ms", this->receive_timeout_ms_);
ESP_LOGCONFIG(TAG, " Skip CRC Check: %s", YESNO(this->skip_crc_check_));
for (const auto &pattern : this->custom_patterns_) {
+7 -17
View File
@@ -55,22 +55,12 @@ CONFIG_SCHEMA = EMC2101_COMPONENT_SCHEMA.extend(
async def to_code(config: ConfigType) -> None:
paren = await cg.get_variable(config[CONF_EMC2101_ID])
var = cg.new_Pvariable(config[CONF_ID], paren)
hub = await cg.get_variable(config[CONF_EMC2101_ID])
var = cg.new_Pvariable(config[CONF_ID], hub)
await cg.register_component(var, config)
if CONF_INTERNAL_TEMPERATURE in config:
sens = await sensor.new_sensor(config[CONF_INTERNAL_TEMPERATURE])
cg.add(var.set_internal_temperature_sensor(sens))
if CONF_EXTERNAL_TEMPERATURE in config:
sens = await sensor.new_sensor(config[CONF_EXTERNAL_TEMPERATURE])
cg.add(var.set_external_temperature_sensor(sens))
if CONF_SPEED in config:
sens = await sensor.new_sensor(config[CONF_SPEED])
cg.add(var.set_speed_sensor(sens))
if CONF_DUTY_CYCLE in config:
sens = await sensor.new_sensor(config[CONF_DUTY_CYCLE])
cg.add(var.set_duty_cycle_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_INTERNAL_TEMPERATURE, var.set_internal_temperature_sensor)
await sensors(CONF_EXTERNAL_TEMPERATURE, var.set_external_temperature_sensor)
await sensors(CONF_SPEED, var.set_speed_sensor)
await sensors(CONF_DUTY_CYCLE, var.set_duty_cycle_sensor)
+4 -9
View File
@@ -63,15 +63,10 @@ async def to_code_base(config: ConfigType) -> MockObj:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
if eco2_config := config.get(CONF_ECO2):
sens = await sensor.new_sensor(eco2_config)
cg.add(var.set_co2(sens))
if tvoc_config := config.get(CONF_TVOC):
sens = await sensor.new_sensor(tvoc_config)
cg.add(var.set_tvoc(sens))
if aqi_config := config.get(CONF_AQI):
sens = await sensor.new_sensor(aqi_config)
cg.add(var.set_aqi(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_ECO2, var.set_co2)
await sensors(CONF_TVOC, var.set_tvoc)
await sensors(CONF_AQI, var.set_aqi)
if compensation_config := config.get(CONF_COMPENSATION):
sens = await cg.get_variable(compensation_config[CONF_TEMPERATURE])
+25 -16
View File
@@ -16,6 +16,28 @@ namespace esphome::epaper_spi {
/** Delta for when to regard as gray */
static constexpr uint8_t COLORCONV_GRAY_THRESHOLD = 50;
/** Rec.601 luma (0.299/0.587/0.114 weights, scaled by 256) for optimum perceptual brightness */
constexpr uint8_t rec601_luma(Color color) {
return (uint8_t) ((77u * color.r + 150u * color.g + 29u * color.b + 128u) >> 8);
}
/** Map RGB color to a single monochrome bit
*
* @param color RGB color to convert from
* @return 1 = white, 0 = black
*/
constexpr uint8_t color_to_mono(Color color) { return rec601_luma(color) >= 128 ? 1 : 0; }
/** Map RGB color to one of 4 discrete gray levels (2 bits per pixel)
*
* @param color RGB color to convert from
* @return Gray level: 0 = black, 3 = white
*/
constexpr uint8_t color_to_gray4(Color color) {
const uint8_t level = (uint8_t) ((rec601_luma(color) + 32u) >> 6); // quantize 0..255 to 0..3, rounded
return level > 3 ? 3 : level;
}
/** Map RGB color to discrete BWYR hex 4 color key
*
* @tparam NATIVE_COLOR Type of native hardware color values
@@ -25,7 +47,6 @@ static constexpr uint8_t COLORCONV_GRAY_THRESHOLD = 50;
* @param hw_yellow Native value for yellow
* @param hw_red Native value for red
* @return Converted native hardware color value
* @internal Constexpr. Does not depend on side effects ("pure").
*/
template<typename NATIVE_COLOR>
constexpr NATIVE_COLOR color_to_bwyr(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white, NATIVE_COLOR hw_yellow,
@@ -38,11 +59,7 @@ constexpr NATIVE_COLOR color_to_bwyr(Color color, NATIVE_COLOR hw_black, NATIVE_
if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) {
// It's a shade of gray. Map to BLACK or WHITE.
// We split the luminance at the halfway point (382 = (255*3)/2)
if ((static_cast<int>(color.r) + color.g + color.b) > 382) {
return hw_white;
}
return hw_black;
return color_to_mono(color) ? hw_white : hw_black;
}
// --- Step 2: Check for Primary/Secondary Colors ---
@@ -96,7 +113,6 @@ constexpr NATIVE_COLOR color_to_bwr(Color color, NATIVE_COLOR hw_black, NATIVE_C
* @param hw_green Native value for green
* @param hw_blue Native value for blue
* @return Converted native hardware color value
* @internal Constexpr. Does not depend on side effects ("pure").
*/
template<typename NATIVE_COLOR>
constexpr NATIVE_COLOR color_to_bwyrgb(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white,
@@ -105,10 +121,7 @@ constexpr NATIVE_COLOR color_to_bwyrgb(Color color, NATIVE_COLOR hw_black, NATIV
const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b});
if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) {
if ((static_cast<int>(color.r) + color.g + color.b) > 382) {
return hw_white;
}
return hw_black;
return color_to_mono(color) ? hw_white : hw_black;
}
const bool r_on = (color.r > 128);
@@ -158,7 +171,6 @@ constexpr NATIVE_COLOR color_to_bwyrgb(Color color, NATIVE_COLOR hw_black, NATIV
* @param hw_blue Native value for blue
* @param hw_orange Native value for orange
* @return Converted native hardware color value
* @internal Constexpr. Does not depend on side effects ("pure").
*/
template<typename NATIVE_COLOR>
constexpr NATIVE_COLOR color_to_bwyrgbo(Color color, NATIVE_COLOR hw_black, NATIVE_COLOR hw_white,
@@ -167,10 +179,7 @@ constexpr NATIVE_COLOR color_to_bwyrgbo(Color color, NATIVE_COLOR hw_black, NATI
const auto [min_rgb, max_rgb] = std::minmax({color.r, color.g, color.b});
if ((max_rgb - min_rgb) < COLORCONV_GRAY_THRESHOLD) {
if ((static_cast<int>(color.r) + color.g + color.b) > 382) {
return hw_white;
}
return hw_black;
return color_to_mono(color) ? hw_white : hw_black;
}
const bool r_on = (color.r > 128);
+36 -2
View File
@@ -1,7 +1,9 @@
from collections.abc import Callable
import importlib
import pkgutil
from typing import Any
from esphome import core, pins
from esphome import automation, core, pins
import esphome.codegen as cg
from esphome.components import display, spi
from esphome.components.display import CONF_SHOW_TEST_CARD, validate_rotation
@@ -54,6 +56,12 @@ EPaperBase = epaper_spi_ns.class_(
)
Transform = epaper_spi_ns.enum("Transform")
automation.register_apply_action(
"epaper_spi.full_update_next",
automation.maybe_simple_id({cv.Required(CONF_ID): cv.use_id(EPaperBase)}),
automation.ApplyCall("request_full_update()"),
)
# Import all models dynamically from the models package
for module_info in pkgutil.iter_modules(models.__path__):
importlib.import_module(f".models.{module_info.name}", package=__package__)
@@ -70,6 +78,23 @@ DIMENSION_SCHEMA = cv.Schema(
TRANSFORM_OPTIONS = {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY}
def _full_update_every_validator(
model: models.EpaperModel,
) -> Callable[[Any], int]:
if model.get_default("partial_update"):
return cv.int_range(1, 255)
def validate(value: Any) -> int:
value = cv.int_range(1, 255)(value)
if value != 1:
raise cv.Invalid(
f"{model.name} does not support partial update; full_update_every must be 1"
)
return value
return validate
def model_schema(config):
model = MODELS[config[CONF_MODEL]]
class_name = epaper_spi_ns.class_(model.class_name, EPaperBase)
@@ -96,7 +121,9 @@ def model_schema(config):
cv.Required(CONF_MIRROR_Y): cv.boolean,
}
),
cv.Optional(CONF_FULL_UPDATE_EVERY, default=1): cv.int_range(1, 255),
cv.Optional(
CONF_FULL_UPDATE_EVERY, default=1
): _full_update_every_validator(model),
model.option(CONF_BUSY_PIN): pins.gpio_input_pin_schema,
model.option(CONF_CS_PIN): pins.gpio_output_pin_schema,
model.option(CONF_DC_PIN, fallback=None): pins.gpio_output_pin_schema,
@@ -132,8 +159,15 @@ def customise_schema(config):
extra=cv.ALLOW_EXTRA,
)(config)
model = MODELS[config[CONF_MODEL]]
model.check_requirements()
config = model_schema(config)(config)
config = model.validate_config(config)
width, height = model.get_dimensions(config)
if width % (width_multiple := model.get_default("width_multiple", 1)):
raise cv.Invalid(
f"{model.name} requires a width that is a multiple of {width_multiple}",
path=[CONF_DIMENSIONS],
)
display.add_metadata(
config[CONF_ID],
width,
+11 -2
View File
@@ -196,6 +196,15 @@ void EPaperBase::process_state_() {
break;
case EPaperState::UPDATE:
this->do_update_(); // Calls ESPHome (current page) lambda
if (this->full_update_requested_) {
// Refresh the whole panel even if nothing was drawn
this->full_update_requested_ = false;
this->update_count_ = 0;
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
}
if (this->x_high_ < this->x_low_ || this->y_high_ < this->y_low_) {
this->set_state_(EPaperState::IDLE);
return;
@@ -327,9 +336,9 @@ void HOT EPaperBase::draw_pixel_at(int x, int y, Color color) {
return;
const size_t byte_position = y * this->row_width_ + x / 8;
const uint8_t bit_position = x % 8;
const uint8_t pixel_bit = 0x80 >> bit_position;
const uint8_t pixel_bit = 0x80u >> bit_position;
const auto original = this->buffer_[byte_position];
if ((color_to_bit(color) == 0)) {
if (color_to_mono(color) == 0) {
this->buffer_[byte_position] = original & ~pixel_bit;
} else {
this->buffer_[byte_position] = original | pixel_bit;
+6 -10
View File
@@ -1,5 +1,6 @@
#pragma once
#include "colorconv.h"
#include "esphome/components/display/display.h"
#include "esphome/components/spi/spi.h"
#include "esphome/components/split_buffer/split_buffer.h"
@@ -80,15 +81,7 @@ class EPaperBase : public Display,
DisplayType get_display_type() override { return this->display_type_; };
// Default implementations for monochrome displays
static uint8_t color_to_bit(Color color) {
// It's always a shade of gray. Map to BLACK or WHITE.
// We split the luminance at a suitable point
if ((color.r + color.g + color.b) >= 382) {
return 1;
}
return 0;
}
// Default implementation for monochrome displays
void fill(Color color) override {
// If clipping is active, fall back to base implementation
if (this->get_clipping().is_set()) {
@@ -96,7 +89,7 @@ class EPaperBase : public Display,
return;
}
auto pixel_color = color_to_bit(color) ? 0xFF : 0x00;
auto pixel_color = color_to_mono(color) ? 0xFF : 0x00;
// We store 8 pixels per byte
this->buffer_.fill(pixel_color);
@@ -114,6 +107,8 @@ class EPaperBase : public Display,
int get_width() override { return this->effective_transform_ & SWAP_XY ? this->height_ : this->width_; }
int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; }
void draw_pixel_at(int x, int y, Color color) override;
// Make the next update a full one. Applied when that update starts, so one in progress is not affected.
void request_full_update() { this->full_update_requested_ = true; }
protected:
int get_height_internal() override { return this->height_; };
@@ -185,6 +180,7 @@ class EPaperBase : public Display,
uint8_t transform_{};
uint8_t effective_transform_{};
uint8_t update_count_{};
bool full_update_requested_{};
// these values represent the bounds of the updated buffer. Note that x_high and y_high
// point to the pixel past the last one updated, i.e. may range up to width/height.
uint16_t x_low_{}, y_low_{}, x_high_{}, y_high_{};
@@ -9,8 +9,8 @@ namespace esphome::epaper_spi {
class EPaperMono : public EPaperBase {
public:
EPaperMono(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence,
size_t init_sequence_length)
: EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) {
size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY)
: EPaperBase(name, width, height, init_sequence, init_sequence_length, display_type) {
this->buffer_length_ = (width + 7) / 8 * height; // 8 pixels per byte, rounded up
}
@@ -0,0 +1,101 @@
#include "epaper_spi_ssd1677.h"
#include <algorithm>
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::epaper_spi {
static constexpr const char *const TAG = "epaper_spi.ssd1677";
void EPaperSSD1677::setup() {
EPaperMono::setup();
if (!this->is_failed())
this->init_comparison_frame_();
}
void EPaperSSD1677::init_comparison_frame_() {
if (!this->is_using_partial_update_())
return;
if (!this->sent_.init(this->plane_row_length_() * this->height_)) {
ESP_LOGW(TAG, "No memory for the comparison frame; partial updates will degrade unchanged areas");
}
}
void EPaperSSD1677::plane_row(size_t y, uint8_t *out) {
const size_t row_length = this->plane_row_length_();
const size_t data_idx = y * row_length;
for (size_t i = 0; i != row_length; i++)
out[i] = this->buffer_[data_idx + i];
}
// Nothing a partial update needs is kept in controller RAM any more, so skip the reset for those.
bool EPaperSSD1677::reset() {
if (this->update_count_ != 0 && this->sent_.is_valid())
return true;
return EPaperMono::reset();
}
// The window always covers the whole panel, so each plane is the frame's bytes in order. Where
// those bytes are already stored as the plane needs them (the comparison frame, and a 1-bit
// buffer) they are written straight from the buffer, as many at a time as the time slice allows;
// otherwise they are built a row at a time by plane_row().
bool HOT EPaperSSD1677::transfer_data() {
if (!this->sent_.is_valid())
return EPaperMono::transfer_data();
const auto start_time = millis();
if (this->current_data_index_ == 0) {
if (this->plane_ == 0) {
this->x_low_ = 0;
this->x_high_ = this->width_;
this->y_low_ = 0;
this->y_high_ = this->height_;
}
this->set_window();
this->command(this->plane_ == 0 ? 0x26 : 0x24);
}
// A full update ignores 0x26, and the copy may not hold a real frame yet (first update after
// boot): send the new frame to both planes.
const bool send_copy = this->plane_ == 0 && this->update_count_ != 0;
const bool direct = send_copy || this->buffer_is_plane();
const auto &source = send_copy ? this->sent_ : this->buffer_;
const size_t row_length = this->plane_row_length_();
const size_t plane_length = row_length * this->height_;
// Roughly what the bus moves in one time slice, so a slice is not overrun by much
const size_t max_chunk = std::max<size_t>(this->data_rate_ / 8000 * MAX_TRANSFER_TIME, MAX_TRANSFER_SIZE);
SmallBufferWithHeapFallback<128> row_alloc(direct ? 0 : row_length);
this->start_data_();
while (this->current_data_index_ != plane_length) {
size_t length;
const uint8_t *data;
if (direct) {
data = source.get_span(this->current_data_index_, length);
length = std::min(length, max_chunk);
} else {
// Always at the start of a row here, since this path sends whole rows only
this->plane_row(this->current_data_index_ / row_length, row_alloc.get());
data = row_alloc.get();
length = row_length;
}
this->write_array(data, length);
if (this->plane_ == 1)
this->sent_.write(this->current_data_index_, data, length);
this->current_data_index_ += length;
if (this->current_data_index_ != plane_length && millis() - start_time > MAX_TRANSFER_TIME) {
// Let the main loop run and come back next loop
this->disable();
return false;
}
}
this->disable();
this->current_data_index_ = 0;
if (this->plane_ == 0) {
this->plane_ = 1;
return false;
}
this->plane_ = 0;
return true;
}
} // namespace esphome::epaper_spi
@@ -0,0 +1,49 @@
#pragma once
#include "epaper_spi_mono.h"
namespace esphome::epaper_spi {
/**
* Monochrome SSD1677 with partial refreshes that leave unchanged pixels alone.
*
* A partial refresh drives each pixel from the pair (RAM 0x26 = the image on the panel,
* RAM 0x24 = the new image) across the whole panel; the RAM window only scopes a write.
* EPaperMono writes 0x26 once and afterwards only the changed window of 0x24, which relies on
* the controller's RAM being unchanged from one update to the next. On this controller it is not:
* the hardware reset at the start of each update loses it, and even without resets, keeping 0x26
* in step one window at a time left unchanged areas alternating between older frames. Either way
* unchanged pixels get driven on every partial and wash out.
*
* So before every refresh this class writes both planes over the whole panel: 0x26 from a copy of
* the frame last sent, 0x24 from the buffer. GxEPD2 likewise rewrites both planes after each
* partial on this controller. The copy is taken as the data goes out, not from the buffer, which
* may already hold the next frame by the time the refresh completes.
*/
class EPaperSSD1677 : public EPaperMono {
public:
EPaperSSD1677(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence,
size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY)
: EPaperMono(name, width, height, init_sequence, init_sequence_length, display_type) {}
void setup() override;
protected:
// Allocates the comparison frame when partial updates are enabled. Separate from setup() so it
// can run without a bus.
void init_comparison_frame_();
// Bytes in one row of a RAM plane: 8 pixels per byte, whatever the buffer holds.
size_t plane_row_length_() const { return (this->width_ + 7) / 8; }
// Whether the buffer already holds the frame as a RAM plane does, so it can be sent as it is.
// A subclass with a deeper buffer returns false and overrides plane_row().
virtual bool buffer_is_plane() const { return true; }
// Row y of the frame as a RAM plane holds it, 1 bit per pixel with 1 = white.
virtual void plane_row(size_t y, uint8_t *out);
bool reset() override;
bool transfer_data() override;
split_buffer::SplitBuffer sent_{}; // the frame last sent to 0x24, i.e. what the panel shows
uint8_t plane_{0}; // 0 while sending 0x26, 1 while sending 0x24
};
} // namespace esphome::epaper_spi
@@ -0,0 +1,129 @@
#include "epaper_spi_ssd1677_gray4.h"
#include "esphome/core/log.h"
namespace esphome::epaper_spi {
static constexpr const char *const TAG = "epaper_spi.ssd1677_gray4";
// Combine two source bytes (4 pixels each, 2 bits per pixel, most significant pixel first) into
// one plane byte covering the same 8 pixels (1 bit per pixel), choosing high or low bit
static uint8_t plane_byte(uint8_t first, uint8_t second, bool high_bit) {
uint8_t out = 0;
for (const uint8_t src : {first, second}) {
for (uint8_t shift = 6;; shift -= 2) {
const uint8_t level = (src >> shift) & 0x03;
out = (uint8_t) ((out << 1) | (high_bit ? (level >> 1) : (level & 1)));
if (shift == 0)
break;
}
}
return out;
}
void EPaperSSD1677Gray4::fill(Color color) {
if (this->get_clipping().is_set()) {
// Falls back to the generic per-pixel implementation for the clipped rectangle.
EPaperBase::fill(color);
return;
}
const uint8_t level = color_to_gray4(color);
this->buffer_.fill((uint8_t) (level | (level << 2) | (level << 4) | (level << 6)));
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
}
void HOT EPaperSSD1677Gray4::draw_pixel_at(int x, int y, Color color) {
if (!this->rotate_coordinates_(x, y))
return;
const uint8_t level = color_to_gray4(color);
const size_t byte_position = (size_t) y * this->row_width_ + x / 4;
const uint8_t shift = (uint8_t) (6 - 2 * (x % 4)); // most significant pixel first
const uint8_t original = this->buffer_[byte_position];
this->buffer_[byte_position] = (uint8_t) ((original & ~(0x03 << shift)) | (level << shift));
}
// A partial update reduces each pixel to its high bit: levels 2 and 3 are light, 0 and 1 dark.
void EPaperSSD1677Gray4::plane_row(size_t y, uint8_t *out) {
const size_t src_row = y * this->row_width_;
for (size_t i = 0; i != this->plane_row_length_(); i++)
out[i] = plane_byte(this->buffer_[src_row + 2 * i], this->buffer_[src_row + 2 * i + 1], true);
}
// the high bit of every pixel's level goes to the new (bw) plane
// (0x24), the low bit to the old (red) plane (0x26)
bool HOT EPaperSSD1677Gray4::transfer_data() {
if (this->is_partial_push_())
return EPaperSSD1677::transfer_data();
auto start_time = millis();
const bool first_pass = this->send_red_;
if (this->current_data_index_ == 0) {
if (first_pass) {
// With partial updates enabled the window follows the changed area, but the four-level
// refresh drives every pixel from both planes, and the reset before it does not keep RAM.
this->x_low_ = 0;
this->x_high_ = this->width_;
this->y_low_ = 0;
this->y_high_ = this->height_;
this->set_window();
}
this->command(first_pass ? 0x24 : 0x26);
this->current_data_index_ = this->y_low_;
}
const size_t plane_row_length = (this->x_high_ - this->x_low_) / 8;
// Stack-backed for every panel width in practice; only a custom `dimensions:` far wider than any
// supported panel would fall back to the heap.
SmallBufferWithHeapFallback<128> bytes_to_send_alloc(plane_row_length);
uint8_t *bytes_to_send = bytes_to_send_alloc.get();
ESP_LOGV(TAG, "Writing %u bytes at line %zu at %ums", plane_row_length, this->current_data_index_,
(unsigned) millis());
this->start_data_();
while (this->current_data_index_ != this->y_high_) {
const size_t src_row = this->current_data_index_ * this->row_width_ + this->x_low_ / 4;
for (size_t i = 0; i != plane_row_length; i++) {
const uint8_t plane = plane_byte(this->buffer_[src_row + 2 * i], this->buffer_[src_row + 2 * i + 1], first_pass);
// The OTP grayscale waveform treats data as inverted relative to monochrome
bytes_to_send[i] = (uint8_t) ~plane;
// What the next partial update compares against: the high bits, as a black-and-white frame.
if (first_pass && this->sent_.is_valid())
this->sent_[this->current_data_index_ * plane_row_length + i] = plane;
}
++this->current_data_index_;
this->write_array(bytes_to_send, plane_row_length);
if (millis() - start_time > MAX_TRANSFER_TIME) {
// Let the main loop run and come back next loop
this->disable();
return false;
}
}
this->disable();
this->current_data_index_ = 0;
if (first_pass) {
this->send_red_ = false;
return false;
}
this->send_red_ = true;
return true;
}
void EPaperSSD1677Gray4::refresh_screen(bool partial) {
if (this->is_partial_push_()) {
ESP_LOGV(TAG, "Black-and-white partial refresh");
// The border follows the LUT selected in 0x3C. The model's setting (sent with the init
// sequence) picks the LUT that is white under the four-level waveform's inverted data; under
// the black-and-white waveform that LUT drives black and the border darkens, so use LUT1.
this->cmd_data(0x3C, {0x01});
EPaperSSD1677::refresh_screen(true);
return;
}
ESP_LOGV(TAG, "Four-level refresh");
this->cmd_data(0x1A, {0x67, 0x00}); // force temperature by OTP
this->cmd_data(0x22, {0xD7}); // four-level update sequence, panel's OTP waveform
this->command(0x20); // master activation
}
} // namespace esphome::epaper_spi
@@ -0,0 +1,49 @@
#pragma once
#include "colorconv.h"
#include "epaper_spi_ssd1677.h"
namespace esphome::epaper_spi {
/**
* Four-level grayscale for SSD1677 panels.
*
* The SSD1677 has two independent 1-bit RAM planes, normally used for a
* black/white and a red plane. This class writes image data to both,
* splitting each pixel's 2-bit gray level across them, and triggers the
* panel's own OTP grayscale waveform instead of the normal monochrome update
* sequence. No custom LUT upload needed for any currently supported panel,
* the OTP waveform is used instead.
*
* The framebuffer therefore packs 2 bits per pixel (4 per byte, most
* significant pixel first) instead of EPaperMono's 1 bit.
*
* The grayscale waveform has no partial form: master activation redraws the whole panel
* regardless of the RAM window. So a full update is a four-level refresh, and when partial updates
* are enabled (full_update_every > 1, which the model only allows on explicit request) a partial
* update is EPaperSSD1677's black-and-white one, each pixel reduced to light or dark. The partial
* waveform also drives unchanged pixels towards black or white, so from the first partial update
* the whole panel loses its gray levels until the next full update.
*/
class EPaperSSD1677Gray4 : public EPaperSSD1677 {
public:
EPaperSSD1677Gray4(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence,
size_t init_sequence_length)
: EPaperSSD1677(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_GRAYSCALE) {
this->row_width_ = (width + 3) / 4; // 4 pixels per byte
this->buffer_length_ = (size_t) this->row_width_ * height;
}
void fill(Color color) override;
void draw_pixel_at(int x, int y, Color color) override;
protected:
// A partial update if partial updates are enabled and this is not a full one.
bool is_partial_push_() const { return this->update_count_ != 0 && this->sent_.is_valid(); }
bool buffer_is_plane() const override { return false; }
void plane_row(size_t y, uint8_t *out) override;
void refresh_screen(bool partial) override;
bool transfer_data() override;
};
} // namespace esphome::epaper_spi
@@ -2,7 +2,9 @@ from typing import Any, Self
import esphome.config_validation as cv
from esphome.const import CONF_DIMENSIONS, CONF_HEIGHT, CONF_WIDTH
from esphome.core import CORE
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
class EpaperModel:
@@ -48,6 +50,16 @@ class EpaperModel:
"""
return {}
def validate_config(self, config: ConfigType) -> ConfigType:
"""
Validate the configuration as a whole, once the schema has been applied.
The base implementation accepts it unchanged; specific models override this for
rules that span several options.
:param config: The validated configuration
:return: The configuration, possibly updated
"""
return config
async def to_code(self, var: MockObj, config: dict) -> dict:
"""
Generate model-specific code for the options added by add_options().
@@ -89,3 +101,26 @@ class EpaperModel:
defaults = self.defaults.copy()
defaults.update(kwargs)
return self.__class__(name, initsequence=tuple(initsequence), **defaults)
def check_requirements(self) -> None:
"""
Raise a friendly error if any component this model requires is not configured.
This runs during schema validation (before ID references are resolved) so that a
model whose default pins live on a pin expander reports the missing expander clearly
instead of a cryptic "Couldn't find ID" from the unresolved pin reference.
"""
if requirements := self.get_default("requires", set()):
# ``raw_config`` is populated before any component schema runs during a real
# validation, so presence of a required component is simply a top-level key.
# When it is absent (e.g. a unit test that invokes the schema directly) there
# is no config to check against, so skip.
global_config = CORE.raw_config
if global_config is None:
return
missing = {x for x in requirements if x not in global_config}
if missing:
reqstr = ", ".join(f"'{x}'" for x in sorted(missing))
raise cv.Invalid(
f"{self.name} requires component{'s' if len(missing) > 1 else ''} {reqstr} to be configured"
)
@@ -10,7 +10,9 @@ from . import EpaperModel
class JD79660(EpaperModel):
def __init__(self, name, class_name="EPaperJD79660", fast_update=None, **kwargs):
super().__init__(name, class_name, **kwargs)
# Only a fast_update sequence lets the driver do anything but a full refresh
kwargs.setdefault("partial_update", fast_update is not None)
super().__init__(name, class_name=class_name, **kwargs)
self.fast_update = fast_update
def option(self, name, fallback=cv.UNDEFINED) -> cv.Optional | cv.Required:
@@ -1,12 +1,56 @@
from esphome.const import CONF_DATA_RATE
from typing import Any
import esphome.config_validation as cv
from esphome.const import CONF_DATA_RATE, CONF_FULL_UPDATE_EVERY
from esphome.types import ConfigType
from . import EpaperModel
CONF_BORDER_WAVEFORM = "border_waveform"
CONF_MONOCHROME_PARTIAL_UPDATES = "monochrome_partial_updates"
# partial_update value for models whose partial updates are black and white only
MONOCHROME = "monochrome"
class SSD1677(EpaperModel):
def __init__(self, name, class_name="EPaperMono", data_rate="20MHz", **defaults):
def __init__(
self,
name: str,
class_name: str = "EPaperSSD1677",
data_rate: str = "20MHz",
border_waveform: int = 0x01,
**defaults: Any,
) -> None:
defaults[CONF_DATA_RATE] = data_rate
super().__init__(name, class_name, **defaults)
defaults[CONF_BORDER_WAVEFORM] = border_waveform
defaults.setdefault("partial_update", True)
super().__init__(name, class_name=class_name, **defaults)
def get_config_options(self) -> dict:
options = {
self.option(CONF_BORDER_WAVEFORM): cv.hex_uint8_t,
}
if self.get_default("partial_update") == MONOCHROME:
options[cv.Optional(CONF_MONOCHROME_PARTIAL_UPDATES, default=False)] = (
cv.boolean
)
return options
def validate_config(self, config: ConfigType) -> ConfigType:
if (
self.get_default("partial_update") == MONOCHROME
and config[CONF_FULL_UPDATE_EVERY] > 1
and not config[CONF_MONOCHROME_PARTIAL_UPDATES]
):
raise cv.Invalid(
f"{self.name} can only update partially in black and white, and a partial "
"update reduces the whole panel to black and white until the next full "
f"update. Set '{CONF_MONOCHROME_PARTIAL_UPDATES}: true' to accept this, "
"or leave full_update_every at 1",
path=[CONF_FULL_UPDATE_EVERY],
)
return config
# fmt: off
def get_init_sequence(self, config: dict):
@@ -15,13 +59,14 @@ class SSD1677(EpaperModel):
(0x18, 0x80), # Select internal Temp sensor
(0x0C, 0xAE, 0xC7, 0xC3, 0xC0, 0x80), # inrush current level 2
(0x01, (height - 1) % 256, (height - 1) // 256, 0x02), # Set gate limit (number of rows-1)
(0x3C, 0x01), # Set border waveform
(0x3C, config[CONF_BORDER_WAVEFORM]), # Set border waveform
(0x11, 3), # Set transform
)
ssd1677 = SSD1677("ssd1677")
wave_4_26 = ssd1677.extend(
"waveshare-4.26in",
width=800,
@@ -52,7 +97,8 @@ ssd1677.extend(
mirror_x=True,
)
ssd1677.extend(
# Sticky - monochrome version
seeed_sticky = ssd1677.extend(
"seeed-reterminal-sticky",
width=800,
height=480,
@@ -63,4 +109,15 @@ ssd1677.extend(
reset_pin=17,
busy_pin=18,
data_rate="10MHz",
requires={"psram"},
)
# Sticky - 4 level grayscale; partial updates only in black and white, on request
seeed_sticky.extend(
"seeed-reterminal-sticky-gray4",
class_name="EPaperSSD1677Gray4",
border_waveform=0x00,
partial_update=MONOCHROME,
# each plane byte is built from two whole buffer bytes
width_multiple=8,
)
@@ -6,7 +6,8 @@ from . import EpaperModel
class SSD1683(EpaperModel):
def __init__(self, name, class_name="EPaperSSD1683", data_rate="20MHz", **defaults):
defaults[CONF_DATA_RATE] = data_rate
super().__init__(name, class_name, **defaults)
defaults.setdefault("partial_update", True)
super().__init__(name, class_name=class_name, **defaults)
# fmt: off
def get_init_sequence(self, config: dict):
@@ -32,7 +32,8 @@ class UC8179(EpaperModel):
**defaults: Any,
) -> None:
defaults.setdefault(CONF_DATA_RATE, data_rate)
super().__init__(name, class_name, **defaults)
defaults.setdefault("partial_update", True)
super().__init__(name, class_name=class_name, **defaults)
def get_init_sequence(self, config: dict) -> tuple:
"""Generate the initialization sequence for UC8179 mono displays.
@@ -6,8 +6,12 @@ from . import EpaperModel
class WaveshareModel(EpaperModel):
def __init__(self, name, lut, lut_partial=None, **defaults):
super().__init__(name, "EpaperWaveshare", **defaults)
def __init__(
self, name, lut, lut_partial=None, class_name="EpaperWaveshare", **defaults
):
# A partial LUT is what lets EpaperWaveshare do partial refresh
defaults.setdefault("partial_update", lut_partial is not None)
super().__init__(name, class_name=class_name, **defaults)
self.lut = lut
self.lut_partial = lut_partial
+52 -13
View File
@@ -327,6 +327,34 @@ ARDUINO_EXCLUDED_IDF_COMPONENTS = (
"joltwallet__littlefs", # LittleFS - ESPHome doesn't use filesystem
)
# Entries arduino-esp32 only declares below the given IDF version; stubbing one past
# it clashes with ESPHome's own managed component of the same short name.
ARDUINO_EXCLUDED_IDF_COMPONENTS_MAX_IDF: dict[str, cv.Version] = {
"espressif__libsodium": cv.Version(6, 0, 0),
}
def arduino_bundles_libsodium() -> bool:
"""arduino-esp32 ships its own libsodium below IDF 6.0."""
return (
CORE.using_arduino
and idf_version()
< ARDUINO_EXCLUDED_IDF_COMPONENTS_MAX_IDF["espressif__libsodium"]
)
def arduino_excluded_idf_components() -> set[str]:
"""The arduino-bundled components to stub for this build's IDF version."""
version = idf_version()
return {
component
for component in ARDUINO_EXCLUDED_IDF_COMPONENTS
if (max_version := ARDUINO_EXCLUDED_IDF_COMPONENTS_MAX_IDF.get(component))
is None
or version < max_version
}
# Mapping of Arduino libraries to IDF managed components they require
# When an Arduino library is enabled via cg.add_library(), these components
# are automatically un-stubbed from ARDUINO_EXCLUDED_IDF_COMPONENTS.
@@ -647,20 +675,29 @@ def get_download_types(storage_json):
# No recorded firmware path means nothing was built; no downloads.
if storage_json.firmware_bin_path is None:
return []
return [
{
"title": "Factory format (Previously Modern)",
"description": "For use with ESPHome Web and other tools.",
"file": "firmware.factory.bin",
"download": f"{storage_json.name}.factory.bin",
},
from esphome.espidf.toolchain import tree_skips_bootloader
types = []
# A --skip-bootloader tree deliberately has no factory image; an
# unreadable tree (PlatformIO, capability probes) reads as full.
if not tree_skips_bootloader(Path(storage_json.firmware_bin_path).parent):
types.append(
{
"title": "Factory format (Previously Modern)",
"description": "For use with ESPHome Web and other tools.",
"file": "firmware.factory.bin",
"download": f"{storage_json.name}.factory.bin",
}
)
types.append(
{
"title": "OTA format (Previously Legacy)",
"description": "For OTA updating a device.",
"file": "firmware.ota.bin",
"download": f"{storage_json.name}.ota.bin",
},
]
}
)
return types
def only_on_variant(*, supported=None, unsupported=None, msg_prefix="This feature"):
@@ -3545,9 +3582,7 @@ def _write_idf_component_yml():
}
# Only stub components that are not required by any enabled Arduino library
components_to_stub = (
set(ARDUINO_EXCLUDED_IDF_COMPONENTS) - required_idf_components
)
components_to_stub = arduino_excluded_idf_components() - required_idf_components
stubs_dir = CORE.relative_build_path("component_stubs")
stubs_dir.mkdir(exist_ok=True)
@@ -3612,7 +3647,11 @@ def _write_idf_component_yml():
# Don't process arduino libraries
if name not in ARDUINO_DISABLED_LIBRARIES
]
for component in generate_idf_components(libraries):
# A library also declared as a managed component is not converted too, or
# IDF sees the same requirement twice; converted components reach it through
# ${ESPHOME_PROJECT_MANAGED_COMPONENTS}.
managed = set(CORE.data[KEY_ESP32].get(KEY_COMPONENTS, {}))
for component in generate_idf_components(libraries, managed=managed):
dependencies[component.get_sanitized_name()] = {
"override_path": str(component.path)
}
+1 -1
View File
@@ -30,7 +30,7 @@ static const gpio_hal_context_t GPIO_HAL = {.dev = GPIO_HAL_GET_HW(GPIO_PORT_0)}
bool ESP32InternalGPIOPin::isr_service_installed = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
static gpio_mode_t flags_to_mode(gpio::Flags flags) {
flags = (gpio::Flags)(flags & ~(gpio::FLAG_PULLUP | gpio::FLAG_PULLDOWN));
flags = flags & (gpio::FLAG_INPUT | gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN);
if (flags == gpio::FLAG_INPUT)
return GPIO_MODE_INPUT;
if (flags == gpio::FLAG_OUTPUT)
+7 -2
View File
@@ -474,12 +474,17 @@ def final_validation(config: ConfigType) -> None:
# Check if hosted bluetooth is being used
if "esp32_hosted" in full_config:
from esphome.components.esp32_hosted import uses_esp_hosted_3x
add_idf_sdkconfig_option("CONFIG_BT_CLASSIC_ENABLED", False)
add_idf_sdkconfig_option("CONFIG_BT_BLE_ENABLED", True)
add_idf_sdkconfig_option("CONFIG_BT_BLUEDROID_ENABLED", True)
add_idf_sdkconfig_option("CONFIG_BT_CONTROLLER_DISABLED", True)
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID", True)
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_BLUEDROID_HCI_VHCI", True)
if uses_esp_hosted_3x():
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_FEAT_BT", True)
else:
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID", True)
add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_BLUEDROID_HCI_VHCI", True)
# Check if BLE Client is needed (via esp32_ble_tracker or esp32_ble_client)
has_ble_client = (
+34 -10
View File
@@ -6,16 +6,21 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
#include <esp_bt.h>
#else
#include "esphome/components/watchdog/watchdog.h"
#include <cinttypes>
extern "C" {
#include <esp_hosted.h>
#ifndef CONFIG_ESP_HOSTED_HOST_FEAT_BT
#include <esp_hosted_misc.h>
#include <esp_hosted_bluedroid.h>
#endif
}
#ifdef CONFIG_ESP_HOSTED_HOST_FEAT_BT
#include <esp_hosted_bt_host_stack.h>
#endif
#endif
#include <esp_bt_device.h>
#include <esp_bt_main.h>
@@ -35,7 +40,7 @@ namespace esphome::esp32_ble {
static const char *const TAG = "esp32_ble";
#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifdef CONFIG_BT_CONTROLLER_DISABLED
// Bringing up the remote BT controller issues synchronous RPCs to the
// co-processor with 5 second response timeouts, and the default task watchdog
// is also 5 seconds. If the co-processor firmware does not answer (for example
@@ -43,8 +48,9 @@ static const char *const TAG = "esp32_ble";
// device before the RPC could return an error, causing a boot loop. Raise the
// watchdog for the duration of the bring-up so failures surface as error
// returns instead. 60 seconds covers the worst case: transport reconnect
// (up to ~20s), version preflight (1s), controller init/enable (5s each) and
// the bluedroid host bring-up over the hosted HCI transport.
// (up to ~20s), version preflight (1s), the controller init retry window
// (5s of 5s RPCs), controller enable (5s) and the bluedroid host bring-up
// over the hosted HCI transport.
static constexpr uint32_t HOSTED_BT_WDT_TIMEOUT_MS = 60000;
#endif
@@ -202,10 +208,10 @@ void ESP32BLE::advertising_init_() {
bool ESP32BLE::ble_setup_() {
esp_err_t err;
#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifdef CONFIG_BT_CONTROLLER_DISABLED
watchdog::WatchdogManager wdt(HOSTED_BT_WDT_TIMEOUT_MS);
#endif
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
if (esp_bt_controller_get_status() != ESP_BT_CONTROLLER_STATUS_ENABLED) {
// start bt controller
if (esp_bt_controller_get_status() == ESP_BT_CONTROLLER_STATUS_IDLE) {
@@ -239,8 +245,7 @@ bool ESP32BLE::ble_setup_() {
}
// Fast preflight (1 second RPC timeout): verifies the co-processor answers
// RPCs at all before the 5 second timeout BT controller RPCs below, and
// before hosted_hci_bluedroid_open(), which aborts if the transport is down.
// RPCs at all before the 5 second timeout BT controller RPCs below.
esp_hosted_coprocessor_fwver_t fw_ver{};
if (esp_hosted_get_coprocessor_fwversion(&fw_ver) != ESP_OK) {
ESP_LOGE(TAG, "Co-processor not responding; BLE disabled. Update its firmware with the esp32_hosted "
@@ -249,6 +254,19 @@ bool ESP32BLE::ble_setup_() {
}
ESP_LOGD(TAG, "Co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32, fw_ver.major1, fw_ver.minor1, fw_ver.patch1);
#ifdef CONFIG_ESP_HOSTED_HOST_FEAT_BT
esp_hosted_bt_host_stack_cfg_t bt_cfg{};
bt_cfg.stack = ESP_HOSTED_BT_HOST_STACK_BLUEDROID;
bt_cfg.bring_up_controller = true;
bt_cfg.controller_ready_timeout_ms = EH_BT_CTRL_DEFAULT_READY_TIMEOUT_MS;
if (esp_hosted_bt_host_stack_setup(&bt_cfg) != ESP_OK) {
ESP_LOGE(TAG,
"BT controller bring-up failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32
" may lack BT support. Update it with the esp32_hosted update component; BLE disabled",
fw_ver.major1, fw_ver.minor1, fw_ver.patch1);
return false;
}
#else
if (esp_hosted_bt_controller_init() != ESP_OK) {
ESP_LOGE(TAG,
"BT controller init failed; co-processor firmware %" PRIu32 ".%" PRIu32 ".%" PRIu32
@@ -273,6 +291,7 @@ bool ESP32BLE::ble_setup_() {
.register_host_callback = hosted_hci_bluedroid_register_host_callback,
};
esp_bluedroid_attach_hci_driver(&operations);
#endif
#endif
err = esp_bluedroid_init();
@@ -393,7 +412,7 @@ bool ESP32BLE::ble_setup_() {
}
bool ESP32BLE::ble_dismantle_() {
#ifdef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifdef CONFIG_BT_CONTROLLER_DISABLED
// Same 5 second RPCs as the bring-up path; see HOSTED_BT_WDT_TIMEOUT_MS
watchdog::WatchdogManager wdt(HOSTED_BT_WDT_TIMEOUT_MS);
#endif
@@ -416,7 +435,7 @@ bool ESP32BLE::ble_dismantle_() {
ESP_LOGD(TAG, "Already deinitialized");
}
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
if (esp_bt_controller_get_status() != ESP_BT_CONTROLLER_STATUS_IDLE) {
// stop bt controller
if (esp_bt_controller_get_status() == ESP_BT_CONTROLLER_STATUS_ENABLED) {
@@ -440,6 +459,11 @@ bool ESP32BLE::ble_dismantle_() {
return false;
}
}
#elif defined(CONFIG_ESP_HOSTED_HOST_FEAT_BT)
if (esp_hosted_bt_host_stack_teardown() != ESP_OK) {
ESP_LOGE(TAG, "esp_hosted_bt_host_stack_teardown failed");
return false;
}
#else
if (esp_hosted_bt_controller_disable() != ESP_OK) {
ESP_LOGE(TAG, "esp_hosted_bt_controller_disable failed");
@@ -3,7 +3,7 @@
#ifdef USE_ESP32
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
#include <esp_bt.h>
#endif
#include <esp_bt_main.h>
@@ -35,7 +35,7 @@ void ESP32BLEBeacon::dump_config() {
}
}
*bpos = '\0';
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
ESP_LOGCONFIG(TAG,
" UUID: %s, Major: %u, Minor: %u, Min Interval: %ums, Max Interval: %ums, Measured Power: %d"
", TX Power: %ddBm",
@@ -81,7 +81,7 @@ void ESP32BLEBeacon::on_advertise_() {
ibeacon_adv_data.ibeacon_vendor.measured_power = static_cast<uint8_t>(this->measured_power_);
esp_err_t err;
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
ESP_LOGD(TAG, "Setting BLE TX power");
err = esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, this->tx_power_);
if (err != ESP_OK) {
@@ -5,7 +5,7 @@
#ifdef USE_ESP32
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
#include <esp_bt.h>
#endif
#include <esp_gap_ble_api.h>
@@ -47,7 +47,7 @@ class ESP32BLEBeacon final : public Component {
void set_min_interval(uint16_t val) { this->min_interval_ = val; }
void set_max_interval(uint16_t val) { this->max_interval_ = val; }
void set_measured_power(int8_t val) { this->measured_power_ = val; }
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
void set_tx_power(esp_power_level_t val) { this->tx_power_ = val; }
#endif
void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param);
@@ -61,7 +61,7 @@ class ESP32BLEBeacon final : public Component {
uint16_t min_interval_{};
uint16_t max_interval_{};
int8_t measured_power_{};
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
esp_power_level_t tx_power_{};
#endif
esp_ble_adv_params_t ble_adv_params_;
@@ -10,7 +10,7 @@
#include <nvs_flash.h>
#include <freertos/FreeRTOSConfig.h>
#include <esp_bt_main.h>
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
#include <esp_bt.h>
#endif
#include <freertos/task.h>
@@ -436,6 +436,25 @@ async def to_code(config: ConfigType) -> None:
cg.add_define("USE_ESP32_BLE_SOFTWARE_COEXISTENCE")
# First tagged release per series with espressif/esp-idf@82e71c1767 (see bluedroid_stubs.cpp).
# A series without an entry keeps the guard until a fixed release is tagged; the guard is
# harmless on fixed sources. The 5.4, 5.5 and 6.1 branches carry the fix but have no tag yet.
DIRECT_CONN_FIX_VERSIONS = {
(5, 2): cv.Version(5, 2, 8),
(5, 3): cv.Version(5, 3, 6),
(6, 0): cv.Version(6, 0, 3),
}
DIRECT_CONN_FIX_ALL_FROM = cv.Version(6, 2, 0)
def _needs_direct_conn_guard() -> bool:
ver = idf_version()
if ver >= DIRECT_CONN_FIX_ALL_FROM:
return False
fixed = DIRECT_CONN_FIX_VERSIONS.get((ver.major, ver.minor))
return fixed is None or ver < fixed
# This needs to be run as a job with very low priority so that all components have
# chance to call register_ble_tracker and register_client before the list is checked
# and added to the global defines list.
@@ -452,6 +471,11 @@ async def _add_ble_features() -> None:
if BLEFeatures.ESP_BT_DEVICE in required_features:
cg.add_define("USE_ESP32_BLE_DEVICE")
cg.add_define("USE_ESP32_BLE_UUID")
if cg.get_slot_count(CLIENT_COUNT_DEFINE) and _needs_direct_conn_guard():
# --undefined keeps the wrapper, libsrc.a is scanned before the IDF libraries
cg.add_define("USE_ESP32_BLE_TRACKER_DIRECT_CONN_GUARD")
cg.add_build_flag("-Wl,--wrap=l2cble_init_direct_conn")
cg.add_build_flag("-Wl,--undefined=__wrap_l2cble_init_direct_conn")
ESP32_BLE_START_SCAN_ACTION_SCHEMA = cv.Schema(
@@ -0,0 +1,48 @@
/*
* Bluedroid queues outgoing BLE connections as raw link block pointers and does
* not drop them when the block is released, so btm_send_pending_direct_conn()
* can start a connect on a released block and l2c_link_timeout() later crashes
* on its null timer parameter. Mirrors espressif/esp-idf@82e71c1767; codegen
* only enables it for releases without that commit.
*/
#include "esphome/core/defines.h"
#ifdef USE_ESP32_BLE_TRACKER_DIRECT_CONN_GUARD
#include <esp_idf_version.h>
#include <cstdint>
#include "esphome/core/log.h"
namespace esphome::esp32_ble_tracker {
static const char *const TAG = "esp32_ble_tracker";
} // namespace esphome::esp32_ble_tracker
static_assert(ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(6, 2, 0),
"ESP-IDF 6.2 and later have the fix, this guard should not be enabled (esphome/esphome#19373)");
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
extern "C" {
bool __real_l2cble_init_direct_conn(void *p_lcb);
void l2cu_release_lcb(void *p_lcb);
bool __wrap_l2cble_init_direct_conn(void *p_lcb) {
// in_use is the first member of the private tL2C_LCB (checked ESP-IDF 5.0 to 6.1)
const auto *in_use = static_cast<const uint8_t *>(p_lcb);
if (p_lcb == nullptr || *in_use == 0) {
ESP_LOGW(esphome::esp32_ble_tracker::TAG, "Dropped queued connect on a released link block");
return false;
}
const bool started = __real_l2cble_init_direct_conn(p_lcb);
// Every failure path releases the block except unknown device, also fixed upstream
if (!started && *in_use != 0) {
l2cu_release_lcb(p_lcb);
}
return started;
}
} // extern "C"
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
#endif // USE_ESP32_BLE_TRACKER_DIRECT_CONN_GUARD
@@ -7,7 +7,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
#include <esp_bt.h>
#endif
#include <esp_bt_defs.h>
@@ -635,7 +635,7 @@ void ESP32BLETracker::log_unexpected_state_(const char *operation, ScannerState
#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE
void ESP32BLETracker::update_coex_preference_(bool force_ble) {
#ifndef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#ifndef CONFIG_BT_CONTROLLER_DISABLED
if (force_ble && !this->coex_prefer_ble_) {
ESP_LOGD(TAG, "Setting coexistence to Bluetooth to make connection.");
this->coex_prefer_ble_ = true;
@@ -645,7 +645,7 @@ void ESP32BLETracker::update_coex_preference_(bool force_ble) {
this->coex_prefer_ble_ = false;
esp_coex_preference_set(ESP_COEX_PREFER_BALANCE); // Reset to default
}
#endif // CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
#endif // CONFIG_BT_CONTROLLER_DISABLED
}
#endif
+195 -24
View File
@@ -1,5 +1,6 @@
import os
from pathlib import Path
import re
from esphome import pins
from esphome.components import esp32
@@ -7,15 +8,22 @@ from esphome.components.const import CONF_SLOT, CONF_USE_PSRAM
import esphome.config_validation as cv
from esphome.const import (
CONF_CLK_PIN,
CONF_COMPONENTS,
CONF_CS_PIN,
CONF_FRAMEWORK,
CONF_FREQUENCY,
CONF_MISO_PIN,
CONF_MOSI_PIN,
CONF_NAME,
CONF_REF,
CONF_RESET_PIN,
CONF_TYPE,
CONF_VARIANT,
KEY_ESP32,
)
from esphome.core import CORE
from esphome.cpp_generator import add_define
import esphome.final_validate as fv
from esphome.types import ConfigType
CODEOWNERS = ["@swoboda1337"]
@@ -23,6 +31,9 @@ DEPENDENCIES = ["esp32"]
# esp32_ble raises the task watchdog around the remote BT controller bring-up
AUTO_LOAD = ["watchdog"]
# 3.x is opt-in via a pin under esp32.framework.components (needs ESP-IDF 5.5)
ESP_HOSTED_COMPONENT = "espressif/esp_hosted"
CONF_ACTIVE_HIGH = "active_high"
CONF_BUS_WIDTH = "bus_width"
CONF_CMD_PIN = "cmd_pin"
@@ -145,6 +156,34 @@ CONFIG_SCHEMA = cv.typed_schema(
)
# Version specs that stay within one major (exact, ^, ~, ~=); other ranges are 3.x
_PINNED_MAJOR = re.compile(r"^(?:==|\^|~=|~)?(\d+)(?:\.(?:\d+|\*))*$")
def user_esp_hosted_major() -> int | None:
"""Major version of a user-pinned esp_hosted, or None without a pin."""
try:
full_config = fv.full_config.get()
except LookupError:
full_config = CORE.config
esp32_config = full_config.get(KEY_ESP32) or {}
for component in esp32_config.get(CONF_FRAMEWORK, {}).get(CONF_COMPONENTS, []):
if component.get(CONF_NAME) != ESP_HOSTED_COMPONENT:
continue
ref = component.get(CONF_REF, "").strip()
if not ref:
return 2
if match := _PINNED_MAJOR.match(ref):
return int(match.group(1))
return 3
return None
def uses_esp_hosted_3x() -> bool:
"""Whether the user pinned esp_hosted 3.x."""
return (major := user_esp_hosted_major()) is not None and major >= 3
def _final_validate(config: ConfigType) -> None:
# The esp_hosted releases compatible with older ESP-IDF versions crash at
# boot with a heap double free in the SDIO RX path (fixed in esp_hosted
@@ -155,12 +194,34 @@ def _final_validate(config: ConfigType) -> None:
"Remove the framework version from your configuration to use the "
"recommended version, or pin a version at or above 5.3."
)
if (major := user_esp_hosted_major()) is None or major < 3:
return
if idf_ver < cv.Version(5, 5, 0):
raise cv.Invalid(
f"esp_hosted 3.x requires ESP-IDF 5.5 or newer, got {idf_ver}. "
f"Remove the {ESP_HOSTED_COMPONENT} pin from esp32.framework."
"components to stay on the 2.x line, or use ESP-IDF 5.5 or newer."
)
if not config[CONF_ACTIVE_HIGH]:
raise cv.Invalid(
"esp_hosted 3.x always parks the reset line high with a low pulse, so "
"'active_high: false' cannot be expressed on it. Remove the "
f"{ESP_HOSTED_COMPONENT} pin from esp32.framework.components to stay "
"on the 2.x line."
)
if config[CONF_TYPE] == "sdio" and config[CONF_BUS_WIDTH] == 1:
raise cv.Invalid(
"esp_hosted 3.x cannot be built with a 1-bit SDIO bus "
"(espressif/esp-hosted-mcu#245). Remove the "
f"{ESP_HOSTED_COMPONENT} pin from esp32.framework.components to stay "
"on the 2.x line, or use a 4-bit bus."
)
FINAL_VALIDATE_SCHEMA = _final_validate
def _configure_sdio(config: ConfigType) -> None:
def _configure_sdio_2x(config: ConfigType) -> None:
slot = config[CONF_SLOT]
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_SDIO_SLOT_{slot}",
@@ -202,7 +263,42 @@ def _configure_sdio(config: ConfigType) -> None:
)
def _configure_spi(config: ConfigType) -> None:
def _configure_sdio_3x(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_TRANSPORT_BUS_SDIO", True)
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_SDIO_SLOT_{config[CONF_SLOT]}",
True,
)
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_HOST_SDIO_BUS_WIDTH_{config[CONF_BUS_WIDTH]}",
True,
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SDIO_PIN_CLK", config[CONF_CLK_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SDIO_PIN_CMD", config[CONF_CMD_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D0", config[CONF_D0_PIN]
)
if config[CONF_BUS_WIDTH] == 4:
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D1", config[CONF_D1_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D2", config[CONF_D2_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SDIO_PIN_D3", config[CONF_D3_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SDIO_CLK_KHZ",
int(config[CONF_SDIO_FREQUENCY] // 1000),
)
def _configure_spi_2x(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_SPI_HOST_INTERFACE", True)
# SPI mode is set via per-variant choice options
variant = config[CONF_VARIANT]
@@ -250,11 +346,53 @@ def _configure_spi(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_DR_ACTIVE_LOW", True)
async def to_code(config: ConfigType) -> None:
add_define("USE_ESP32_HOSTED")
transport = config[CONF_TYPE]
transport_prefix = "SDIO" if transport == "sdio" else "SPI"
def _configure_spi_3x(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_TRANSPORT_BUS_SPI", True)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SPI_MODE", config[CONF_SPI_MODE]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SPI_CLK_MHZ", int(config[CONF_FREQUENCY] // 1e6)
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SPI_MOSI_GPIO", config[CONF_MOSI_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SPI_MISO_GPIO", config[CONF_MISO_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SPI_CLK_GPIO", config[CONF_CLK_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_SPI_CS_GPIO", config[CONF_CS_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_HANDSHAKE_GPIO", config[CONF_HANDSHAKE_PIN]
)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_DATA_READY_GPIO", config[CONF_DATA_READY_PIN]
)
# Handshake and data_ready polarity
if config[CONF_HANDSHAKE_ACTIVE_HIGH]:
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_SPI_HANDSHAKE_ACTIVE_HIGH", True
)
else:
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_SPI_HANDSHAKE_ACTIVE_LOW", True
)
if config[CONF_DATA_READY_ACTIVE_HIGH]:
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_SPI_DATAREADY_ACTIVE_HIGH", True
)
else:
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_SPI_DATAREADY_ACTIVE_LOW", True
)
def _configure_2x(config: ConfigType) -> None:
transport_prefix = "SDIO" if config[CONF_TYPE] == "sdio" else "SPI"
# Reset polarity
if config[CONF_ACTIVE_HIGH]:
esp32.add_idf_sdkconfig_option(
@@ -274,24 +412,11 @@ async def to_code(config: ConfigType) -> None:
f"CONFIG_SLAVE_IDF_TARGET_{config[CONF_VARIANT]}", # NOLINT
True,
)
# Transport-specific configuration
if transport == "sdio":
_configure_sdio(config)
if config[CONF_TYPE] == "sdio":
_configure_sdio_2x(config)
else:
_configure_spi(config)
# ESP-NOW-over-hosted shim: only the radio-less ESP32-P4 host needs it (see
# the note by _MAX_CUSTOM_MSG_HANDLERS). Enabled for every P4 host, not
# gated on the `espnow` component being present: the shim is tiny and the
# esp_now_* symbols/CustomRpc calls it defines require these Kconfig options
# to link whenever esp_now_hosted.cpp compiles (which is on any P4 host), so
# coupling the two keeps the build consistent. When `espnow` is absent the
# symbols are simply unused and never register a callback at runtime.
_configure_spi_2x(config)
if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4:
add_define("USE_ESP_NOW_HOSTED")
# esp_now_hosted.cpp includes esp_now.h, which esp_wifi provides
esp32.include_builtin_idf_component("esp_wifi")
# esp-hosted's CustomRpc ("peer data transfer") path — off by default.
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER", True
@@ -299,18 +424,64 @@ async def to_code(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_MAX_CUSTOM_MSG_HANDLERS", _MAX_CUSTOM_MSG_HANDLERS
)
# Place the transport mempool in PSRAM. Required on memory-tight host
# configurations (e.g. P4 with a large LVGL UI) where the internal-RAM
# mempool allocation fails at boot with `sdio_mempool_create` assert.
if config[CONF_USE_PSRAM]:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True)
def _configure_3x(config: ConfigType) -> None:
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_RESET_GPIO", config[CONF_RESET_PIN]
)
# Co-processor variant
esp32.add_idf_sdkconfig_option(
f"CONFIG_ESP_HOSTED_CP_TARGET_{config[CONF_VARIANT]}",
True,
)
if config[CONF_TYPE] == "sdio":
_configure_sdio_3x(config)
else:
_configure_spi_3x(config)
if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4:
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_HOST_FEAT_PEER_DATA", True)
esp32.add_idf_sdkconfig_option(
"CONFIG_ESP_HOSTED_HOST_FEAT_PEER_DATA_MAX_CUSTOM_MSG_HANDLERS",
_MAX_CUSTOM_MSG_HANDLERS,
)
if config[CONF_USE_PSRAM]:
esp32.add_idf_sdkconfig_option("CONFIG_EH_HOST_PORT_DMA_PREFER_SPIRAM", True)
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_DFLT_TASK_FROM_SPIRAM", True)
async def to_code(config: ConfigType) -> None:
add_define("USE_ESP32_HOSTED")
use_3x = uses_esp_hosted_3x()
if use_3x:
_configure_3x(config)
else:
_configure_2x(config)
# ESP-NOW-over-hosted shim: only the radio-less ESP32-P4 host needs it (see
# the note by _MAX_CUSTOM_MSG_HANDLERS). Enabled for every P4 host, not
# gated on the `espnow` component being present: the shim is tiny and the
# esp_now_* symbols/CustomRpc calls it defines require the peer-data Kconfig
# options (set above) to link whenever esp_now_hosted.cpp compiles (which is
# on any P4 host), so coupling the two keeps the build consistent. When
# `espnow` is absent the symbols are simply unused and never register a
# callback at runtime.
if esp32.get_esp32_variant() == esp32.VARIANT_ESP32P4:
add_define("USE_ESP_NOW_HOSTED")
# esp_now_hosted.cpp includes esp_now.h, which esp_wifi provides
esp32.include_builtin_idf_component("esp_wifi")
# Library versions; this component set requires ESP-IDF 5.3 or newer,
# which is enforced at validation time.
idf_ver = esp32.idf_version()
os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}"
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.3")
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.6.5")
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.3")
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.13")
@@ -26,6 +26,8 @@ static const char *const TAG = "esp32_hosted.update";
// Older coprocessor firmware versions have a 1500-byte limit per RPC call
constexpr size_t CHUNK_SIZE = 1500;
// OTA begin blocks while the coprocessor erases its partition
constexpr uint32_t OTA_WDT_TIMEOUT_MS = 60000;
#ifdef USE_ESP32_HOSTED_HTTP_UPDATE
// Interval/timeout IDs (uint32_t to avoid string comparison)
@@ -336,7 +338,11 @@ bool Esp32HostedUpdate::stream_firmware_to_coprocessor_() {
ESP_LOGI(TAG, "Firmware size: %zu bytes", total_size);
// Begin OTA on coprocessor
esp_err_t err = esp_hosted_slave_ota_begin(); // NOLINT
esp_err_t err;
{
watchdog::WatchdogManager wdt(OTA_WDT_TIMEOUT_MS);
err = esp_hosted_slave_ota_begin(); // NOLINT
}
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to begin OTA: %s", esp_err_to_name(err));
container->end();
@@ -425,7 +431,11 @@ bool Esp32HostedUpdate::write_embedded_firmware_to_coprocessor_() {
ESP_LOGI(TAG, "Starting OTA update (%zu bytes)", this->firmware_size_);
esp_err_t err = esp_hosted_slave_ota_begin(); // NOLINT
esp_err_t err;
{
watchdog::WatchdogManager wdt(OTA_WDT_TIMEOUT_MS);
err = esp_hosted_slave_ota_begin(); // NOLINT
}
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to begin OTA: %s", esp_err_to_name(err));
this->status_set_error(LOG_STR("Failed to begin OTA"));
@@ -472,8 +482,6 @@ void Esp32HostedUpdate::perform(bool force) {
this->update_info_.has_progress = false;
this->publish_state();
watchdog::WatchdogManager watchdog(60000);
#ifdef USE_ESP32_HOSTED_HTTP_UPDATE
if (!this->stream_firmware_to_coprocessor_())
#else
+44 -18
View File
@@ -77,22 +77,32 @@ def set_core_data(config: ConfigType) -> ConfigType:
_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.ARDUINO)
_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS)
_resolve_toolchain = cv.resolve_toolchain("ESP8266", _TOOLCHAINS, Toolchain.PLATFORMIO)
_resolve_toolchain = cv.resolve_toolchain("ESP8266", _TOOLCHAINS, Toolchain.ARDUINO)
def _warn_platformio_toolchain(config: ConfigType) -> ConfigType:
# Remove before 2027.4.0
if CORE.using_toolchain_platformio:
_LOGGER.warning(
"The 'platformio' toolchain for ESP8266 is deprecated and will be "
"removed in ESPHome 2027.4.0; the native 'arduino' toolchain is the "
"default."
)
return config
def _validate_native_toolchain(config: ConfigType) -> ConfigType:
"""Constraints of the native (non-PlatformIO) Arduino toolchain."""
if not CORE.using_toolchain_arduino:
return config
from esphome.arduino8266.framework import MIN_FRAMEWORK_VERSION
from esphome.arduino8266.framework import framework_release
conf = config[CONF_FRAMEWORK]
version = cv.Version.parse(conf[CONF_VERSION])
if version < MIN_FRAMEWORK_VERSION:
raise cv.Invalid(
"'toolchain: arduino' requires framework version "
f"{MIN_FRAMEWORK_VERSION} or newer"
)
try:
framework_release(version)
except EsphomeError as err:
raise cv.Invalid(str(err), path=[CONF_FRAMEWORK, CONF_VERSION]) from err
# platform_version is a PlatformIO concept; drop it, warning when a
# custom pin is discarded
if (
@@ -103,6 +113,8 @@ def _validate_native_toolchain(config: ConfigType) -> ConfigType:
"'platform_version' is ignored by 'toolchain: arduino'; the native "
"toolchain downloads the framework and compiler directly"
)
# The native path fetches its own build; source may only be the
# PlatformIO default the schema filled in
if conf[CONF_SOURCE] != _format_framework_arduino_version(version):
raise cv.Invalid(
"'toolchain: arduino' does not support a custom framework source; "
@@ -140,16 +152,27 @@ def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]:
]
def _framework_package_version(ver: cv.Version) -> str:
"""Map an Arduino core version to its PlatformIO registry package version
(3.1.2 -> 3.30102.0; the leading 3 is the package major)."""
if ver.major > 3:
raise EsphomeError(
f"Arduino core {ver} is not supported yet; "
"the newest known core series is 3.x"
)
if ver.major < 3:
raise EsphomeError(
f"Arduino core {ver} is not supported; ESPHome requires core 3.x"
)
return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
def _format_framework_arduino_version(ver: cv.Version) -> str:
# format the given arduino (https://github.com/esp8266/Arduino/releases) version to
# a PIO platformio/framework-arduinoespressif8266 value
# List of package versions: https://api.registry.platformio.org/v3/packages/platformio/tool/framework-arduinoespressif8266
# Same encoding the native toolchain uses for its package download, so a
# custom-source check against this value cannot drift from what it fetches.
from esphome.arduino8266.framework import framework_package_version
try:
return f"~{framework_package_version(ver)}"
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
@@ -160,7 +183,8 @@ def _format_framework_arduino_version(ver: cv.Version) -> str:
# * New framework historically have had some regressions, especially for WiFi.
# The new version needs to be thoroughly validated before changing the
# recommended version as otherwise a bunch of devices could be bricked
# * For all constants below, update platformio.ini (in this repo)
# * For all constants below, update platformio.ini (in this repo) and
# FRAMEWORK_RELEASES in esphome/arduino8266/framework.py
# The default/recommended arduino framework version
# - https://github.com/esp8266/Arduino/releases
@@ -272,6 +296,7 @@ CONFIG_SCHEMA = cv.All(
}
),
_resolve_toolchain,
_warn_platformio_toolchain,
_validate_native_toolchain,
set_core_data,
)
@@ -280,8 +305,8 @@ CONFIG_SCHEMA = cv.All(
def check_rosetta() -> None:
"""Fail fast when the x86_64 ESP8266 toolchain cannot run on this Mac.
There is no native arm64 build of the xtensa-lx106 toolchain; on Apple
Silicon it runs under Rosetta 2, which macOS updates can remove.
PlatformIO's xtensa-lx106 toolchain has no arm64 build; on Apple Silicon
it runs under Rosetta 2, which macOS updates can remove.
"""
if not IS_MACOS or platform.machine() != "arm64":
return
@@ -296,9 +321,10 @@ def check_rosetta() -> None:
return # arch(1) unavailable; let the build proceed
if result.returncode != 0:
raise EsphomeError(
"ESP8266 builds on Apple Silicon Macs use an Intel (x86_64) "
"compiler that requires Rosetta 2, which is not installed on "
"this system. Install it with:\n"
"ESP8266 builds with PlatformIO on Apple Silicon Macs use an Intel "
"(x86_64) compiler that requires Rosetta 2, which is not installed "
"on this system. Either set 'toolchain: arduino' under 'esp8266:', "
"which needs no Rosetta, or install it with:\n"
" softwareupdate --install-rosetta --agree-to-license"
)
+2
View File
@@ -8,6 +8,8 @@ namespace esphome::esp8266 {
static const char *const TAG = "esp8266";
static int flags_to_mode(gpio::Flags flags, uint8_t pin) {
flags =
flags & (gpio::FLAG_INPUT | gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN | gpio::FLAG_PULLUP | gpio::FLAG_PULLDOWN);
if (flags == gpio::FLAG_OUTPUT || flags == (gpio::FLAG_OUTPUT | gpio::FLAG_INPUT)) {
return OUTPUT;
}
+11 -1
View File
@@ -138,6 +138,7 @@ ETHERNET_TYPES = {
"GENERIC": EthernetType.ETHERNET_TYPE_GENERIC,
"YT8531": EthernetType.ETHERNET_TYPE_YT8531,
"CH390": EthernetType.ETHERNET_TYPE_CH390,
"KSZ8851SNL": EthernetType.ETHERNET_TYPE_KSZ8851SNL,
}
# PHY types that need compile-time defines for conditional compilation
@@ -160,6 +161,7 @@ _PHY_TYPE_TO_DEFINE = {
"GENERIC": "USE_ETHERNET_GENERIC",
"YT8531": "USE_ETHERNET_YT8531",
"CH390": "USE_ETHERNET_CH390",
"KSZ8851SNL": "USE_ETHERNET_KSZ8851SNL",
}
@@ -184,13 +186,14 @@ _IDF6_ETHERNET_COMPONENTS: dict[str, IDFRegistryComponent] = {
"ENC28J60": IDFRegistryComponent("espressif/enc28j60", "1.0.1"),
"LAN8670": IDFRegistryComponent("espressif/lan867x", "2.0.0"),
"CH390": IDFRegistryComponent("espressif/ch390", "0.3.0"),
"KSZ8851SNL": IDFRegistryComponent("espressif/ksz8851snl", "1.2.0"),
}
# These types are always external IDF components (never built-in to ESP-IDF)
_ALWAYS_EXTERNAL_IDF_COMPONENTS = {"LAN8670", "ENC28J60", "CH390"}
# ESP32-only SPI ethernet types (W5100 is RP2040-only, no ESP-IDF driver)
SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60", "CH390"}
SPI_ETHERNET_TYPES = {"W5500", "DM9051", "ENC28J60", "CH390", "KSZ8851SNL"}
# RP2-supported ethernet types (SPI and PIO QSPI). Applies to the whole
# RP2 family (RP2040 and RP2350); the chip-specific W5100 caveat in the
# comment above is about ESP-IDF driver coverage, not the RP2 platform.
@@ -534,6 +537,12 @@ SPI_SCHEMA_ENC28J60 = _spi_schema(default_clock="20MHz", max_clock=int(20e6))
# https://www.wch-ic.com/downloads/CH390DS1_PDF.html
SPI_SCHEMA_CH390 = _spi_schema(max_clock=int(72e6))
# The KSZ8851SNL rates fSCLK at 40 MHz maximum, so the shared 80 MHz ceiling is
# out of spec while the 26.67 MHz default is not. KSZ8851SNL/SNLI datasheet
# DS00002381C, table 7-1:
# https://ww1.microchip.com/downloads/aemDocuments/documents/UNG/ProductDocuments/DataSheets/KSZ8851SNL-Single-Port-Ethernet-Controller-with-SPI-DS00002381C.pdf
SPI_SCHEMA_KSZ8851SNL = _spi_schema(max_clock=int(40e6))
CONFIG_SCHEMA = cv.All(
cv.typed_schema(
{
@@ -549,6 +558,7 @@ CONFIG_SCHEMA = cv.All(
"OPENETH": cv.All(BASE_SCHEMA, cv.only_on([Platform.ESP32])),
"DM9051": SPI_SCHEMA,
"CH390": SPI_SCHEMA_CH390,
"KSZ8851SNL": SPI_SCHEMA_KSZ8851SNL,
"ENC28J60": SPI_SCHEMA_ENC28J60,
"W6100": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])),
"W6300": cv.All(SPI_SCHEMA, cv.only_on([Platform.RP2])),
@@ -92,6 +92,7 @@ enum EthernetType : uint8_t {
ETHERNET_TYPE_GENERIC,
ETHERNET_TYPE_YT8531,
ETHERNET_TYPE_CH390,
ETHERNET_TYPE_KSZ8851SNL,
};
struct ManualIP {
@@ -10,6 +10,9 @@
#include <lwip/dns.h>
#include <cinttypes>
#include "esp_event.h"
#ifdef USE_PSRAM
#include <esp_psram.h>
#endif
// IDF 6.0 moved per-chip PHY/MAC drivers to the Espressif Component Registry;
// they are no longer included via esp_eth.h and need explicit includes.
@@ -38,6 +41,10 @@
#include "esp_eth_mac_dm9051.h"
#include "esp_eth_phy_dm9051.h"
#endif
#ifdef USE_ETHERNET_KSZ8851SNL
#include "esp_eth_mac_ksz8851snl.h"
#include "esp_eth_phy_ksz8851snl.h"
#endif
#endif // ESP_IDF_VERSION >= 6.0.0
// LAN867x header exists on all IDF versions (external component since IDF 5.3)
@@ -71,6 +78,32 @@ static const char *const TAG = "ethernet";
// PHY register size for hex logging
static constexpr size_t PHY_REG_SIZE = 2;
// Dual wifi + ethernet SPI builds: the one place internal RAM is short and lwip's other buffers are
// already in PSRAM. Not with L2 TAP, whose filter lives in the glue's input path this replaces.
#if defined(USE_PSRAM) && defined(USE_ETHERNET_SPI) && defined(USE_WIFI) && !defined(CONFIG_ESP_NETIF_L2_TAP)
#define USE_ETHERNET_RX_PSRAM
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) - reported by dump_config()
static bool rx_psram_installed = false;
// ESP-IDF ethernet drivers malloc() every received frame in internal RAM, where it stays until lwIP
// hands it to the application. Move it to PSRAM; if that fails the frame is passed on where it is.
static esp_err_t eth_input_to_psram(esp_eth_handle_t handle, uint8_t *buffer, uint32_t length, void *priv) {
auto *copy = static_cast<uint8_t *>(heap_caps_malloc(length, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
if (copy != nullptr) {
memcpy(copy, buffer, length);
free(buffer); // NOLINT(cppcoreguidelines-no-malloc) - allocated by the driver with malloc()
buffer = copy;
} else {
static bool warned = false; // once, this runs per frame in the driver's task
if (!warned) {
warned = true;
ESP_LOGW(TAG, "PSRAM allocation failed, frame kept in internal RAM (reported once)");
}
}
return esp_netif_receive(static_cast<esp_netif_t *>(priv), buffer, length, nullptr);
}
#endif
void EthernetComponent::log_error_and_mark_failed_(esp_err_t err, const char *message) {
ESP_LOGE(TAG, "%s: (%d) %s", message, err, esp_err_to_name(err));
this->mark_failed();
@@ -235,6 +268,8 @@ void EthernetComponent::ethernet_lazy_init_() {
eth_enc28j60_config_t enc28j60_config = ETH_ENC28J60_DEFAULT_CONFIG(host, &devcfg);
#elif defined(USE_ETHERNET_CH390)
eth_ch390_config_t ch390_config = ETH_CH390_DEFAULT_CONFIG(host, &devcfg);
#elif defined(USE_ETHERNET_KSZ8851SNL)
eth_ksz8851snl_config_t ksz8851snl_config = ETH_KSZ8851SNL_DEFAULT_CONFIG(host, &devcfg);
#endif
#if defined(USE_ETHERNET_W5500)
@@ -261,6 +296,11 @@ void EthernetComponent::ethernet_lazy_init_() {
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
ch390_config.poll_period_ms = this->polling_interval_;
#endif
#elif defined(USE_ETHERNET_KSZ8851SNL)
ksz8851snl_config.int_gpio_num = this->interrupt_pin_;
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
ksz8851snl_config.poll_period_ms = this->polling_interval_;
#endif
#endif
phy_config.phy_addr = this->phy_addr_spi_;
@@ -391,6 +431,12 @@ void EthernetComponent::ethernet_lazy_init_() {
this->phy_ = esp_eth_phy_new_ch390(&phy_config);
break;
}
#elif defined(USE_ETHERNET_KSZ8851SNL)
case ETHERNET_TYPE_KSZ8851SNL: {
mac = esp_eth_mac_new_ksz8851snl(&ksz8851snl_config, &mac_config);
this->phy_ = esp_eth_phy_new_ksz8851snl(&phy_config);
break;
}
#endif
#endif
default: {
@@ -453,6 +499,16 @@ void EthernetComponent::ethernet_lazy_init_() {
/* attach Ethernet driver to TCP/IP stack */
err = esp_netif_attach(this->eth_netif_, esp_eth_new_netif_glue(this->eth_handle_));
ESPHL_ERROR_CHECK(err, "ETH netif attach error");
#ifdef USE_ETHERNET_RX_PSRAM
// The glue frees every receive buffer with free(), so the replacement buffer must come from the heap
if (esp_psram_is_initialized()) {
err = esp_eth_update_input_path(this->eth_handle_, eth_input_to_psram, this->eth_netif_);
rx_psram_installed = err == ESP_OK;
if (!rx_psram_installed) {
ESP_LOGW(TAG, "PSRAM RX path not installed: %s", esp_err_to_name(err));
}
}
#endif
// Register user defined event handers
err = esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, &EthernetComponent::eth_event_handler, nullptr);
@@ -554,6 +610,10 @@ void EthernetComponent::dump_config() {
case ETHERNET_TYPE_CH390:
eth_type = "CH390";
break;
#elif defined(USE_ETHERNET_KSZ8851SNL)
case ETHERNET_TYPE_KSZ8851SNL:
eth_type = "KSZ8851SNL";
break;
#endif
#ifdef USE_ETHERNET_OPENETH
case ETHERNET_TYPE_OPENETH:
@@ -630,6 +690,13 @@ void EthernetComponent::dump_config() {
this->clk_pin_, this->mdc_pin_, this->mdio_pin_, this->phy_addr_);
#endif
ESP_LOGCONFIG(TAG, " Type: %s", eth_type);
#ifdef USE_ETHERNET_RX_PSRAM
// Only known once the driver is up; with enable_on_boot: false that is after this dump
if (this->ethernet_initialized_) {
ESP_LOGCONFIG(TAG, " RX frames: %s",
rx_psram_installed ? LOG_STR_LITERAL("PSRAM") : LOG_STR_LITERAL("internal RAM"));
}
#endif
}
network::IPAddresses EthernetComponent::get_ip_addresses() {
+5 -15
View File
@@ -63,18 +63,8 @@ CONFIG_SCHEMA = cv.Schema(
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_GDK101_ID])
if radiation_dose_per_1m := config.get(CONF_RADIATION_DOSE_PER_1M):
sens = await sensor.new_sensor(radiation_dose_per_1m)
cg.add(hub.set_rad_1m_sensor(sens))
if radiation_dose_per_10m := config.get(CONF_RADIATION_DOSE_PER_10M):
sens = await sensor.new_sensor(radiation_dose_per_10m)
cg.add(hub.set_rad_10m_sensor(sens))
if status_config := config.get(CONF_STATUS):
sens = await sensor.new_sensor(status_config)
cg.add(hub.set_status_sensor(sens))
if measurement_duration_config := config.get(CONF_MEASUREMENT_DURATION):
sens = await sensor.new_sensor(measurement_duration_config)
cg.add(hub.set_measurement_duration_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_RADIATION_DOSE_PER_1M, hub.set_rad_1m_sensor)
await sensors(CONF_RADIATION_DOSE_PER_10M, hub.set_rad_10m_sensor)
await sensors(CONF_STATUS, hub.set_status_sensor)
await sensors(CONF_MEASUREMENT_DURATION, hub.set_measurement_duration_sensor)
+5 -12
View File
@@ -136,15 +136,8 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_oversampling(config[CONF_OVERSAMPLING]))
cg.add(var.set_datarate(auto_data_rate(config)))
cg.add(var.set_range(config[CONF_RANGE]))
if CONF_FIELD_STRENGTH_X in config:
sens = await sensor.new_sensor(config[CONF_FIELD_STRENGTH_X])
cg.add(var.set_x_sensor(sens))
if CONF_FIELD_STRENGTH_Y in config:
sens = await sensor.new_sensor(config[CONF_FIELD_STRENGTH_Y])
cg.add(var.set_y_sensor(sens))
if CONF_FIELD_STRENGTH_Z in config:
sens = await sensor.new_sensor(config[CONF_FIELD_STRENGTH_Z])
cg.add(var.set_z_sensor(sens))
if CONF_HEADING in config:
sens = await sensor.new_sensor(config[CONF_HEADING])
cg.add(var.set_heading_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_FIELD_STRENGTH_X, var.set_x_sensor)
await sensors(CONF_FIELD_STRENGTH_Y, var.set_y_sensor)
await sensors(CONF_FIELD_STRENGTH_Z, var.set_z_sensor)
await sensors(CONF_HEADING, var.set_heading_sensor)
+4 -11
View File
@@ -67,17 +67,10 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if CONF_TEMPERATURE in config:
sens = await sensor.new_sensor(config[CONF_TEMPERATURE])
cg.add(var.set_temperature(sens))
if CONF_HUMIDITY in config:
sens = await sensor.new_sensor(config[CONF_HUMIDITY])
cg.add(var.set_humidity(sens))
if CONF_HEATER in config:
sens = await sensor.new_sensor(config[CONF_HEATER])
cg.add(var.set_heater(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TEMPERATURE, var.set_temperature)
await sensors(CONF_HUMIDITY, var.set_humidity)
await sensors(CONF_HEATER, var.set_heater)
cg.add(var.set_sensor_model(config[CONF_MODEL]))
@@ -77,6 +77,12 @@ void I2SAudioSpeakerBase::loop() {
ESP_LOGV(TAG, "Started");
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING);
this->state_ = speaker::STATE_RUNNING;
#ifdef USE_AUDIO_DAC
// The task raises TASK_RUNNING only after enabling the channel, so the clocks are already running
if (this->audio_dac_ != nullptr) {
this->audio_dac_->on_audio_started();
}
#endif // USE_AUDIO_DAC
}
if (event_group_bits & SpeakerEventGroupBits::TASK_STOPPING) {
ESP_LOGV(TAG, "Stopping");
+4 -11
View File
@@ -59,14 +59,7 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if CONF_BUS_VOLTAGE in config:
sens = await sensor.new_sensor(config[CONF_BUS_VOLTAGE])
cg.add(var.set_bus_voltage_sensor(sens))
if CONF_CURRENT in config:
sens = await sensor.new_sensor(config[CONF_CURRENT])
cg.add(var.set_current_sensor(sens))
if CONF_POWER in config:
sens = await sensor.new_sensor(config[CONF_POWER])
cg.add(var.set_power_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_BUS_VOLTAGE, var.set_bus_voltage_sensor)
await sensors(CONF_CURRENT, var.set_current_sensor)
await sensors(CONF_POWER, var.set_power_sensor)
@@ -71,15 +71,8 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
if CONF_TEMPERATURE in config:
sens = await sensor.new_sensor(config[CONF_TEMPERATURE])
cg.add(var.set_temperature(sens))
if CONF_EXTERNAL_TEMPERATURE in config:
sens = await sensor.new_sensor(config[CONF_EXTERNAL_TEMPERATURE])
cg.add(var.set_external_temperature(sens))
if CONF_HUMIDITY in config:
sens = await sensor.new_sensor(config[CONF_HUMIDITY])
cg.add(var.set_humidity(sens))
if CONF_BATTERY_LEVEL in config:
sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL])
cg.add(var.set_battery_level(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TEMPERATURE, var.set_temperature)
await sensors(CONF_EXTERNAL_TEMPERATURE, var.set_external_temperature)
await sensors(CONF_HUMIDITY, var.set_humidity)
await sensors(CONF_BATTERY_LEVEL, var.set_battery_level)
+6
View File
@@ -21,3 +21,9 @@ async def to_code(config: ConfigType) -> None:
cg.add_library("bblanchon/ArduinoJson", "7.4.3")
cg.add_define("USE_JSON")
cg.add_global(json_ns.using)
def enable_arena() -> None:
"""Compile JsonArena and the allocator constructor of JsonBuilder; only the consumers that build
documents in a stack arena pay for them."""
cg.add_define("USE_JSON_ARENA")
+33 -18
View File
@@ -1,4 +1,6 @@
#include "json_util.h"
#include <cstring>
#include "esphome/core/log.h"
// ArduinoJson::Allocator is included via ArduinoJson.h in json_util.h
@@ -44,11 +46,7 @@ JsonDocument parse_json(const uint8_t *data, size_t len) {
ESP_LOGE(TAG, "No data to parse");
return JsonObject(); // return unbound object
}
#ifdef USE_PSRAM
JsonDocument json_document(&global_json_allocator);
#else
JsonDocument json_document;
#endif
JsonDocument json_document(heap_json_allocator());
if (json_document.overflowed()) {
ESP_LOGE(TAG, "Could not allocate memory for JSON document!");
return JsonObject(); // return unbound object
@@ -66,7 +64,34 @@ JsonDocument parse_json(const uint8_t *data, size_t len) {
// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks,clang-analyzer-core.StackAddressEscape)
}
JsonBuilder::JsonBuilder() = default;
JsonBuilder::JsonBuilder() : doc_(heap_json_allocator()) {}
#ifdef USE_JSON_ARENA
JsonBuilder::JsonBuilder(ArduinoJson::Allocator *allocator) : doc_(allocator) {}
#endif
ArduinoJson::Allocator *heap_json_allocator() {
#ifdef USE_PSRAM
return &global_json_allocator;
#else
return ArduinoJson::detail::DefaultAllocator::instance();
#endif
}
size_t JsonBuilder::serialize_to(char *buf, size_t cap) {
if (doc_.overflowed()) {
ESP_LOGE(TAG, "JSON document overflow");
// Same contract as serializeJson; written by hand so no "{}" literal lives in RAM on ESP8266
size_t n = 0;
if (n < cap)
buf[n++] = '{';
if (n < cap)
buf[n++] = '}';
if (n < cap)
buf[n] = '\0';
return n;
}
return serializeJson(doc_, buf, cap);
}
SerializationBuffer<> JsonBuilder::serialize() {
// ===========================================================================================
@@ -109,17 +134,7 @@ SerializationBuffer<> JsonBuilder::serialize() {
constexpr size_t buf_size = SerializationBuffer<>::BUFFER_SIZE;
SerializationBuffer<> result(buf_size - 1); // Max content size (reserve 1 for null)
if (doc_.overflowed()) {
ESP_LOGE(TAG, "JSON document overflow");
auto *buf = result.data_writable_();
buf[0] = '{';
buf[1] = '}';
buf[2] = '\0';
result.set_size_(2);
return result;
}
size_t size = serializeJson(doc_, result.data_writable_(), buf_size);
size_t size = this->serialize_to(result.data_writable_(), buf_size);
if (size < buf_size) {
// Fits in stack buffer - update size to actual length
result.set_size_(size);
@@ -134,7 +149,7 @@ SerializationBuffer<> JsonBuilder::serialize() {
size_t heap_size = buf_size * 2;
while (heap_size <= max_heap_size) {
result.reallocate_heap_(heap_size - 1);
size = serializeJson(doc_, result.data_writable_(), heap_size);
size = this->serialize_to(result.data_writable_(), heap_size);
if (size < heap_size) {
result.set_size_(size);
return result;
+83 -5
View File
@@ -1,5 +1,7 @@
#pragma once
#include <algorithm>
#include <cstddef>
#include <cstring>
#include <string>
#include <vector>
@@ -165,11 +167,88 @@ inline JsonDocument parse_json(const std::string &data) {
return parse_json(reinterpret_cast<const uint8_t *>(data.c_str()), data.size());
}
/// The allocator a JsonBuilder uses by default (PSRAM first when available)
ArduinoJson::Allocator *heap_json_allocator();
#ifdef USE_JSON_ARENA
/// Size of one ArduinoJson slot pool, the first allocation every document makes (1 KB on 32 bit targets)
constexpr size_t JSON_POOL_BYTES = ARDUINOJSON_POOL_CAPACITY * sizeof(ArduinoJson::detail::VariantData);
/// One pool plus room for copied string nodes; a 40 option select with linked options fits
constexpr size_t JSON_ARENA_SIZE = JSON_POOL_BYTES + 1152;
static_assert(sizeof(void *) != 4 || JSON_ARENA_SIZE == 2176, "the arena was sized for a 1 KB pool");
/// Bump allocator over a fixed buffer for a document built and serialized in one scope. What the
/// buffer cannot hold goes to the heap allocator; nothing is freed until the arena goes away.
template<size_t N> class JsonArena final : public ArduinoJson::Allocator {
public:
// Takes what the buffer cannot hold
explicit JsonArena(ArduinoJson::Allocator *fallback = heap_json_allocator()) : fallback_(fallback) {}
// The document points into buf_
JsonArena(const JsonArena &) = delete;
JsonArena &operator=(const JsonArena &) = delete;
void *allocate(size_t size) override {
if (size > N) {
return this->fallback_->allocate(size); // also keeps the rounding below from wrapping
}
size = (size + ALIGN - 1) & ~(ALIGN - 1);
if (size > N - this->used_) {
return this->fallback_->allocate(size);
}
this->last_ = this->used_;
this->used_ += size;
return this->buf_ + this->last_;
}
void deallocate(void *ptr) override {
if (!this->owns_(ptr)) {
this->fallback_->deallocate(ptr);
}
}
void *reallocate(void *ptr, size_t new_size) override {
if (!this->owns_(ptr)) {
return this->fallback_->reallocate(ptr, new_size);
}
const size_t off = static_cast<uint8_t *>(ptr) - this->buf_;
const size_t size = new_size > N ? N + ALIGN : (new_size + ALIGN - 1) & ~(ALIGN - 1);
const bool newest = off == this->last_;
if (newest && size <= N - off) {
this->used_ = off + size; // the newest block grows or shrinks in place
return ptr;
}
// An older block's size is unknown; copying to the end of the buffer stays in bounds
const size_t old_size = newest ? this->used_ - off : N - off;
void *moved = this->fallback_->allocate(new_size);
if (moved == nullptr) {
return nullptr; // the caller keeps ptr, so its arena space stays reserved
}
std::memcpy(moved, ptr, std::min(new_size, old_size));
if (newest) {
this->used_ = off; // it moved to the heap, so its arena space is free again
}
return moved;
}
/// Bytes of the buffer handed out so far
size_t used() const { return this->used_; }
private:
static constexpr size_t ALIGN = alignof(std::max_align_t);
bool owns_(const void *ptr) const { return ptr >= this->buf_ && ptr < this->buf_ + N; }
ArduinoJson::Allocator *fallback_;
alignas(ALIGN) uint8_t buf_[N];
size_t used_{0};
size_t last_{0};
};
#endif // USE_JSON_ARENA
/// Builder class for creating JSON documents without lambdas
class JsonBuilder {
public:
// Out of line: inlining the JsonDocument constructor duplicates it at every call site
JsonBuilder();
#ifdef USE_JSON_ARENA
// The builder must not outlive the allocator
explicit JsonBuilder(ArduinoJson::Allocator *allocator);
#endif
JsonObject root() {
if (!root_created_) {
@@ -179,17 +258,16 @@ class JsonBuilder {
return root_;
}
/// Serialize into a caller owned buffer. Returns the length; cap or more means it did not fit
/// and buf holds a truncated, unterminated copy. An overflowed document serializes as "{}".
size_t serialize_to(char *buf, size_t cap);
/// Serialize the JSON document to a SerializationBuffer (stack-first allocation)
/// Uses 512-byte stack buffer by default, falls back to heap for larger JSON
SerializationBuffer<> serialize();
private:
#ifdef USE_PSRAM
SpiRamAllocator allocator_;
JsonDocument doc_{&allocator_};
#else
JsonDocument doc_;
#endif
JsonObject root_;
bool root_created_{false};
};
+8 -28
View File
@@ -101,31 +101,11 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await modbus.register_modbus_client_device(var, config)
if CONF_PH in config:
conf = config[CONF_PH]
sens = await sensor.new_sensor(conf)
cg.add(var.set_ph_sensor(sens))
if CONF_TEMPERATURE in config:
conf = config[CONF_TEMPERATURE]
sens = await sensor.new_sensor(conf)
cg.add(var.set_temperature_sensor(sens))
if CONF_DIS1 in config:
conf = config[CONF_DIS1]
sens = await sensor.new_sensor(conf)
cg.add(var.set_dis1_sensor(sens))
if CONF_DIS2 in config:
conf = config[CONF_DIS2]
sens = await sensor.new_sensor(conf)
cg.add(var.set_dis2_sensor(sens))
if CONF_REDOX in config:
conf = config[CONF_REDOX]
sens = await sensor.new_sensor(conf)
cg.add(var.set_redox_sensor(sens))
if CONF_EC in config:
conf = config[CONF_EC]
sens = await sensor.new_sensor(conf)
cg.add(var.set_ec_sensor(sens))
if CONF_OCI in config:
conf = config[CONF_OCI]
sens = await sensor.new_sensor(conf)
cg.add(var.set_oci_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_PH, var.set_ph_sensor)
await sensors(CONF_TEMPERATURE, var.set_temperature_sensor)
await sensors(CONF_DIS1, var.set_dis1_sensor)
await sensors(CONF_DIS2, var.set_dis2_sensor)
await sensors(CONF_REDOX, var.set_redox_sensor)
await sensors(CONF_EC, var.set_ec_sensor)
await sensors(CONF_OCI, var.set_oci_sensor)
+2 -3
View File
@@ -40,9 +40,8 @@ CONFIG_SCHEMA = (
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_LD6002B_ID])
if target_config := config.get(CONF_TARGET):
sens = await binary_sensor.new_binary_sensor(target_config)
cg.add(hub.set_presence_binary_sensor(sens))
binary_sensors = binary_sensor.sub_binary_sensors(config)
await binary_sensors(CONF_TARGET, hub.set_presence_binary_sensor)
for i in range(MAX_TARGETS):
if target_config := config.get(f"target_{i + 1}"):
+3 -7
View File
@@ -154,13 +154,9 @@ CONFIG_SCHEMA = (
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_LD6002B_ID])
if target_count_config := config.get(CONF_TARGET_COUNT):
sens = await sensor.new_sensor(target_count_config)
cg.add(hub.set_target_count_sensor(sens))
if point_count_config := config.get(CONF_POINT_COUNT):
sens = await sensor.new_sensor(point_count_config)
cg.add(hub.set_point_count_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_TARGET_COUNT, hub.set_target_count_sensor)
await sensors(CONF_POINT_COUNT, hub.set_point_count_sensor)
for i in range(MAX_TARGETS):
if target_config := config.get(f"target_{i + 1}"):
+3 -6
View File
@@ -24,9 +24,6 @@ CONFIG_SCHEMA = cv.Schema(
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_LD6002B_ID])
if work_mode_config := config.get(CONF_WORK_MODE):
sens = await text_sensor.new_text_sensor(work_mode_config)
cg.add(hub.set_work_mode_text_sensor(sens))
if ota_config := config.get(CONF_OTA_VERSION):
sens = await text_sensor.new_text_sensor(ota_config)
cg.add(hub.set_ota_version_text_sensor(sens))
text_sensors = text_sensor.sub_text_sensors(config)
await text_sensors(CONF_WORK_MODE, hub.set_work_mode_text_sensor)
await text_sensors(CONF_OTA_VERSION, hub.set_ota_version_text_sensor)
@@ -8,6 +8,8 @@ namespace esphome::libretiny {
static const char *const TAG = "libretiny.gpio";
static int IRAM_ATTR flags_to_mode(gpio::Flags flags) {
flags =
flags & (gpio::FLAG_INPUT | gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN | gpio::FLAG_PULLUP | gpio::FLAG_PULLDOWN);
if (flags == gpio::FLAG_INPUT) {
return INPUT;
} else if (flags == gpio::FLAG_OUTPUT) {
@@ -163,6 +163,11 @@ struct StrobeLightEffectColor {
class StrobeLightEffect : public LightEffect {
public:
explicit StrobeLightEffect(const char *name) : LightEffect(name) {}
void start() override {
// Place the cycle at the end of the last color, so the first apply() switches straight to the first color
this->at_color_ = this->colors_.size() - 1;
this->last_switch_ = millis() - this->colors_.back().duration;
}
void apply() override {
const uint32_t now = millis();
if (now - this->last_switch_ < this->colors_[this->at_color_].duration)
+85 -47
View File
@@ -31,9 +31,10 @@ from esphome.const import (
)
from esphome.cpp_generator import MockObjClass
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
from esphome.util import Registry
from .automation import validate_light_state
from .automation import COLOR_SCHEMA, color_to_rgb, validate_light_state
from .types import (
COLOR_MODES,
AddressableColorWipeEffect,
@@ -88,6 +89,28 @@ MONOCHROMATIC_EFFECTS = []
RGB_EFFECTS = []
ADDRESSABLE_EFFECTS = []
def _with_color_option(schema: cv.Schema, *default_keys: str) -> cv.Schema:
"""Add the `color:` option to a per-item effect color schema.
`color_to_rgb` must run before the keys are defaulted to full level below, so a
`color:` can populate `default_keys` (red/green/blue and, where the effect has
one, color_brightness) and a plain channel list still defaults them to full
level. Centralizing both steps here means a new effect schema only has to call
this once instead of repeating the ordering rule itself.
"""
schema = schema.extend(COLOR_SCHEMA)
schema.add_extra(color_to_rgb)
def _default_to_full_level(config: ConfigType) -> ConfigType:
for key in default_keys:
config.setdefault(key, 1.0)
return config
schema.add_extra(_default_to_full_level)
return schema
EFFECTS_REGISTRY = Registry()
@@ -267,6 +290,30 @@ async def random_effect_to_code(config, effect_id):
return effect
STROBE_COLOR_SCHEMA = cv.Schema(
{
cv.Optional(CONF_STATE, default=True): validate_light_state,
cv.Optional(CONF_BRIGHTNESS, default=1.0): cv.percentage,
cv.Optional(CONF_COLOR_MODE): cv.enum(COLOR_MODES, upper=True, space="_"),
cv.Optional(CONF_COLOR_BRIGHTNESS): cv.percentage,
cv.Optional(CONF_RED): cv.percentage,
cv.Optional(CONF_GREEN): cv.percentage,
cv.Optional(CONF_BLUE): cv.percentage,
cv.Optional(CONF_WHITE, default=1.0): cv.percentage,
cv.Optional(CONF_COLOR_TEMPERATURE): cv.color_temperature,
cv.Optional(CONF_COLD_WHITE, default=1.0): cv.percentage,
cv.Optional(CONF_WARM_WHITE, default=1.0): cv.percentage,
cv.Required(CONF_DURATION): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_TRANSITION_LENGTH, default="0s"
): cv.positive_time_period_milliseconds,
}
)
STROBE_COLOR_SCHEMA = _with_color_option(
STROBE_COLOR_SCHEMA, CONF_COLOR_BRIGHTNESS, CONF_RED, CONF_GREEN, CONF_BLUE
)
@register_binary_effect(
"strobe",
StrobeLightEffect,
@@ -280,29 +327,7 @@ async def random_effect_to_code(config, effect_id):
],
): cv.All(
cv.ensure_list(
cv.Schema(
{
cv.Optional(CONF_STATE, default=True): validate_light_state,
cv.Optional(CONF_BRIGHTNESS, default=1.0): cv.percentage,
cv.Optional(CONF_COLOR_MODE): cv.enum(
COLOR_MODES, upper=True, space="_"
),
cv.Optional(CONF_COLOR_BRIGHTNESS, default=1.0): cv.percentage,
cv.Optional(CONF_RED, default=1.0): cv.percentage,
cv.Optional(CONF_GREEN, default=1.0): cv.percentage,
cv.Optional(CONF_BLUE, default=1.0): cv.percentage,
cv.Optional(CONF_WHITE, default=1.0): cv.percentage,
cv.Optional(CONF_COLOR_TEMPERATURE): cv.color_temperature,
cv.Optional(CONF_COLD_WHITE, default=1.0): cv.percentage,
cv.Optional(CONF_WARM_WHITE, default=1.0): cv.percentage,
cv.Required(
CONF_DURATION
): cv.positive_time_period_milliseconds,
cv.Optional(
CONF_TRANSITION_LENGTH, default="0s"
): cv.positive_time_period_milliseconds,
}
),
STROBE_COLOR_SCHEMA,
cv.has_at_least_one_key(
CONF_STATE,
CONF_BRIGHTNESS,
@@ -406,6 +431,27 @@ async def addressable_rainbow_effect_to_code(config, effect_id):
return var
ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA = cv.Schema(
{
cv.Optional(CONF_COLOR_BRIGHTNESS): cv.percentage,
cv.Optional(CONF_RED): cv.percentage,
cv.Optional(CONF_GREEN): cv.percentage,
cv.Optional(CONF_BLUE): cv.percentage,
cv.Optional(CONF_WHITE, default=1.0): cv.percentage,
cv.Optional(CONF_RANDOM, default=False): cv.boolean,
cv.Required(CONF_NUM_LEDS): cv.All(cv.uint32_t, cv.Range(min=1)),
cv.Optional(CONF_GRADIENT, default=False): cv.boolean,
}
)
ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA = _with_color_option(
ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA,
CONF_COLOR_BRIGHTNESS,
CONF_RED,
CONF_GREEN,
CONF_BLUE,
)
@register_addressable_effect(
"addressable_color_wipe",
AddressableColorWipeEffect,
@@ -413,17 +459,7 @@ async def addressable_rainbow_effect_to_code(config, effect_id):
{
cv.Optional(
CONF_COLORS, default=[{CONF_NUM_LEDS: 1, CONF_RANDOM: True}]
): cv.ensure_list(
{
cv.Optional(CONF_RED, default=1.0): cv.percentage,
cv.Optional(CONF_GREEN, default=1.0): cv.percentage,
cv.Optional(CONF_BLUE, default=1.0): cv.percentage,
cv.Optional(CONF_WHITE, default=1.0): cv.percentage,
cv.Optional(CONF_RANDOM, default=False): cv.boolean,
cv.Required(CONF_NUM_LEDS): cv.All(cv.uint32_t, cv.Range(min=1)),
cv.Optional(CONF_GRADIENT, default=False): cv.boolean,
}
),
): cv.ensure_list(ADDRESSABLE_COLOR_WIPE_COLOR_SCHEMA),
cv.Optional(
CONF_ADD_LED_INTERVAL, default="0.1s"
): cv.positive_time_period_milliseconds,
@@ -434,19 +470,21 @@ async def addressable_color_wipe_effect_to_code(config, effect_id):
var = cg.new_Pvariable(effect_id, config[CONF_NAME])
cg.add(var.set_add_led_interval(config[CONF_ADD_LED_INTERVAL]))
cg.add(var.set_reverse(config[CONF_REVERSE]))
colors = [
cg.StructInitializer(
AddressableColorWipeEffectColor,
("r", int(round(color[CONF_RED] * 255))),
("g", int(round(color[CONF_GREEN] * 255))),
("b", int(round(color[CONF_BLUE] * 255))),
("w", int(round(color[CONF_WHITE] * 255))),
("random", color[CONF_RANDOM]),
("num_leds", color[CONF_NUM_LEDS]),
("gradient", color[CONF_GRADIENT]),
colors = []
for color in config.get(CONF_COLORS, []):
color_brightness = color[CONF_COLOR_BRIGHTNESS]
colors.append(
cg.StructInitializer(
AddressableColorWipeEffectColor,
("r", int(round(color[CONF_RED] * color_brightness * 255))),
("g", int(round(color[CONF_GREEN] * color_brightness * 255))),
("b", int(round(color[CONF_BLUE] * color_brightness * 255))),
("w", int(round(color[CONF_WHITE] * 255))),
("random", color[CONF_RANDOM]),
("num_leds", color[CONF_NUM_LEDS]),
("gradient", color[CONF_GRADIENT]),
)
)
for color in config.get(CONF_COLORS, [])
]
cg.add(var.set_colors(colors))
return var
+25 -8
View File
@@ -156,11 +156,13 @@ void LightCall::perform() {
ESP_LOGV(TAG, " Effect: '%.*s'", (int) effect_s.size(), effect_s.c_str());
}
this->parent_->start_effect_(this->effect_);
if (this->effect_ != this->parent_->active_effect_index_) {
this->parent_->start_effect_(this->effect_);
// Also set light color values when starting an effect
// For example to turn off the light
this->parent_->set_immediately_(v, true);
// Also set light color values when starting an effect
// For example to turn off the light
this->parent_->set_immediately_(v, true);
}
} else {
// INSTANT CHANGE
this->parent_->set_immediately_(v, publish);
@@ -193,10 +195,9 @@ LightColorValues LightCall::validate_() {
auto *name = this->parent_->get_name().c_str();
auto traits = this->parent_->get_traits();
#ifdef USE_LIGHT_RESUME_EFFECT
// Snapshot before the adjustments below add flags of their own
const bool sets_values = (this->flags_ & VALUE_FLAGS_MASK) != 0;
const bool plain_turn_on = this->has_state() && this->state_ && (this->flags_ & ~STATE_ONLY_FLAGS_MASK) == 0;
#endif // USE_LIGHT_RESUME_EFFECT
// Color mode check
if (this->has_color_mode() && !traits.supports_color_mode(this->color_mode_)) {
@@ -348,9 +349,25 @@ LightColorValues LightCall::validate_() {
}
#endif // USE_LIGHT_RESUME_EFFECT
// If effect is already active, remove effect start
// A plain turn-on of a lit light keeps a running effect as it is.
// Effects' own calls don't publish, so they are not caught here.
if (plain_turn_on && this->get_publish_() && this->parent_->remote_values.is_on() &&
this->parent_->active_effect_index_ != 0) {
this->effect_ = this->parent_->active_effect_index_;
this->set_flag_(FLAG_HAS_EFFECT);
}
// If effect is already active, remove effect start. When a lit light that stays on gets no new values or flash,
// keep the flag and drop any transition, which has nothing to fade to; perform() then leaves the running effect
// undisturbed. A call that turns the light off keeps no flag, so the turn-off block below stops the effect.
// has_brightness() catches the brightness added above to make the turn-on visible.
if (this->has_effect_() && this->effect_ == this->parent_->active_effect_index_) {
this->clear_flag_(FLAG_HAS_EFFECT);
if (sets_values || this->has_brightness() || this->has_flash_() || this->effect_ == 0 ||
!this->parent_->remote_values.is_on() || !v.is_on()) {
this->clear_flag_(FLAG_HAS_EFFECT);
} else {
this->clear_flag_(FLAG_HAS_TRANSITION);
}
}
// validate effect index
+1
View File
@@ -217,6 +217,7 @@ class LightCall {
static constexpr uint16_t CLAMP_FLAGS_MASK = 0x00FFu; // bits 0-7
// Flags a plain turn-on may carry; any other flag means the caller asked for something specific
static constexpr uint16_t STATE_ONLY_FLAGS_MASK = FLAG_HAS_STATE | FLAG_PUBLISH | FLAG_SAVE;
static constexpr uint16_t VALUE_FLAGS_MASK = CLAMP_FLAGS_MASK | FLAG_HAS_COLOR_TEMPERATURE | FLAG_HAS_COLOR_MODE;
inline bool has_transition_() { return (this->flags_ & FLAG_HAS_TRANSITION) != 0; }
inline bool has_flash_() { return (this->flags_ & FLAG_HAS_FLASH) != 0; }
+9 -2
View File
@@ -20,6 +20,13 @@ __attribute__((weak)) void print_coredump() {}
namespace esphome::logger {
// Zephyr 3.7 renamed z_arch_esf_t to struct arch_esf; the old name was later removed.
#if KERNEL_VERSION_NUMBER >= 0x030700
using FatalErrorEsf = ::arch_esf;
#else
using FatalErrorEsf = z_arch_esf_t;
#endif
__attribute__((section(".noinit"))) struct {
uint32_t magic;
uint32_t reason;
@@ -168,7 +175,7 @@ void Logger::dump_crash_() {
}
}
void k_sys_fatal_error_handler(unsigned int reason, const z_arch_esf_t *esf) {
void k_sys_fatal_error_handler(unsigned int reason, const FatalErrorEsf *esf) {
crash_buf.magic = App.get_config_hash();
crash_buf.reason = reason;
if (esf) {
@@ -192,7 +199,7 @@ void k_sys_fatal_error_handler(unsigned int reason, const z_arch_esf_t *esf) {
extern "C" {
void k_sys_fatal_error_handler(unsigned int reason, const z_arch_esf_t *esf) {
void k_sys_fatal_error_handler(unsigned int reason, const esphome::logger::FatalErrorEsf *esf) {
esphome::logger::k_sys_fatal_error_handler(reason, esf);
}
}
+7 -23
View File
@@ -229,29 +229,13 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if als_config := config.get(CONF_AMBIENT_LIGHT):
sens = await sensor.new_sensor(als_config)
cg.add(var.set_ambient_light_sensor(sens))
if infrared_cnt_config := config.get(CONF_INFRARED_COUNTS):
sens = await sensor.new_sensor(infrared_cnt_config)
cg.add(var.set_infrared_counts_sensor(sens))
if full_spect_cnt_config := config.get(CONF_FULL_SPECTRUM_COUNTS):
sens = await sensor.new_sensor(full_spect_cnt_config)
cg.add(var.set_full_spectrum_counts_sensor(sens))
if act_gain_config := config.get(CONF_ACTUAL_GAIN):
sens = await sensor.new_sensor(act_gain_config)
cg.add(var.set_actual_gain_sensor(sens))
if act_itime_config := config.get(CONF_ACTUAL_INTEGRATION_TIME):
sens = await sensor.new_sensor(act_itime_config)
cg.add(var.set_actual_integration_time_sensor(sens))
if prox_cnt_config := config.get(CONF_PS_COUNTS):
sens = await sensor.new_sensor(prox_cnt_config)
cg.add(var.set_proximity_counts_sensor(sens))
sensors = sensor.sub_sensors(config)
await sensors(CONF_AMBIENT_LIGHT, var.set_ambient_light_sensor)
await sensors(CONF_INFRARED_COUNTS, var.set_infrared_counts_sensor)
await sensors(CONF_FULL_SPECTRUM_COUNTS, var.set_full_spectrum_counts_sensor)
await sensors(CONF_ACTUAL_GAIN, var.set_actual_gain_sensor)
await sensors(CONF_ACTUAL_INTEGRATION_TIME, var.set_actual_integration_time_sensor)
await sensors(CONF_PS_COUNTS, var.set_proximity_counts_sensor)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
+11 -10
View File
@@ -61,7 +61,6 @@ from .defines import (
get_focused_widgets,
get_lv_images_used,
get_refreshed_widgets,
set_widgets_completed,
)
from .encoders import (
ENCODERS_CONFIG,
@@ -108,6 +107,7 @@ from .widgets import (
get_screen_active,
set_obj_properties,
)
from .widgets.keyboard import attach_textareas
# Import only what we actually use directly in this file
from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code
@@ -238,6 +238,7 @@ def multi_conf_validate(configs: list[dict]):
CONF_COLOR_DEPTH,
CONF_BYTE_ORDER,
df.CONF_TRANSPARENCY_KEY,
df.CONF_DEBUG_OUTLINE,
):
if base_config[item] != config[item]:
raise cv.Invalid(
@@ -393,6 +394,12 @@ async def to_code(configs):
df.add_define("LV_FONT_DEFAULT", await lvalid.lv_font.process(default_font))
cg.add(lvgl_static.esphome_lvgl_init())
default_group = get_default_group(config_0)
df.get_options()[df.CONF_DEBUG_OUTLINE] = config_0[df.CONF_DEBUG_OUTLINE]
# Create theme lambdas before any widgets.
async with LvContext():
for config in configs:
await theme_to_code(config)
for config in configs:
frac = config[CONF_BUFFER_SIZE]
@@ -446,7 +453,6 @@ async def to_code(configs):
await touchscreens_to_code(lv_component, config)
await encoders_to_code(lv_component, config, default_group)
await keypads_to_code(lv_component, config, default_group)
await theme_to_code(config)
await gradients_to_code(config)
await styles_to_code(config)
await set_obj_properties(lv_scr_act, config)
@@ -457,20 +463,14 @@ async def to_code(configs):
await msgboxes_to_code(lv_component, config)
await animations_to_code(config.get(CONF_ANIMATIONS, []))
# Mark all widgets as completed so awaiters of ``wait_for_widgets`` proceed.
set_widgets_completed(True)
async with LvContext():
# Local import: lv_list imports meter, which imports obj_spec/set_obj_properties
# from this module's own namespace - a top-level import here would be circular.
# Local import to avoid circularity
from .widgets.lv_list import finish_list_triggers
# Must run before generate_triggers(): that's what actually processes other
# widgets' on_click etc. automations, which can include lvgl.list.add/remove/
# clear actions that fire a list's on_add/on_remove triggers - those need to
# already exist by then, not still be pending.
await finish_list_triggers()
await generate_triggers()
await generate_align_tos(configs[0])
await attach_textareas()
for config in configs:
lv_component = await cg.get_variable(config[CONF_ID])
await add_animation_triggers(config.get(CONF_ANIMATIONS, []))
@@ -634,6 +634,7 @@ LVGL_TOP_LEVEL_SCHEMA = (
cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t),
cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean,
cv.Optional(df.CONF_PAUSED, default=False): cv.boolean,
cv.Optional(df.CONF_DEBUG_OUTLINE, default=False): cv.boolean,
}
)
.extend(DISP_BG_SCHEMA)
+25
View File
@@ -101,6 +101,31 @@ class LvAnimationTimingEaseInOut : public LvAnimationTiming {
float slope_;
};
class LvAnimationTimingEaseIn : public LvAnimationTiming {
public:
LvAnimationTimingEaseIn(float slope) : slope_(slope) {}
float map_progress(float value) override {
const float sqr = value * value;
return this->slope_ * sqr + (1.0f - this->slope_) * value;
}
protected:
float slope_;
};
class LvAnimationTimingEaseOut : public LvAnimationTiming {
public:
LvAnimationTimingEaseOut(float slope) : slope_(slope) {}
float map_progress(float value) override {
const float inv = 1.0f - value;
const float sqr = 1.0f - inv * inv;
return this->slope_ * sqr + (1.0f - this->slope_) * value;
}
protected:
float slope_;
};
template<size_t DATA_SIZE, bool AUTO_START = false> class LvAnimation : public Component {
public:
LvAnimation(void (*update_callback)(const lv_coord_t *data), std::vector<TemplatableValue<lv_coord_t>> from,
+8
View File
@@ -76,6 +76,14 @@ TIMING_SCHEMA = cv.maybe_simple_value(
"ease_in_out",
{cv.Optional(CONF_WEIGHT, default=1.0): cv.zero_to_one_float},
),
timing_class(
"ease_in",
{cv.Optional(CONF_WEIGHT, default=1.0): cv.zero_to_one_float},
),
timing_class(
"ease_out",
{cv.Optional(CONF_WEIGHT, default=1.0): cv.zero_to_one_float},
),
timing_class(
"gravity",
{
+3 -16
View File
@@ -78,7 +78,6 @@ from .widgets import (
get_screen_active,
get_widgets,
set_obj_properties,
wait_for_widgets,
)
# Widgets that are used in a focused/refreshed action are tracked in
@@ -127,15 +126,7 @@ async def action_to_code(
action_id,
template_arg,
args,
config=None,
):
# Ensure all required ids have been processed, so our LambdaContext doesn't get context-switched.
if config:
for lamb in config.values():
if isinstance(lamb, Lambda):
for id_ in lamb.requires_ids:
await get_variable(id_)
await wait_for_widgets()
async with LambdaContext(parameters=args, where=action_id) as context:
for widget in widgets:
await action(widget)
@@ -145,7 +136,7 @@ async def action_to_code(
async def update_to_code(config, action_id, template_arg, args):
async def do_update(widget: Widget):
await set_obj_properties(widget, config)
await widget.type.to_code(widget, config)
await widget.type.update_to_code(widget, config)
if (
widget.type.w_type.value_property is not None
and widget.type.w_type.value_property in config
@@ -153,9 +144,7 @@ async def update_to_code(config, action_id, template_arg, args):
lv_obj.send_event(widget.obj, UPDATE_EVENT, nullptr)
widgets = await get_widgets(config[CONF_ID])
return await action_to_code(
widgets, do_update, action_id, template_arg, args, config
)
return await action_to_code(widgets, do_update, action_id, template_arg, args)
@automation.register_condition(
@@ -507,9 +496,7 @@ async def obj_update_to_code(config, action_id, template_arg, args):
await set_obj_properties(widget, config)
widgets = await get_widgets(config[CONF_ID])
return await action_to_code(
widgets, do_update, action_id, template_arg, args, config
)
return await action_to_code(widgets, do_update, action_id, template_arg, args)
def validate_refresh_config(config):
@@ -9,7 +9,7 @@ from esphome.const import CONF_STATE
from ..defines import CONF_WIDGET, LV_OBJ_FLAG, LvConstant
from ..lvcode import EVENT_ARG, UPDATE_EVENT, LambdaContext, LvContext, lvgl_static
from ..types import LV_EVENT, LV_STATE, lv_pseudo_button_t
from ..widgets import Widget, get_widgets, wait_for_widgets
from ..widgets import Widget, get_widgets
STATE_PRESSED = "PRESSED"
STATE_CHECKED = "CHECKED"
@@ -33,7 +33,6 @@ async def to_code(config):
widget = widget[0]
assert isinstance(widget, Widget)
state = await BS_STATE.process(config[CONF_STATE])
await wait_for_widgets()
is_pressed = str(state) == str(LV_STATE.PRESSED)
test_expr = widget.is_pressed() if is_pressed else widget.is_checked()
async with LambdaContext(EVENT_ARG) as test_ctx:
+59 -21
View File
@@ -20,21 +20,40 @@ DOMAIN = "lvgl"
KEY_COLOR_FORMATS = "color_formats"
KEY_ESPHOME_FONTS_USED = "esphome_fonts_used"
KEY_FOCUSED_WIDGETS = "focused_widgets"
KEY_LIST_TRIGGERS = "list_triggers"
KEY_LV_DEFINES = "lv_defines"
KEY_LV_FONTS_USED = "lv_fonts_used"
KEY_LV_IMAGES_USED = "lv_images_used"
KEY_LV_USES = "lv_uses"
KEY_NAMED_STYLES = "named_styles"
KEY_OPTIONS = "options"
KEY_REFRESHED_WIDGETS = "refreshed_widgets"
KEY_REMAPPED_USES = "remapped_uses"
KEY_STYLES_USED = "styles_used"
KEY_THEME_UPDATE_REQUESTS = "theme_update_requests"
KEY_THEME_WIDGET_MAP = "theme_widget_map"
KEY_THEME_STYLES = "theme_styles"
KEY_UPDATED_WIDGETS = "updated_widgets"
KEY_WIDGET_MAP = "widget_map"
KEY_WIDGETS_COMPLETED = "widgets_completed"
KEY_OPTIONS = "options"
KEY_WARNINGS = "warnings"
KEY_WIDGET_MAP = "widget_map"
KEY_WIDGET_THEME_STYLES = "widget_theme_styles"
KEY_DEBUG_OUTLINE_COUNT = "debug_outline_count"
# Colours for the debug outline, in (red, green, blue) order. They are picked to stay
# distinct from each other and to show up on both light and dark backgrounds.
DEBUG_OUTLINE_COLORS = (
(255, 0, 0),
(0, 160, 0),
(0, 0, 255),
(255, 140, 0),
(200, 0, 200),
(0, 190, 190),
(160, 100, 0),
(255, 0, 120),
(110, 110, 110),
(140, 200, 0),
(0, 110, 255),
(130, 0, 255),
)
# Initial set of LVGL features that are always enabled.
_INITIAL_LV_USES = frozenset(
@@ -102,6 +121,15 @@ def get_options() -> dict[str, Any]:
return _get_data(KEY_OPTIONS, {})
def next_debug_outline_color() -> tuple[int, int, int]:
"""Return the next debug outline colour, cycling through the palette."""
# A one-element list so that the count can be updated in place.
count = _get_data(KEY_DEBUG_OUTLINE_COUNT, [0])
color = DEBUG_OUTLINE_COLORS[count[0] % len(DEBUG_OUTLINE_COLORS)]
count[0] += 1
return color
def get_defines() -> dict[str, str]:
return _get_data(KEY_LV_DEFINES, {})
@@ -110,8 +138,14 @@ def get_updated_widgets() -> dict:
return _get_data(KEY_UPDATED_WIDGETS, {})
def get_theme_widget_map() -> dict[str, Any]:
return _get_data(KEY_THEME_WIDGET_MAP, {})
def get_theme_styles() -> dict[str, MockObj]:
"""Get a map of already created theme style names to their corresponding style IDs."""
return _get_data(KEY_THEME_STYLES, {})
def get_widget_theme_style_data() -> dict[str, list[tuple[MockObj, MockObj]]]:
"""Get the map of widget type names to the list of (style variable, part/state name)"""
return _get_data(KEY_WIDGET_THEME_STYLES, {})
def get_theme_update_requests() -> dict[str, dict[tuple[str, str], None]]:
@@ -130,20 +164,6 @@ def get_widget_map() -> dict[str, Any]:
return _get_data(KEY_WIDGET_MAP, {})
def get_widgets_completed() -> bool:
# ``[value]`` rather than the bare value so that we can mutate the
# entry in place; ``CORE.data`` is reset for us between runs.
return _get_data(KEY_WIDGETS_COMPLETED, [False])[0]
def set_widgets_completed(value: bool) -> None:
_get_data(KEY_WIDGETS_COMPLETED, [False])[0] = value
def is_widget_completed(name: ID) -> bool:
return name in get_widget_map()
def get_focused_widgets() -> set:
return _get_data(KEY_FOCUSED_WIDGETS, set())
@@ -152,6 +172,10 @@ def get_refreshed_widgets() -> set:
return _get_data(KEY_REFRESHED_WIDGETS, set())
def get_list_triggers() -> dict:
return _get_data(KEY_LIST_TRIGGERS, {})
def add_define(macro: str, value="1"):
lv_defines = get_defines()
value = str(value)
@@ -684,6 +708,7 @@ CONF_COLOR_END = "color_end"
CONF_COLOR_START = "color_start"
CONF_CONTAINER = "container"
CONF_CONTROL = "control"
CONF_DEBUG_OUTLINE = "debug_outline"
CONF_DEFAULT_FONT = "default_font"
CONF_DEFAULT_GROUP = "default_group"
CONF_DIR = "dir"
@@ -841,7 +866,7 @@ LV_SCALE_MODE = LvConstant(
DEFAULT_ESPHOME_FONT = "esphome_lv_default_font"
def join_enums(enums, prefix=""):
def join_enums(enums: tuple[str], prefix: str = "") -> MockObj:
enums = list(enums)
enums.sort()
# If a prefix is provided, prepend each constant with the prefix, and assume that all the constants are within the
@@ -851,6 +876,19 @@ def join_enums(enums, prefix=""):
return literal("|".join(f"(int){e.upper()}" for e in enums))
def get_part_state_selector(part: str, state: str) -> MockObj:
"""Combine a part and state into a single selector value, e.g. LV_PART_KNOB | LV_STATE_PRESSED."""
state = "LV_STATE_" + state.removeprefix("LV_STATE_").upper()
part = "LV_PART_" + part.removeprefix("LV_PART_").upper()
if state == "LV_STATE_DEFAULT":
return literal(part)
if part == "LV_PART_MAIN":
return literal(state)
return MockObj(
StaticCastExpression("lv_style_selector_t", literal(state))
) | MockObj(StaticCastExpression("lv_style_selector_t", literal(part)))
# fmt: off
LV_COLOR_FORMATS = (
"RGB565", "SWAPPED", "RGB565A8", "RGB888", "XRGB8888", "ARGB8888", "PREMULTIPLIED", "L8", "AL88", "A8", "I1",
+1 -2
View File
@@ -7,7 +7,7 @@ from esphome.const import CONF_GAMMA_CORRECT, CONF_OUTPUT_ID
from ..defines import CONF_WIDGET
from ..lvcode import LvContext
from ..types import LvType, lvgl_ns
from ..widgets import get_widgets, wait_for_widgets
from ..widgets import get_widgets
lv_led_t = LvType("lv_led_t")
LVLight = lvgl_ns.class_("LVLight", LightOutput)
@@ -26,6 +26,5 @@ async def to_code(config):
widget = await get_widgets(config, CONF_WIDGET)
widget = widget[0]
await wait_for_widgets()
async with LvContext() as ctx:
ctx.add(var.set_obj(widget.obj))
+4 -2
View File
@@ -157,10 +157,12 @@ padding = LValidator(padding_validator, int32, retmapper=literal)
def scale_validator(value):
return cv.float_range(0.1, 10.0)(value)
return cv.float_range(0.0, 10.0)(value)
scale = LValidator(scale_validator, uint32, retmapper=lambda x: int(x * 256))
scale = LValidator(
scale_validator, uint32, retmapper=lambda x: int(x * 256), animatable=True
)
def angle(value):
+25 -8
View File
@@ -1,5 +1,5 @@
import abc
from typing import TYPE_CHECKING
import contextvars
from esphome import codegen as cg
from esphome.config import Config
@@ -65,15 +65,33 @@ class IndentedStatement(Statement):
return result
class CodeContext(abc.ABC):
class _CodeContextMeta(abc.ABCMeta):
"""
Backs `CodeContext.code_context` with a contextvar instead of a plain class
attribute, so the FakeEventLoop's per-task context isolation (see coroutine.py)
keeps interleaved to_code() jobs from clobbering each other's current context.
"""
_context_var: contextvars.ContextVar["CodeContext | None"] = contextvars.ContextVar(
"code_context", default=None
)
@property
def code_context(cls) -> "CodeContext | None":
return _CodeContextMeta._context_var.get()
@code_context.setter
def code_context(cls, value: "CodeContext | None") -> None:
_CodeContextMeta._context_var.set(value)
class CodeContext(abc.ABC, metaclass=_CodeContextMeta):
"""
A class providing a context for code generation. Generated code will be added to the
current context. A new context will stack on the current context, and restore it
when done. Used with the `with` statement.
"""
code_context = None
@abc.abstractmethod
def add(self, expression: Expression | Statement):
pass
@@ -115,6 +133,9 @@ class CodeContext(abc.ABC):
def indented_statement(self, stmt):
return IndentedStatement(stmt, self.indent_level)
def get_automation_parameters(self) -> list[tuple[SafeExpType, str]]:
return []
class MainContext(CodeContext):
"""
@@ -212,10 +233,6 @@ def get_lambda_context_args() -> list[tuple[SafeExpType, str]]:
"""
if CodeContext.code_context is None:
return []
if TYPE_CHECKING:
# CodeContext base class doesn't define get_automation_parameters(),
# but LambdaContext and LvContext (the concrete implementations) do.
assert isinstance(CodeContext.code_context, LambdaContext)
return CodeContext.code_context.get_automation_parameters()
+1
View File
@@ -994,6 +994,7 @@ lv_point_t LvglComponent::get_touch_relative_to_obj(lv_obj_t *obj) {
static void lv_container_constructor(const lv_obj_class_t *class_p, lv_obj_t *obj) {
LV_TRACE_OBJ_CREATE("begin");
lv_obj_remove_flag(obj, LV_OBJ_FLAG_CLICKABLE);
LV_UNUSED(class_p);
}
+1 -2
View File
@@ -22,7 +22,7 @@ from ..lvcode import (
)
from ..schemas import TRIGGER_EVENT_MAP, VALUE_TRIGGER_SCHEMA
from ..types import LvNumber, lvgl_ns
from ..widgets import get_widgets, wait_for_widgets
from ..widgets import get_widgets
LVGLNumber = lvgl_ns.class_("LVGLNumber", number.Number, cg.Component)
@@ -47,7 +47,6 @@ async def to_code(config):
trigger = CONF_ON_RELEASE
widget = await get_widgets(config, CONF_WIDGET)
widget = widget[0]
await wait_for_widgets()
async with LambdaContext([], return_type=cg.float_) as value:
value.add(ReturnStatement(widget.get_value()))
async with LambdaContext([(cg.float_, "v")]) as control:

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