mirror of
https://github.com/esphome/esphome.git
synced 2026-10-15 10:42:52 +00:00
Merge remote-tracking branch 'origin/dev' into sscanf_stub
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 \
|
||||
|
||||
@@ -3,5 +3,7 @@ esphome:
|
||||
|
||||
esp8266:
|
||||
board: d1_mini
|
||||
# The PlatformIO path stays covered whatever the default is
|
||||
toolchain: platformio
|
||||
|
||||
logger:
|
||||
|
||||
+53
-157
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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_))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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_) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"):
|
||||
|
||||
@@ -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}"):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user