Compare commits

...
Author SHA1 Message Date
J. Nick Koston e5632aa20e Simulate the unreadable partitions.csv instead of chmod 2026-08-28 11:58:13 -05:00
J. Nick Koston c75e3898e4 Harden the summary against foreign json shapes and partition edge cases 2026-08-28 11:44:48 -05:00
J. Nick Koston b66990fbff Update the print_summary docstring 2026-08-28 11:36:30 -05:00
J. Nick Koston 05ec260ff3 Split the summary skip diagnostics and validate total_size 2026-08-28 10:39:37 -05:00
J. Nick Koston ad40853283 Pin the size command format in the template test, warn on schema drift 2026-08-28 09:15:35 -05:00
J. Nick Koston 52ce6668d6 Reject ELFs with no allocated PROGBITS sections 2026-08-28 00:51:02 -05:00
J. Nick Koston 2808837743 Parametrize the bad input cases as data 2026-08-28 00:22:45 -05:00
J. Nick Koston 4caf7bafb8 Trim comments and docstrings 2026-08-28 00:21:18 -05:00
J. Nick Koston 22e29df396 Tighten the ELF parser and log skipped summary lines 2026-08-28 00:19:54 -05:00
J. Nick Koston cafc09bdca Cover create_elf_copy 2026-08-28 00:13:15 -05:00
J. Nick Koston e632661adf Derive the exact image size from the ELF when json2 lacks total_size 2026-08-28 00:08:42 -05:00
J. Nick Koston 8d0f3ea2bb Use json2 total_size when present, bin size as fallback 2026-08-28 00:01:13 -05:00
J. Nick Koston 8ee2f4247e Document bin padding and pin the print_summary wiring 2026-08-27 23:59:10 -05:00
J. Nick Koston edf2f7c62a Let print_summary own missing input handling 2026-08-27 23:54:39 -05:00
J. Nick Koston 1fc7a0798d Cover the unreadable firmware bin branch 2026-08-27 23:50:51 -05:00
J. Nick Koston 39092a791a [espidf] Emit json2 size data so the link edge is not blocked 2026-08-27 23:44:45 -05:00
Jesse Hills 2031be0c23 [core] Make script/setup idempotent and prepare new worktrees (#18843) 2026-08-28 14:11:38 +12:00
esphome[bot] 708b7bfb53 Update webserver local assets to 20260824-021242 (#18704) 2026-08-28 11:39:30 +12:00
J. Nick Koston 7782bc11c6 [core] Remove make_name_with_suffix std::string overloads (#18828) 2026-08-27 15:29:43 -05:00
J. Nick Koston a9a66baeba [ci] Compress the compile-test image with zstd (#18812) 2026-08-27 15:29:23 -05:00
J. Nick Koston 16d0fa1165 [core] Add step_to_accuracy_decimals benchmarks (#18826) 2026-08-27 15:28:04 -05:00
25d5cd3e14 [modbus_controller] Poll through PollingDevice; deprecate ModbusCommandItem (#18071)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-27 14:38:13 -05:00
dependabot[bot] 9598449b6b Bump CodSpeedHQ/action from 5.0.3 to 5.2.1 (#18834)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-27 14:31:11 -05:00
dependabot[bot] a8c7279b3b Bump platformdirs from 4.11.3 to 4.11.4 (#18835)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-27 14:30:53 -05:00
rwrozellepre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>Jonathan SwobodaClaude Sonnet 4.6
c3c69c730c [openthread] fix shutdown (#16332)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-27 12:59:17 -05:00
Bonne EgglestonandJ. Nick Koston a8c8827a5b [modbus_controller] Switch write_lambda return value is the wire value only (#18788)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-27 10:33:16 -05:00
Bonne EgglestonandJ. Nick Koston 18b7002604 [modbus_controller] Apply the write offset byte-accurately on switch and output (#18787)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-27 08:43:51 -05:00
51105a25db [tuya] Add water_heater platform (#17323)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-08-27 20:55:51 +12:00
J. Nick Koston fb132b4421 [improv_serial][esp32_improv] Use buffer based RPC response builder (#18793) 2026-08-27 01:01:35 -05:00
guillempages 20b38ac8b0 [runtime_image] Add support for QOI images (#16945) 2026-08-27 16:37:20 +12:00
Keith BurzinskiandJ. Nick Koston cdd892a526 [provisioning] Shut down the Wi-Fi AP and captive portal when the window closes (#17466)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-27 15:16:25 +12:00
J. Nick Koston b99e7f5ae2 [core] Install prefetched PlatformIO packages with parallel extraction (#18775) 2026-08-27 03:02:02 +00:00
J. Nick Koston e55a8aeabe [api] Drop connection instead of crashing when buffer allocation fails (#18803) 2026-08-27 14:49:12 +12:00
J. Nick Koston a8094ed548 [docker] Parallelize the image's PlatformIO library preinstall (#18777) 2026-08-27 14:45:47 +12:00
J. Nick Koston 2104096f02 [core] Prefetch PlatformIO packages in parallel (#18769) 2026-08-27 14:42:30 +12:00
J. Nick Koston e53870085b [improv_serial] Add uart bus support and host integration test (#18794) 2026-08-26 21:13:47 -05:00
J. Nick Koston b62305793d [improv_base] Bump Improv library to 1.2.7 (#18809) 2026-08-27 01:40:12 +00:00
Jonathan Swoboda a1b29b0d0b [external_components] Log when a source overrides built-in components (#18805) 2026-08-26 20:56:57 -04:00
n-IA-hane e458a38f89 [spi] Use PSRAM DMA for external buffers (#18699) 2026-08-27 10:55:39 +10:00
J. Nick Koston 39177402dd [api] Deprecate media player supports_pause field (#18801) 2026-08-26 19:46:28 -05:00
J. Nick Koston 3361d031de [api] Drop connection instead of crashing when overflow buffer allocation fails (#18802) 2026-08-26 19:46:13 -05:00
Bonne EgglestonandJ. Nick Koston 5df1c7f1d3 [modbus_controller] Writer entities as their own hub device; heap-free, byte-accurate write path (#18082)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-26 18:24:09 -05:00
guillempages 9508165797 [runtime_image] Add check for dimensions in BMP (#18800) 2026-08-26 16:36:47 -05:00
esphome[bot] 5878406918 Bump bundled esphome-device-builder to 1.13.1 (#18807) 2026-08-26 16:33:52 -05:00
esphome[bot] fedd46e648 Bump aioesphomeapi from 46.2.0 to 46.2.1 (#18804) 2026-08-26 20:55:29 +00:00
dependabot[bot] d28b17c619 Bump filelock from 3.32.3 to 3.32.4 (#18799)
Signed-off-by: dependabot[bot] <support@github.com>
2026-08-26 19:26:45 +00:00
Josef Zweck f17ef133f3 [hoermann_hcp] Add buttons to hoermann_hcp (#18544) 2026-08-26 14:27:16 -04:00
Leonardo Rivera 34e7453677 [climate] Don't restore a saved mode the device no longer supports (#18296) 2026-08-26 14:26:31 -04:00
Frédéric Metrich 8a89f3075f [emontx] Fix sensor storage initialization ordering (#18771) 2026-08-26 14:03:10 -04:00
MakerYuichianddoraemon2200 5328813814 [time] Silence compiler warning by initializing transit variables (#18715) (#18723)
Co-authored-by: doraemon2200 <106516578+doraemon2200@users.noreply.github.com>
2026-08-26 12:43:55 -04:00
Keith Burzinski 1cbe3a49b2 [remote_transmitter] ISR-driven transmission and non_blocking support on BK7231N/BK7238 (#18660) 2026-08-26 11:30:07 -05:00
guillempages 5c79c92c06 [runtime_image] Add FILTER_SOURCE_FILES (#18768) 2026-08-26 11:26:39 -05:00
J. Nick Koston 7b4894da03 [mdns] Skip MDNS.update() while the ESP8266 radio cannot transmit (#18785) 2026-08-26 15:16:44 +00:00
Clyde Stubbs 4eb85a24c2 [mipi_spi] Fix dimensions for jc3636518v2 (#18786) 2026-08-26 10:04:57 -05:00
Bonne Eggleston 1e7c48e2cf [modbus_controller] Add integration tests for register offset, response size and write buffer (#18741) 2026-08-26 08:35:46 -05:00
IreuN 6ef13cc8de [climate_ir_lg] advanced vert. swing, setting temp. in heat/cool mode, jet mode decoding, other fixes + refactor (#10875) 2026-08-25 22:26:50 -07:00
Keith Burzinski e37a540fb7 [esp32] Apply custom eFuse MAC as base MAC for all interfaces (#18452) 2026-08-25 23:59:37 -05:00
J. Nick Koston 9baff76520 [api] Treat homeassistant.event variables as lambdas (#18759) 2026-08-26 16:29:14 +12:00
J. Nick KostonandCopilot Autofix powered by AI 612d58ec37 [http_request] Default watchdog_timeout from timeout on ESP32 (#18732)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-26 16:11:26 +12:00
Keith Burzinski cb981c2930 [remote_transmitter] ISR-driven transmission and non_blocking support on RTL8720C (#18648) 2026-08-25 22:19:54 -05:00
Clyde StubbsandClaude Sonnet 5 8b8de0c9c6 [lvgl] Fix on_value/on_update triggers for LVGL select entities (#18778)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 13:33:39 +12:00
J. Nick Koston 9e4c52989c [esp8266] Add the native framework and toolchain installer (#18557) 2026-08-25 20:24:47 -05:00
J. Nick Koston 7ff56c62f9 [core] Add shared registry, ninja, and cache infrastructure for native toolchains (#18570) 2026-08-26 13:23:16 +12:00
J. Nick Koston 370e8fffed [core] Add the arduino toolchain seam and validate --toolchain on every platform (#18556) 2026-08-26 11:16:02 +12:00
c272c4c1a6 [lvgl] Add table widget (#18422)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick+github@koston.org>
2026-08-26 08:44:42 +10:00
J. Nick Koston 150f75d8f6 [esp8266] Add linker-script surgery and board build metadata for the native toolchain (#18555) 2026-08-26 10:29:57 +12:00
Jonathan Swoboda cf5ec2d277 [ci] Remove the remaining max-parallel caps (#18762) 2026-08-25 14:42:32 -04:00
234 changed files with 17672 additions and 10387 deletions
+16
View File
@@ -0,0 +1,16 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "d=\"${CLAUDE_PROJECT_DIR:-.}\"; [ -x \"$d/venv/bin/python\" ] || { mkdir -p \"$d/.temp\"; env -u VIRTUAL_ENV \"$d/script/setup\" >\"$d/.temp/setup.log\" 2>&1 || echo '{\"systemMessage\":\"script/setup failed; see .temp/setup.log\"}'; }",
"statusMessage": "Setting up dev environment (script/setup)...",
"timeout": 900
}
]
}
]
}
}
+13 -9
View File
@@ -119,16 +119,22 @@ jobs:
# pushed image) keeps it working for fork PRs, which never push to ghcr.io.
- name: Export image for compile-test
if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker'
run: docker save "ghcr.io/esphome/esphome-amd64:${{ steps.tag.outputs.tag }}" | gzip > compile-test-image.tar.gz
# zstd over gzip: docker save is on the critical path for every
# compile-test job, and zstd -T0 is multithreaded (export 50s -> 9s).
# docker load auto-detects the format; its time is layer extraction,
# not decompression, so it is unchanged. shell: bash adds pipefail so
# a failed docker save cannot upload a truncated artifact.
shell: bash
run: docker save "ghcr.io/esphome/esphome-amd64:${{ steps.tag.outputs.tag }}" | zstd -T0 -3 > compile-test-image.tar.zst
- name: Upload compile-test image artifact
if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
# The tar is already gzipped, so upload it as-is. archive: false skips
# the redundant zip and makes the file name the artifact name (the
# `name` input is ignored in that mode).
path: compile-test-image.tar.gz
# The tar is already compressed, so upload it as-is. archive: false
# skips the redundant zip and makes the file name the artifact name
# (the `name` input is ignored in that mode).
path: compile-test-image.tar.zst
retention-days: 1
archive: false
@@ -182,8 +188,6 @@ jobs:
contents: read # actions/checkout to load the test configs
strategy:
fail-fast: false
# Modest cap so this smoke test leaves room on the shared runner pool.
max-parallel: 8
matrix:
# One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4)
# share a toolchain bundle, so esp32 is exercised on the base variant
@@ -208,9 +212,9 @@ jobs:
- name: Download image artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: compile-test-image.tar.gz
name: compile-test-image.tar.zst
- name: Load image
run: docker load --input compile-test-image.tar.gz
run: docker load --input compile-test-image.tar.zst
- name: Compile ${{ matrix.id }}
run: |
docker run --rm \
+1 -2
View File
@@ -536,7 +536,7 @@ jobs:
apt-get install -y libc6-dbg
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3
uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea # v5.2.1
with:
run: |
. venv/bin/activate
@@ -946,7 +946,6 @@ jobs:
ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf
strategy:
fail-fast: false
max-parallel: ${{ needs.determine-jobs.outputs.release-pr == 'true' && 32 || 16 }}
matrix:
batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }}
steps:
+1
View File
@@ -577,6 +577,7 @@ esphome/components/tuya/select/* @bearpawmaxim
esphome/components/tuya/sensor/* @jesserockz
esphome/components/tuya/switch/* @jesserockz
esphome/components/tuya/text_sensor/* @dentra
esphome/components/tuya/water_heater/* @iago-veiga
esphome/components/uart/* @esphome/core
esphome/components/uart/button/* @ssieb
esphome/components/uart/event/* @eoasmxd
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.13.0
RUN uv pip install --no-cache-dir esphome-device-builder==1.13.1
RUN \
platformio settings set enable_telemetry No \
+9 -8
View File
@@ -2734,10 +2734,14 @@ def run_esphome(argv):
# Skipped when -s overrides are passed, since the cache was written
# against the previous substitution set.
config: ConfigType | None = None
cache_eligible = (
cache_write_eligible = (
args.command in ("upload", "logs") and not command_line_substitutions
)
if cache_eligible:
# An explicit --toolchain must re-run the per-platform validators, so
# gate only the cache read; the refresh below saves the result unless
# the sidecar records a different toolchain.
cache_read_eligible = cache_write_eligible and args.toolchain is None
if cache_read_eligible:
from esphome.compiled_config import load_compiled_config
config = load_compiled_config(conf_path)
@@ -2761,17 +2765,14 @@ def run_esphome(argv):
return 2
CORE.config = config
# Fallback for platforms whose validators didn't set the toolchain
# (only the esp32 component reads esp32.framework.toolchain). All
# other platforms only support PlatformIO today. Must run before the
# cache refresh below so its sidecar records the same toolchain a
# compile would.
# The cache fast path skips validation, and legacy sidecars lack the
# toolchain field. Must run before the cache refresh below.
if CORE.toolchain is None:
CORE.toolchain = Toolchain.PLATFORMIO
# Refresh the cache so the next upload/logs hits the fast path
# instead of re-running read_config.
if cache_eligible and cache_missed:
if cache_write_eligible and cache_missed:
from esphome.compiled_config import save_compiled_config_and_sidecar
save_compiled_config_and_sidecar(config)
+9
View File
@@ -0,0 +1,9 @@
"""Native (PlatformIO-free) build support for the ESP8266 Arduino core.
This package downloads the Arduino ESP8266 core and the xtensa-lx106
toolchain, generates a ninja build for them plus the ESPHome sources, and
drives the build directly — the ESP8266 equivalent of ``esphome.espidf``.
Deliberately importable without the esp8266 component to avoid circular
imports; the component wires these modules in via lazy imports.
"""
+164
View File
@@ -0,0 +1,164 @@
"""Download and install the Arduino ESP8266 core, toolchain, and ninja.
Artifacts land in a machine-global cache (shared across projects, like the
ESP-IDF install in ``esphome.espidf.framework``):
<cache>/arduino8266/frameworks/<version>/ framework-arduinoespressif8266
<cache>/arduino8266/toolchains/<version>/ toolchain-xtensa (gcc 10.3)
Packages come from the PlatformIO registry (identical bits to the PlatformIO
backend); ``ESPHOME_ARDUINO8266_*_MIRRORS`` overrides the URLs. ninja comes
from PATH or the ninja PyPI wheel.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import NamedTuple
from esphome.build_helpers.ccache import ccache_defaults_env
from esphome.build_helpers.ninja import find_ninja
from esphome.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path
from esphome.core import EsphomeError, Version
from esphome.framework_helpers import str_to_lst_of_str
from esphome.platformio.registry import install_package, prefetch_packages
FRAMEWORK_PACKAGE = "framework-arduinoespressif8266"
TOOLCHAIN_PACKAGE = "toolchain-xtensa"
# gcc 10.3, the toolchain Arduino core 3.x builds with; the build
# generator's compile flags are tuned to it.
TOOLCHAIN_VERSION = "2.100300.220621"
ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS = str_to_lst_of_str(
os.environ.get("ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS", "")
)
ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS = str_to_lst_of_str(
os.environ.get("ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS", "")
)
def get_arduino8266_tools_path() -> Path:
# Machine-global so all projects share one install; see
# espidf.framework.get_idf_tools_path for the location rationale.
return tools_cache_path(*ARDUINO8266_TOOLS_CACHE)
# 3.1.1 rather than 3.1.0: the registry has no package for 3.1.0, and the
# encoder below cannot name 3.0.0/3.0.1 either (see its docstring)
MIN_FRAMEWORK_VERSION = Version(3, 1, 1)
def framework_package_version(ver: Version) -> str:
"""Map an Arduino core version to its registry package version (3.1.2 ->
3.30102.0; the leading 3 is the package major).
Exact registry names only for cores > 2.6.2 and >= 3.0.2; callers floor
at MIN_FRAMEWORK_VERSION.
"""
if ver.major > 3:
raise EsphomeError(
f"Arduino core {ver} is not supported yet; "
"the newest known core series is 3.x"
)
if ver <= Version(2, 6, 2):
# Cores <= 2.6.2 use the older 1.x/2.x package-major encodings (same
# boundary as _format_framework_arduino_version's era guard)
raise EsphomeError(
f"Arduino core {ver} uses an older package encoding than this "
"helper implements (newer than 2.6.2)"
)
return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
def get_framework_path(package_version: str) -> Path:
return get_arduino8266_tools_path() / "frameworks" / package_version
def get_toolchain_path() -> Path:
return get_arduino8266_tools_path() / "toolchains" / TOOLCHAIN_VERSION
class InstalledPaths(NamedTuple):
"""Locations of the installed framework, toolchain, and ninja binary."""
framework: Path
toolchain: Path
ninja: Path
def check_and_install(framework_version: Version) -> InstalledPaths:
"""Ensure framework, toolchain, and ninja are installed; return their paths."""
if framework_version < MIN_FRAMEWORK_VERSION:
# Config validation enforces this too; keep the module honest when
# called directly.
raise EsphomeError(
f"The native toolchain requires the Arduino core "
f">= {MIN_FRAMEWORK_VERSION}, got {framework_version}"
)
# Probe the cheap local dependency before ~110 MB of downloads
ninja_path = find_ninja()
package_version = framework_package_version(framework_version)
framework_path = get_framework_path(package_version)
downloads_dir = get_arduino8266_tools_path() / "downloads"
toolchain_path = get_toolchain_path()
# One spec per package: the prefetch and the installs must agree
specs = (
(
FRAMEWORK_PACKAGE,
package_version,
framework_path,
ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS,
("cores/esp8266", "tools/sdk", "libraries"),
),
(
TOOLCHAIN_PACKAGE,
TOOLCHAIN_VERSION,
toolchain_path,
ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS,
# xtensa-lx106-elf pins the target: every gcc package has a bin/
("bin", "xtensa-lx106-elf"),
),
)
# Fetch both archives at once; the installs below verify and extract
prefetch_packages([spec[:4] for spec in specs], downloads_dir)
for name, version, dest, mirrors, expect in specs:
install_package(name, version, dest, mirrors, downloads_dir, expect=expect)
return InstalledPaths(
framework=framework_path, toolchain=toolchain_path, ninja=ninja_path
)
def toolchain_tool(toolchain_path: Path, name: str) -> Path:
"""Path to one toolchain tool (gcc, g++, ar, size, addr2line, ...).
The single owner of the ``bin/xtensa-lx106-elf-<name>`` layout and the
Windows suffix, so a toolchain package bump touches one spot.
"""
suffix = ".exe" if os.name == "nt" else ""
return toolchain_path / "bin" / f"xtensa-lx106-elf-{name}{suffix}"
def get_build_env(toolchain_path: Path, ccache: str | None) -> dict[str, str]:
env = os.environ.copy()
# Drop empty entries: a trailing separator from an absent PATH would
# make the shell search the current directory for tools
parts = [
str(toolchain_path / "bin"),
*filter(None, env.get("PATH", "").split(os.pathsep)),
]
env["PATH"] = os.pathsep.join(parts)
env.update(ccache_env(ccache))
return env
def ccache_env(ccache: str | None) -> dict[str, str]:
"""Return ccache settings for the build subprocess (not os.environ).
``ccache`` is the pre-resolved binary (resolve_ccache_path), or None
when disabled. Values the user already set in the environment are
respected.
"""
if ccache is None:
return {}
return ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
+8 -5
View File
@@ -90,9 +90,10 @@ def get_project_cmakelists(
"""
idf_target = variant_to_idf_target(get_esp32_variant())
# esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and
# removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get
# --format=raw because the legacy mode doesn't support it.
# esp_idf_size 2.x (IDF >=6.0) made NG the default and removed --ng;
# 1.x (IDF 5.5) needs --ng for --format=json2. 1.x json2 also lacks
# total_size, hence the ELF fallback in espidf/size_summary.py; both
# go away together when 1.x support is dropped.
size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else ""
# Project-wide compile options: -D defines and -W warning flags (skip
@@ -211,10 +212,12 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
project({CORE.name})
# Emit raw JSON size data for ESPHome to read post-build.
# 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.
add_custom_command(
TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD
COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=raw
COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=json2
-o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json
${{CMAKE_PROJECT_NAME}}.map
WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}}
+92
View File
@@ -0,0 +1,92 @@
"""Shared ccache policy for build backends: env-knob parsing, binary
resolution, and default ``CCACHE_*`` values."""
from __future__ import annotations
import logging
import os
from pathlib import Path
from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs
from esphome.helpers import FALSY_ENV_STRINGS, TRUTHY_ENV_STRINGS
_LOGGER = logging.getLogger(__name__)
def _ccache_runs(ccache: str) -> bool:
"""Return True when the ``ccache`` found on PATH actually runs."""
return tool_version_runs(
ccache,
"Ignoring ccache at %s because it failed to run; compiling without ccache",
)
def parse_enable_env(name: str) -> bool | None:
"""Strictly parse an on/off environment knob; None when unset or invalid.
``bool(str)`` truthiness would flip ``no``/``off`` to enabled, so only
1/true/yes/on and 0/false/no/off count; anything else warns and reads
as unset so the caller's default policy applies.
"""
raw = os.environ.get(name)
if raw is None:
return None
lowered = raw.strip().lower()
if not lowered:
# ENV KNOB= (Docker/CI) has always read as a disable
return False
if lowered in TRUTHY_ENV_STRINGS:
return True
if lowered in FALSY_ENV_STRINGS:
return False
_LOGGER.warning("Ignoring unrecognized %s=%r; use 1 or 0", name, raw)
return None
def resolve_ccache_path() -> str | None:
"""The ccache binary to wrap compiles with, or None when disabled.
An explicit ``ESPHOME_CCACHE_ENABLE=1`` skips the runnability probe; the
Windows extended-length prefix is stripped before probing (#18399).
"""
import shutil
explicit = parse_enable_env("ESPHOME_CCACHE_ENABLE")
if explicit is False:
return None
ccache = shutil.which("ccache")
if ccache is None:
if explicit:
_LOGGER.warning(
"ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; "
"compiling without ccache"
)
return None
ccache = strip_win_long_path_prefix(ccache)
if not explicit and not _ccache_runs(ccache):
return None
return ccache
def ccache_defaults_env(cache_dir: Path) -> dict[str, str]:
"""Default ``CCACHE_*`` values for a build subprocess (not os.environ).
Values the user already set in the environment are respected. Depend
mode is on: both native backends emit depfiles (-MMD / CMake), which
keeps cache-miss overhead low.
"""
from esphome.core import CORE
# An unset build_path means the env was built before preload; fail loudly
# rather than silently drop CCACHE_BASEDIR.
if CORE.build_path is None:
raise ValueError(
"CORE.build_path must be set before constructing the build environment"
)
defaults = {
"CCACHE_DIR": str(cache_dir),
"CCACHE_NOHASHDIR": "true",
"CCACHE_DEPEND": "1",
"CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
}
return {k: v for k, v in defaults.items() if k not in os.environ}
+92
View File
@@ -0,0 +1,92 @@
"""Platform-neutral helpers for ninja-driven native builds."""
from __future__ import annotations
import logging
import os
from pathlib import Path
import re
import shutil
from esphome.core import EsphomeError
from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs
_LOGGER = logging.getLogger(__name__)
def _ninja_runs(binary: str) -> bool:
"""Whether the ninja found on PATH actually runs (see tool_version_runs)."""
return tool_version_runs(
binary,
"Ignoring ninja at %s because it failed to run; "
"falling back to the bundled wheel",
)
def find_ninja() -> Path:
"""Locate the ninja binary: a runnable PATH hit first, else the ninja
PyPI wheel."""
if binary := shutil.which("ninja"):
binary = strip_win_long_path_prefix(binary)
if _ninja_runs(binary):
return Path(binary)
import_error: ImportError | None = None
try:
import ninja
except ImportError as err:
import_error = err
wheel_binary = None
else:
wheel_binary = Path(ninja.BIN_DIR) / (
"ninja.exe" if os.name == "nt" else "ninja"
)
if wheel_binary is None or not wheel_binary.is_file():
raise EsphomeError(
"ninja not found on PATH or in the ninja package; reinstall the "
"esphome Python environment"
) from import_error
return wheel_binary
def escape(value: Path | str) -> str:
"""Escape a path or token for a ninja file."""
return str(value).replace("$", "$$").replace(":", "$:").replace(" ", "$ ")
def quote_arg(tok: str) -> str:
"""Quote with the CreateProcess argv rule (as ``subprocess.list2cmdline``):
backslash runs double only before a quote. Windows-only; ``$`` must
already be doubled for ninja.
"""
quoted = re.sub(r'(\\*)"', lambda m: m.group(1) * 2 + '\\"', tok)
quoted = re.sub(r"(\\+)\Z", lambda m: m.group(1) * 2, quoted)
return f'"{quoted}"'
# Force-quote any token containing a character outside the shlex.quote-style
# safe set: ninja hands POSIX commands to /bin/sh -c, so bare (, ;, <, *, `
# and friends would be re-parsed as shell syntax.
_NEEDS_QUOTE = re.compile(r"[^\w@%+=:,./-]")
def shell_token(tok: str, force: bool = False) -> str:
"""Re-quote a lexed token for the platform shell; ``force`` always quotes.
Single quotes on POSIX (/bin/sh), the argv rule on Windows
(CreateProcess). ``$`` is doubled first because ninja expands it before
the command reaches the shell.
"""
tok = tok.replace("$", "$$") # ninja would expand a bare $ to nothing
if not (force or not tok or _NEEDS_QUOTE.search(tok)):
return tok
# An empty token must become '' / "" or it vanishes from the argv
if os.name == "nt":
return quote_arg(tok)
# shlex.quote's rule; inlined because the $-doubled token must not be
# re-examined for safe characters
return "'" + tok.replace("'", "'\"'\"'") + "'"
def quote_path(value: Path | str) -> str:
"""Force-quote a path for the ninja command line (shell/CreateProcess)."""
return shell_token(str(value), force=True)
+36
View File
@@ -0,0 +1,36 @@
"""Machine-global tools cache location shared by the native backends."""
from __future__ import annotations
from pathlib import Path
def tools_cache_path(env_var: str, subdir: str) -> Path:
"""A backend's machine-global tools directory, with an env override.
A blank/whitespace override is treated as unset: ``Path("")`` resolves
to the CWD, which ``clean-all`` would then delete.
"""
import platformdirs
from esphome.helpers import get_str_env
if prefix := get_str_env(env_var, "").strip():
# resolve(): symlinked prefixes otherwise trip idf.py's
# venv-mismatch warning on every build
return Path(prefix).expanduser().resolve()
# appauthor=False keeps the Windows path short (no vendor segment);
# deep IDF trees run into MAX_PATH otherwise
return (
Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / subdir
).resolve()
# (env override, cache subdir) per native backend. writer.clean_all wipes
# every entry via tools_cache_path, so listing a cache here is the single
# step that registers it for removal; the backends' own path getters use
# the same named pairs so the two cannot drift.
IDF_TOOLS_CACHE = ("ESPHOME_ESP_IDF_PREFIX", "idf")
SDK_NRF_TOOLS_CACHE = ("ESPHOME_SDK_NRF_PREFIX", "sdk-nrf")
ARDUINO8266_TOOLS_CACHE = ("ESPHOME_ARDUINO8266_PREFIX", "arduino8266")
TOOLS_CACHE_SPECS = (IDF_TOOLS_CACHE, SDK_NRF_TOOLS_CACHE, ARDUINO8266_TOOLS_CACHE)
+15
View File
@@ -100,6 +100,21 @@ def _refresh_sidecar() -> bool:
)
return False
if old is not None and old.can_apply_to_core():
if (
old.toolchain is not None
and CORE.toolchain is not None
and old.toolchain != CORE.toolchain.value
):
# Platforms normalize toolchain-sensitive keys differently;
# never cache a config validated under a different toolchain
# than the compile's
_LOGGER.debug(
"Not caching: config validated with toolchain %r but the "
"last compile used %r",
CORE.toolchain.value,
old.toolchain,
)
return False
# Compile-written; nothing to refresh.
return True
if CORE.build_path is not None and CORE.build_path.exists():
+41 -4
View File
@@ -1,4 +1,5 @@
import logging
import re
from typing import Any
from esphome import automation
@@ -499,6 +500,40 @@ async def to_code(config: ConfigType) -> None:
KEY_VALUE_SCHEMA = cv.Schema({cv.string: cv.templatable(cv.string_strict)})
_ID_CALL_PROG = re.compile(r"\bid\s*\(")
# Remove before 2027.3.0: untagged strings that look like lambda source keep
# being compiled as lambdas during the deprecation window
def _coerce_implicit_lambda(value: Any) -> Any:
if not isinstance(value, str):
return value
if cv.looks_like_returning_lambda(value):
_LOGGER.warning(
"[api] The 'variables' value '%s' looks like a lambda but is "
"missing the !lambda tag. It is compiled as a lambda for now but "
"will be sent as literal text from 2027.3.0. Add !lambda to keep "
"it evaluated; literal text belongs under 'data:'.",
value,
)
# cv.templatable runs returning_lambda on the coerced Lambda
return cv.lambda_(value)
if _ID_CALL_PROG.search(value):
# lambda source without a return: issue 5394's mistake class
_LOGGER.warning(
"[api] The 'variables' value '%s' is sent as literal text; wrap "
"it in !lambda 'return ...;' to evaluate it instead.",
value,
)
return value
# Static strings or !lambda values. cv.templatable stays introspectable for
# schema tooling; removing the shim leaves KEY_VALUE_SCHEMA.
VARIABLES_SCHEMA = cv.Schema(
{cv.string: cv.All(_coerce_implicit_lambda, cv.templatable(cv.string_strict))}
)
def _validate_response_config(config: ConfigType) -> ConfigType:
# Validate dependencies:
@@ -535,9 +570,7 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
),
cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA,
cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA,
cv.Optional(CONF_VARIABLES, default={}): cv.Schema(
{cv.string: cv.returning_lambda}
),
cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA,
cv.Optional(CONF_RESPONSE_TEMPLATE): cv.templatable(cv.string),
cv.Optional(CONF_CAPTURE_RESPONSE, default=False): cv.boolean,
cv.Optional(CONF_ON_SUCCESS): automation.validate_automation(single=True),
@@ -598,6 +631,8 @@ async def homeassistant_service_to_code(
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
for key, value in config[CONF_VARIABLES].items():
templ = await cg.templatable(value, args, None)
if isinstance(templ, str):
templ = cg.FlashStringLiteral(templ)
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
if on_error := config.get(CONF_ON_ERROR):
@@ -652,7 +687,7 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema(
cv.Required(CONF_EVENT): validate_homeassistant_event,
cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA,
cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA,
cv.Optional(CONF_VARIABLES, default={}): KEY_VALUE_SCHEMA,
cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA,
}
)
@@ -698,6 +733,8 @@ async def homeassistant_event_to_code(
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
for key, value in config[CONF_VARIABLES].items():
templ = await cg.templatable(value, args, None)
if isinstance(templ, str):
templ = cg.FlashStringLiteral(templ)
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
return var
+2 -1
View File
@@ -1654,7 +1654,8 @@ message ListEntitiesMediaPlayerResponse {
bool disabled_by_default = 6;
EntityCategory entity_category = 7;
bool supports_pause = 8;
// Deprecated in ESPHome 2026.9.0; use feature_flags instead.
bool supports_pause = 8 [deprecated = true];
repeated MediaPlayerSupportedFormat supported_formats = 9;
+9 -2
View File
@@ -1,13 +1,20 @@
#include "api_buffer.h"
#include <new>
namespace esphome::api {
void APIBuffer::grow_(size_t n) {
auto new_data = make_buffer(n);
bool APIBuffer::grow_(size_t n) {
// nothrow (no zero-fill) so OOM is reportable; plain new aborts instead
// (NEW_OOM_ABORT on ESP8266 Arduino, exception stub on ESP-IDF).
// RAMAllocator is no fit here: unique_ptr needs delete[]-compatible memory.
std::unique_ptr<uint8_t[]> new_data(new (std::nothrow) uint8_t[n]);
if (new_data == nullptr)
return false;
if (this->size_)
std::memcpy(new_data.get(), this->data_.get(), this->size_);
this->data_ = std::move(new_data);
this->capacity_ = n;
return true;
}
} // namespace esphome::api
+11 -21
View File
@@ -9,16 +9,6 @@
namespace esphome::api {
/// Helper to use make_unique_for_overwrite where available (skips zero-fill),
/// falling back to make_unique on older GCC (ESP8266, LibreTiny).
inline std::unique_ptr<uint8_t[]> make_buffer(size_t n) {
#if defined(USE_ESP8266) || defined(USE_LIBRETINY)
return std::make_unique<uint8_t[]>(n);
#else
return std::make_unique_for_overwrite<uint8_t[]>(n);
#endif
}
/// Byte buffer that skips zero-initialization on resize().
///
/// std::vector<uint8_t>::resize() zero-fills new bytes via memset. For the
@@ -36,23 +26,23 @@ inline std::unique_ptr<uint8_t[]> make_buffer(size_t n) {
class APIBuffer {
public:
void clear() { this->size_ = 0; }
inline void reserve(size_t n) ESPHOME_ALWAYS_INLINE {
if (n > this->capacity_)
this->grow_(n);
}
inline void resize(size_t n) ESPHOME_ALWAYS_INLINE {
this->reserve(n);
this->size_ = n; // no zero-fill
}
/// Returns false if allocation fails; the buffer is left unchanged.
[[nodiscard]] inline bool reserve(size_t n) ESPHOME_ALWAYS_INLINE { return n <= this->capacity_ || this->grow_(n); }
/// Returns false if allocation fails; the buffer is left unchanged. No zero-fill.
[[nodiscard]] inline bool resize(size_t n) ESPHOME_ALWAYS_INLINE { return this->reserve_and_resize(n, n); }
/// Reserve capacity for max(reserve_size, new_size) bytes, then set size to new_size.
/// Single grow_ check regardless of argument order.
inline void reserve_and_resize(size_t reserve_size, size_t new_size) ESPHOME_ALWAYS_INLINE {
this->reserve(std::max(reserve_size, new_size));
/// Returns false if allocation fails; the buffer is left unchanged.
[[nodiscard]] inline bool reserve_and_resize(size_t reserve_size, size_t new_size) ESPHOME_ALWAYS_INLINE {
if (!this->reserve(std::max(reserve_size, new_size)))
return false;
this->size_ = new_size;
return true;
}
uint8_t *data() { return this->data_.get(); }
const uint8_t *data() const { return this->data_.get(); }
size_t size() const { return this->size_; }
size_t capacity() const { return this->capacity_; }
bool empty() const { return this->size_ == 0; }
uint8_t &operator[](size_t i) { return this->data_[i]; }
const uint8_t &operator[](size_t i) const { return this->data_[i]; }
@@ -64,7 +54,7 @@ class APIBuffer {
}
protected:
void grow_(size_t n);
bool grow_(size_t n);
std::unique_ptr<uint8_t[]> data_;
size_t size_{0};
size_t capacity_{0};
+38 -12
View File
@@ -1,6 +1,6 @@
#include "api_connection.h"
#ifdef USE_API
#include "api_connection_buffer.h" // for encode_to_buffer / get_batch_delay_ms_ inlines
#include "api_connection_buffer.h" // for the APIServer-dependent APIConnection inlines
#ifdef USE_API_NOISE
#include "api_frame_helper_noise.h"
#endif
@@ -1099,7 +1099,6 @@ uint16_t APIConnection::try_send_media_player_info(EntityBase *entity, APIConnec
auto *media_player = static_cast<media_player::MediaPlayer *>(entity);
ListEntitiesMediaPlayerResponse msg;
auto traits = media_player->get_traits();
msg.supports_pause = traits.get_supports_pause();
msg.feature_flags = traits.get_feature_flags();
for (auto &supported_format : traits.get_supported_formats()) {
msg.supported_formats.emplace_back();
@@ -2240,10 +2239,17 @@ bool APIConnection::send_message_(uint32_t payload_size, uint16_t message_type,
this->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
}
#endif
if (!this->prepare_first_message_buffer(payload_size)) [[unlikely]] {
this->fatal_out_of_memory_();
return false;
}
auto &shared_buf = this->parent_->get_shared_buffer_ref();
this->prepare_first_message_buffer(shared_buf, payload_size);
size_t write_start = shared_buf.size();
shared_buf.resize(write_start + payload_size);
#ifdef ESPHOME_DEBUG_API
assert(shared_buf.capacity() >= write_start + payload_size);
#endif
// Capacity reserved above, cannot fail
(void) shared_buf.resize(write_start + payload_size);
ProtoWriteBuffer buffer{&shared_buf, write_start};
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
return this->send_buffer(ProtoWriteBuffer{&shared_buf}, message_type);
@@ -2279,6 +2285,9 @@ void APIConnection::on_no_setup_connection() {
this->on_fatal_error();
this->log_client_(ESPHOME_LOG_LEVEL_DEBUG, LOG_STR("no connection setup"));
}
void APIConnection::fatal_out_of_memory_() {
this->fatal_error_with_log_(LOG_STR("Out of memory"), APIError::OUT_OF_MEMORY);
}
void APIConnection::on_fatal_error() {
// Don't close socket here - keep it open so getpeername() works for logging
// Socket will be closed when client is removed from the list in APIServer::loop()
@@ -2293,16 +2302,25 @@ bool APIConnection::schedule_message_front_(EntityBase *entity, uint16_t message
bool APIConnection::send_message_smart_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
uint8_t aux_data_index) {
if (this->should_send_immediately_(message_type) && this->helper_->can_write_without_blocking()) {
auto &shared_buf = this->parent_->get_shared_buffer_ref();
this->prepare_first_message_buffer(shared_buf, estimated_size);
// No local for the shared buffer here: keeping it live across
// dispatch_message_ costs a register and spills message_type into the
// batching path's dedup loop (measured on x86 GCC -Os)
if (!this->prepare_first_message_buffer(estimated_size)) [[unlikely]] {
this->fatal_out_of_memory_();
return false;
}
DeferredBatch::BatchItem item{entity, message_type, estimated_size, aux_data_index};
if (this->dispatch_message_(item, MAX_BATCH_PACKET_SIZE, true) &&
this->send_buffer(ProtoWriteBuffer{&shared_buf}, message_type)) {
this->send_buffer(ProtoWriteBuffer{&this->parent_->get_shared_buffer_ref()}, message_type)) {
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_batch_item_(item);
#endif
return true;
}
// An OOM during the immediate attempt marks the connection for removal;
// don't queue more work (schedule_message_'s push_back may allocate again)
if (this->flags_.remove) [[unlikely]]
return false;
}
return this->schedule_message_(entity, message_type, estimated_size, aux_data_index);
}
@@ -2352,7 +2370,11 @@ void APIConnection::process_batch_() {
total_estimated_size = MAX_BATCH_PACKET_SIZE;
}
this->prepare_first_message_buffer(shared_buf, header_padding, total_estimated_size);
if (!this->prepare_first_message_buffer(header_padding, total_estimated_size)) [[unlikely]] {
this->fatal_out_of_memory_();
this->clear_batch_();
return;
}
// Fast path for single message - buffer already allocated above
if (num_items == 1) {
@@ -2367,8 +2389,10 @@ void APIConnection::process_batch_() {
#endif
this->clear_batch_();
} else if (payload_size == 0) {
// Message too large to fit in available space
ESP_LOGW(TAG, "Message too large to send: type=%u", item.message_type);
// payload_size == 0 with remove set means encoding hit OOM and the
// connection is being dropped; warn only for a genuinely oversized message
if (!this->flags_.remove)
ESP_LOGW(TAG, "Message too large to send: type=%u", item.message_type);
this->clear_batch_();
}
return;
@@ -2431,8 +2455,10 @@ void APIConnection::process_batch_multi_(APIBuffer &shared_buf, size_t num_items
if (items_processed > 0) {
// Add footer space for the last message (for Noise protocol MAC)
if (footer_size > 0) {
shared_buf.resize(shared_buf.size() + footer_size);
if (footer_size > 0 && !shared_buf.resize(shared_buf.size() + footer_size)) [[unlikely]] {
this->fatal_out_of_memory_();
this->clear_batch_();
return;
}
// Send all collected messages
+8 -14
View File
@@ -352,22 +352,13 @@ class APIConnection final : public APIServerConnectionBase {
}
}
void prepare_first_message_buffer(APIBuffer &shared_buf, size_t header_padding, size_t total_size) {
shared_buf.clear();
// Reserve space for header padding + message + footer
// - Header padding: space for protocol headers (7 bytes for Noise, 6 for Plaintext)
// - Footer: space for MAC (16 bytes for Noise, 0 for Plaintext)
// Reserve full size but only set initial size to header padding
// so message encoding starts at the correct position
shared_buf.reserve_and_resize(total_size, header_padding);
}
/// Clear the shared write buffer and reserve space for the first message.
/// Returns false if the allocation fails (out of memory).
/// Defined in api_connection_buffer.h (needs APIServer complete).
[[nodiscard]] bool prepare_first_message_buffer(size_t header_padding, size_t total_size);
// Convenience overload - computes frame overhead internally
void prepare_first_message_buffer(APIBuffer &shared_buf, size_t payload_size) {
const uint8_t header_padding = this->helper_->frame_header_padding();
const uint8_t footer_size = this->helper_->frame_footer_size();
this->prepare_first_message_buffer(shared_buf, header_padding, payload_size + header_padding + footer_size);
}
[[nodiscard]] bool prepare_first_message_buffer(size_t payload_size);
bool try_to_clear_buffer(bool log_out_of_space) {
if (this->flags_.remove)
@@ -853,6 +844,9 @@ class APIConnection final : public APIServerConnectionBase {
this->on_fatal_error();
this->log_warning_(message, err);
}
// Shared cold path for buffer allocation failures — noinline keeps the
// OOM handling out of the hot send paths
void __attribute__((noinline)) fatal_out_of_memory_();
};
} // namespace esphome::api
+23 -3
View File
@@ -3,8 +3,8 @@
#include "esphome/core/defines.h"
#ifdef USE_API
// Inline APIConnection methods that need APIServer complete. Include this
// instead of api_connection.h when calling encode_to_buffer or get_batch_delay_ms_.
// Inline APIConnection members that need APIServer complete. Include this
// instead of api_connection.h when calling them.
#include "api_connection.h"
#include "api_server.h"
@@ -41,7 +41,10 @@ inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t c
return 0;
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
shared_buf.resize(shared_buf.size() + to_add);
if (!shared_buf.resize(shared_buf.size() + to_add)) [[unlikely]] {
conn->fatal_out_of_memory_();
return 0;
}
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
@@ -50,5 +53,22 @@ inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t c
inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
inline bool APIConnection::prepare_first_message_buffer(size_t header_padding, size_t total_size) {
auto &shared_buf = this->parent_->get_shared_buffer_ref();
shared_buf.clear();
// Reserve space for header padding + message + footer
// - Header padding: space for protocol headers (7 bytes for Noise, 6 for Plaintext)
// - Footer: space for MAC (16 bytes for Noise, 0 for Plaintext)
// Reserve full size but only set initial size to header padding
// so message encoding starts at the correct position
return shared_buf.reserve_and_resize(total_size, header_padding);
}
inline bool APIConnection::prepare_first_message_buffer(size_t payload_size) {
const uint8_t header_padding = this->helper_->frame_header_padding();
const uint8_t footer_size = this->helper_->frame_footer_size();
return this->prepare_first_message_buffer(header_padding, payload_size + header_padding + footer_size);
}
} // namespace esphome::api
#endif
+1 -1
View File
@@ -172,7 +172,7 @@ APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uin
// Queue unsent data into overflow buffer
if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, static_cast<uint16_t>(sent))) {
HELPER_LOG("Overflow buffer full, dropping connection");
HELPER_LOG("Overflow buffer full or out of memory, dropping connection");
this->state_ = State::FAILED;
return APIError::SOCKET_WRITE_FAILED;
}
@@ -68,7 +68,10 @@ APIError APINoiseFrameHelper::init() {
// init prologue
size_t old_size = prologue_.size();
prologue_.resize(old_size + PROLOGUE_INIT_LEN);
if (!prologue_.resize(old_size + PROLOGUE_INIT_LEN)) [[unlikely]] {
state_ = State::FAILED;
return APIError::OUT_OF_MEMORY;
}
#ifdef USE_ESP8266
memcpy_P(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN);
#else
@@ -202,7 +205,10 @@ APIError APINoiseFrameHelper::try_read_frame_() {
// During handshake, rx_buf_.size() is used in prologue construction, so
// the buffer must be exactly msg_size to avoid prologue mismatch.)
uint16_t alloc_size = msg_size + (is_data ? RX_BUF_NULL_TERMINATOR : 0);
this->rx_buf_.resize(alloc_size);
if (!this->rx_buf_.resize(alloc_size)) [[unlikely]] {
state_ = State::FAILED;
return APIError::OUT_OF_MEMORY;
}
if (rx_buf_len_ < msg_size) {
// more data to read
@@ -269,7 +275,10 @@ APIError APINoiseFrameHelper::state_action_client_hello_() {
// Resize for: existing prologue + 2 size bytes + frame data
size_t old_size = this->prologue_.size();
size_t rx_size = this->rx_buf_.size();
this->prologue_.resize(old_size + 2 + rx_size);
if (!this->prologue_.resize(old_size + 2 + rx_size)) [[unlikely]] {
state_ = State::FAILED;
return APIError::OUT_OF_MEMORY;
}
this->prologue_[old_size] = (uint8_t) (rx_size >> 8);
this->prologue_[old_size + 1] = (uint8_t) rx_size;
if (rx_size > 0) {
@@ -477,13 +486,15 @@ APIError APINoiseFrameHelper::write_protobuf_packet(uint16_t type, ProtoWriteBuf
assert(this->state_ == State::DATA);
#endif
APIBuffer *buf = buffer.get_buffer();
// Resize buffer to include footer space for Noise MAC
if (this->frame_footer_size_)
buffer.get_buffer()->resize(buffer.get_buffer()->size() + this->frame_footer_size_);
if (this->frame_footer_size_ && !buf->resize(buf->size() + this->frame_footer_size_)) [[unlikely]] {
state_ = State::FAILED;
return APIError::OUT_OF_MEMORY;
}
uint16_t payload_size =
static_cast<uint16_t>(buffer.get_buffer()->size() - HEADER_PADDING - this->frame_footer_size_);
uint8_t *buf_start = buffer.get_buffer()->data();
uint16_t payload_size = static_cast<uint16_t>(buf->size() - HEADER_PADDING - this->frame_footer_size_);
uint8_t *buf_start = buf->data();
uint16_t encrypted_len;
APIError aerr = this->encrypt_noise_message_(buf_start, payload_size, type, encrypted_len);
if (aerr != APIError::OK)
@@ -172,7 +172,10 @@ APIError APIPlaintextFrameHelper::try_read_frame_() {
// Reserve space for body (+ null terminator so protobuf StringRef fields
// can be safely null-terminated in-place after decode)
this->rx_buf_.resize(this->rx_header_parsed_len_ + RX_BUF_NULL_TERMINATOR);
if (!this->rx_buf_.resize(this->rx_header_parsed_len_ + RX_BUF_NULL_TERMINATOR)) [[unlikely]] {
state_ = State::FAILED;
return APIError::OUT_OF_MEMORY;
}
if (rx_buf_len_ < rx_header_parsed_len_) {
// more data to read
+14 -2
View File
@@ -1,6 +1,7 @@
#include "api_overflow_buffer.h"
#ifdef USE_API
#include <cstring>
#include <new>
namespace esphome::api {
@@ -61,9 +62,18 @@ bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_
return false;
uint16_t buffer_size = total_len - skip;
// nothrow: a failed allocation returns nullptr so the connection is dropped
// cleanly instead of plain new's crash or abort on OOM
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
auto *entry = new Entry{new uint8_t[buffer_size], buffer_size, 0};
this->queue_[this->tail_] = entry;
auto *data = new (std::nothrow) uint8_t[buffer_size];
if (data == nullptr)
return false;
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
auto *entry = new (std::nothrow) Entry{data, buffer_size, 0};
if (entry == nullptr) {
delete[] data;
return false;
}
uint16_t to_skip = skip;
uint16_t write_pos = 0;
@@ -80,6 +90,8 @@ bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_
}
}
// Publish only after the copy completes so a half-built entry is never reachable
this->queue_[this->tail_] = entry;
this->tail_ = (this->tail_ + 1) % API_MAX_SEND_QUEUE;
this->count_++;
return true;
+1 -1
View File
@@ -61,7 +61,7 @@ class APIOverflowBuffer {
/// Enqueue unsent IOV data into the backlog.
/// Copies iov data starting at byte offset `skip` into a new entry.
/// Returns false if the queue is full (caller should fail the connection).
/// Returns false if the queue is full or allocation fails (caller should fail the connection).
bool enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip);
protected:
-2
View File
@@ -2323,7 +2323,6 @@ uint8_t *ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer &buffer PROTO_
#endif
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast<uint32_t>(this->entity_category));
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supports_pause);
for (auto &it : this->supported_formats) {
ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 9, it);
}
@@ -2343,7 +2342,6 @@ uint32_t ListEntitiesMediaPlayerResponse::calculate_size() const {
#endif
size += ProtoSize::calc_bool(1, this->disabled_by_default);
size += this->entity_category ? 2 : 0;
size += ProtoSize::calc_bool(1, this->supports_pause);
if (!this->supported_formats.empty()) {
for (const auto &it : this->supported_formats) {
size += ProtoSize::calc_message_force(1, it.calculate_size());
+1 -2
View File
@@ -1911,11 +1911,10 @@ class MediaPlayerSupportedFormat final : public ProtoMessage {
class ListEntitiesMediaPlayerResponse final : public InfoResponseProtoMessage {
public:
static constexpr uint16_t MESSAGE_TYPE = 63;
static constexpr uint8_t ESTIMATED_SIZE = 80;
static constexpr uint8_t ESTIMATED_SIZE = 78;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_media_player_response"); }
#endif
bool supports_pause{false};
std::vector<MediaPlayerSupportedFormat> supported_formats{};
uint32_t feature_flags{0};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
-1
View File
@@ -1962,7 +1962,6 @@ const char *ListEntitiesMediaPlayerResponse::dump_to(DumpBuffer &out) const {
#endif
dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default);
dump_field(out, ESPHOME_PSTR("entity_category"), static_cast<enums::EntityCategory>(this->entity_category));
dump_field(out, ESPHOME_PSTR("supports_pause"), this->supports_pause);
for (const auto &it : this->supported_formats) {
out.append(4, ' ').append_p(ESPHOME_PSTR("supported_formats")).append(": ");
it.dump_to(out);
+137 -137
View File
@@ -7,146 +7,146 @@ namespace esphome::captive_portal {
#ifdef USE_CAPTIVE_PORTAL_GZIP
constexpr uint8_t INDEX_GZ[] PROGMEM = {
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x95, 0x16, 0x6b, 0x8f, 0xdb, 0x36, 0xf2, 0x7b, 0x7f,
0x05, 0x8f, 0x4d, 0x1b, 0xa9, 0xb1, 0xa8, 0x87, 0xd7, 0xde, 0x44, 0x96, 0x54, 0xa4, 0x7b, 0x2d, 0x5a, 0xa0, 0x69,
0x03, 0xec, 0x36, 0xf7, 0x21, 0x08, 0xb0, 0x34, 0x39, 0xb2, 0x98, 0xa5, 0x48, 0x1d, 0x49, 0xbf, 0x62, 0xf8, 0x7e,
0xfb, 0x81, 0x92, 0xec, 0xf5, 0x2e, 0x9a, 0x03, 0x0e, 0x86, 0x85, 0x19, 0xce, 0x7b, 0x38, 0x0f, 0x16, 0xff, 0xe0,
0x9a, 0xb9, 0x7d, 0x07, 0xa8, 0x71, 0xad, 0xac, 0x0a, 0xff, 0x45, 0x92, 0xaa, 0x55, 0x09, 0xaa, 0x2a, 0x1a, 0xa0,
0xbc, 0x2a, 0x5a, 0x70, 0x14, 0xb1, 0x86, 0x1a, 0x0b, 0xae, 0xfc, 0xeb, 0xee, 0x97, 0xe8, 0x75, 0x55, 0x48, 0xa1,
0x1e, 0x90, 0x01, 0x59, 0x0a, 0xa6, 0x15, 0x6a, 0x0c, 0xd4, 0x25, 0xa7, 0x8e, 0xe6, 0xa2, 0xa5, 0x2b, 0x18, 0x45,
0x14, 0x6d, 0xa1, 0xdc, 0x08, 0xd8, 0x76, 0xda, 0x38, 0xc4, 0xb4, 0x72, 0xa0, 0x5c, 0x89, 0xb7, 0x82, 0xbb, 0xa6,
0xe4, 0xb0, 0x11, 0x0c, 0xa2, 0x1e, 0x99, 0x08, 0x25, 0x9c, 0xa0, 0x32, 0xb2, 0x8c, 0x4a, 0x28, 0xd3, 0xc9, 0xda,
0x82, 0xe9, 0x11, 0xba, 0x94, 0x50, 0x2a, 0x8d, 0xab, 0xc2, 0x32, 0x23, 0x3a, 0x87, 0xbc, 0xab, 0x65, 0xab, 0xf9,
0x5a, 0x42, 0x15, 0xc7, 0xd4, 0x5a, 0x70, 0x36, 0x16, 0x8a, 0xc3, 0x8e, 0xd0, 0x6b, 0xb8, 0xa6, 0x2c, 0x4d, 0xc8,
0x67, 0xfb, 0x0d, 0xd7, 0x6c, 0xdd, 0x82, 0x72, 0x44, 0x6a, 0x46, 0x9d, 0xd0, 0x8a, 0x58, 0xa0, 0x86, 0x35, 0x65,
0x59, 0xe2, 0x1f, 0x2d, 0xdd, 0x00, 0xfe, 0xfe, 0xfb, 0xe0, 0xcc, 0xb4, 0x02, 0xf7, 0xb3, 0x04, 0x0f, 0xda, 0x9f,
0xf6, 0x77, 0x74, 0xf5, 0x07, 0x6d, 0x21, 0xc0, 0xd4, 0x0a, 0x0e, 0x38, 0xfc, 0x98, 0x7c, 0x22, 0xd6, 0xed, 0x25,
0x10, 0x2e, 0x6c, 0x27, 0xe9, 0xbe, 0xc4, 0x4b, 0xa9, 0xd9, 0x03, 0x0e, 0x17, 0xf5, 0x5a, 0x31, 0xaf, 0x1c, 0xe9,
0x00, 0xc2, 0x83, 0x04, 0x87, 0x5c, 0xf9, 0x8e, 0xba, 0x86, 0xb4, 0x74, 0x17, 0x0c, 0x80, 0x50, 0x41, 0xf6, 0x43,
0x00, 0xaf, 0xd2, 0x24, 0x09, 0x27, 0xfd, 0x27, 0x09, 0xe3, 0x34, 0x49, 0x16, 0x06, 0xdc, 0xda, 0x28, 0x44, 0x83,
0xfb, 0xa2, 0xa3, 0xae, 0x41, 0xbc, 0xc4, 0xef, 0xd2, 0x0c, 0xa5, 0x6f, 0x48, 0x36, 0xfb, 0x9d, 0x5c, 0xa3, 0x2b,
0x92, 0xcd, 0xd8, 0x75, 0x34, 0x43, 0xe9, 0x55, 0x34, 0x43, 0x59, 0x46, 0x66, 0x28, 0xf9, 0x82, 0x51, 0x2d, 0xa4,
0x2c, 0xb1, 0xd2, 0x0a, 0x30, 0xb2, 0xce, 0xe8, 0x07, 0x28, 0x31, 0x5b, 0x1b, 0x03, 0xca, 0xdd, 0x68, 0xa9, 0x0d,
0x8e, 0xab, 0x6f, 0xfe, 0x2f, 0x85, 0xce, 0x50, 0x65, 0x6b, 0x6d, 0xda, 0x12, 0xf7, 0xd9, 0x0f, 0x5e, 0x1c, 0xdc,
0x11, 0xf9, 0x4f, 0x78, 0x41, 0x8c, 0xb4, 0x11, 0x2b, 0xa1, 0x4a, 0xec, 0x35, 0xbe, 0xc6, 0x71, 0x75, 0x1f, 0x1e,
0xcf, 0xd1, 0x53, 0x1f, 0xfd, 0x18, 0x0f, 0x0f, 0x3e, 0xde, 0x17, 0x76, 0xb3, 0x42, 0xbb, 0x56, 0x2a, 0x5b, 0xe2,
0xc6, 0xb9, 0x2e, 0x8f, 0xe3, 0xed, 0x76, 0x4b, 0xb6, 0x53, 0xa2, 0xcd, 0x2a, 0xce, 0x92, 0x24, 0x89, 0xed, 0x66,
0x85, 0xd1, 0x50, 0x08, 0x38, 0xbb, 0xc2, 0xa8, 0x01, 0xb1, 0x6a, 0x5c, 0x0f, 0x57, 0x2f, 0x0e, 0x70, 0x2c, 0x3c,
0x47, 0x75, 0xff, 0xe9, 0xc2, 0x8a, 0xb9, 0xb0, 0x02, 0x3f, 0xd2, 0x00, 0x9f, 0xc2, 0x7c, 0xd9, 0x87, 0x79, 0x4d,
0x33, 0x94, 0xa1, 0xa4, 0xff, 0x65, 0x91, 0x87, 0x47, 0x2c, 0x7a, 0x86, 0xa1, 0x0b, 0xcc, 0x43, 0xed, 0x3c, 0x7a,
0x73, 0x96, 0x4d, 0xfd, 0xc9, 0x26, 0x4d, 0x1e, 0x0f, 0xbc, 0xc0, 0xaf, 0xf3, 0x4b, 0x3c, 0xca, 0x3e, 0x5c, 0x32,
0x78, 0x6b, 0x4d, 0xfa, 0x61, 0x4e, 0x67, 0x68, 0x36, 0x9e, 0xcc, 0x22, 0x0f, 0x9f, 0x31, 0x34, 0xdb, 0x64, 0x4d,
0xda, 0x46, 0xf3, 0x68, 0x46, 0xa7, 0x68, 0x3a, 0x3a, 0x32, 0x45, 0xd3, 0x4d, 0xd6, 0xcc, 0x3f, 0xcc, 0x2f, 0xcf,
0xa2, 0xe9, 0x97, 0x97, 0x71, 0x85, 0xc3, 0x1c, 0xe3, 0xc7, 0xc8, 0xf9, 0x65, 0xe4, 0xe4, 0xb3, 0x16, 0x2a, 0xc0,
0x38, 0x3c, 0xd6, 0xe0, 0x58, 0x13, 0xe0, 0x98, 0x69, 0x55, 0x8b, 0x15, 0xf9, 0x6c, 0xb5, 0xc2, 0x21, 0x71, 0x0d,
0xa8, 0xe0, 0x24, 0xea, 0x05, 0xa1, 0xa7, 0x04, 0xcf, 0x29, 0x2e, 0x3c, 0x9c, 0xeb, 0xdf, 0x09, 0x27, 0xa1, 0x74,
0xc4, 0x37, 0xec, 0xe4, 0x6f, 0xba, 0xe2, 0xa7, 0xfd, 0x6f, 0x3c, 0xc0, 0x2d, 0x65, 0x38, 0x24, 0x42, 0x29, 0x30,
0x77, 0xb0, 0x73, 0x25, 0x7e, 0xf7, 0xf6, 0x06, 0xbd, 0xe5, 0xdc, 0x80, 0xb5, 0x39, 0xc2, 0xaf, 0x1c, 0x69, 0x29,
0xfb, 0xba, 0x78, 0x93, 0x3e, 0x95, 0xfe, 0x97, 0xf8, 0x45, 0xa0, 0x3f, 0xc0, 0x6d, 0xb5, 0x79, 0x18, 0xe5, 0xbd,
0xfd, 0x85, 0x6f, 0x23, 0x56, 0x7e, 0x55, 0x8d, 0x02, 0x87, 0xc3, 0x89, 0xf8, 0x3a, 0x83, 0xb5, 0x82, 0xe3, 0x70,
0x22, 0xbf, 0xce, 0xd1, 0x59, 0xdf, 0xbc, 0x8e, 0xd0, 0xce, 0x12, 0x2b, 0x05, 0x83, 0x20, 0x0d, 0x49, 0xad, 0xcd,
0xcf, 0x94, 0x35, 0x8f, 0x09, 0xb2, 0x43, 0x47, 0xab, 0x47, 0x3d, 0xcc, 0x00, 0x75, 0x30, 0xaa, 0x0a, 0x30, 0x17,
0x1b, 0x1c, 0x2e, 0x14, 0x61, 0x92, 0x5a, 0xeb, 0x47, 0x46, 0xe9, 0x9d, 0xf3, 0xe1, 0xe0, 0x89, 0x1a, 0x22, 0xfd,
0xf5, 0xee, 0xdd, 0xef, 0xe5, 0x7d, 0x41, 0x87, 0x01, 0x89, 0xbf, 0xc5, 0xa8, 0x67, 0x3e, 0x33, 0x46, 0x12, 0x6a,
0xe7, 0x2b, 0x5e, 0x07, 0x96, 0x18, 0x6b, 0x45, 0x78, 0x2c, 0x6c, 0x47, 0xd5, 0x73, 0xb6, 0x3e, 0xa6, 0xaa, 0x88,
0x3d, 0xad, 0x2a, 0x62, 0x5a, 0xbd, 0x38, 0x98, 0xc0, 0xfa, 0xe1, 0xf6, 0x10, 0x1e, 0xef, 0x27, 0x8a, 0xfc, 0x7b,
0x0d, 0x66, 0x7f, 0x0b, 0x12, 0x98, 0xd3, 0x26, 0xc0, 0xe4, 0x89, 0x60, 0x48, 0x1c, 0xec, 0xdc, 0xcd, 0x38, 0x7f,
0x2d, 0xf1, 0x87, 0x13, 0x45, 0xb4, 0x62, 0x52, 0xb0, 0x87, 0xf2, 0x1c, 0x71, 0x78, 0x10, 0x64, 0x43, 0xe5, 0x1a,
0x4e, 0x3c, 0x92, 0xd4, 0x9a, 0xad, 0x6d, 0x10, 0x1e, 0x27, 0x8c, 0xd0, 0xae, 0x03, 0xc5, 0x6f, 0x1a, 0x21, 0x79,
0xa0, 0xc2, 0x63, 0xf8, 0x78, 0xd3, 0xcf, 0x8c, 0xfb, 0xd5, 0xf0, 0xd1, 0x80, 0xfc, 0x4f, 0xf9, 0xd2, 0x2f, 0x87,
0x97, 0x9f, 0x70, 0x48, 0xfa, 0xf8, 0xef, 0x1f, 0x37, 0x84, 0x6f, 0xef, 0x57, 0xbb, 0x56, 0x4e, 0x7c, 0xe8, 0xd1,
0x7c, 0x16, 0x1e, 0xef, 0x8f, 0xe1, 0x31, 0x5c, 0x14, 0xf1, 0x30, 0xe7, 0xab, 0xa2, 0x1f, 0xb9, 0xd5, 0x0f, 0x87,
0xa5, 0xde, 0x45, 0x56, 0x7c, 0x11, 0x6a, 0x95, 0x0b, 0xd5, 0x80, 0x11, 0xee, 0xc8, 0xc5, 0x66, 0x22, 0x54, 0xb7,
0x76, 0x87, 0x8e, 0x72, 0xee, 0x29, 0xb3, 0x6e, 0xb7, 0xa8, 0xb5, 0x72, 0x9e, 0x13, 0xf2, 0x14, 0xda, 0xe3, 0x40,
0xef, 0x27, 0x4c, 0xfe, 0x66, 0xf6, 0xdd, 0x71, 0xa9, 0xf9, 0xfe, 0xe0, 0xd3, 0x10, 0x51, 0x29, 0x56, 0x2a, 0x67,
0xa0, 0x1c, 0x98, 0x41, 0xa8, 0xa6, 0xad, 0x90, 0xfb, 0xdc, 0x52, 0x65, 0x23, 0x0b, 0x46, 0xd4, 0xc7, 0xe5, 0xda,
0x39, 0xad, 0x0e, 0x4b, 0x6d, 0x38, 0x98, 0x3c, 0x59, 0x0c, 0x40, 0x64, 0x28, 0x17, 0x6b, 0x9b, 0x93, 0xa9, 0x81,
0x76, 0xb1, 0xa4, 0xec, 0x61, 0x65, 0xf4, 0x5a, 0xf1, 0x88, 0xf9, 0xc9, 0x9b, 0x7f, 0x9b, 0xd6, 0x74, 0x0a, 0x6c,
0x31, 0x62, 0x75, 0x5d, 0x2f, 0xa4, 0x50, 0x10, 0x0d, 0xb3, 0x2d, 0xcf, 0xc8, 0x95, 0x17, 0xbb, 0x70, 0x93, 0x64,
0xfe, 0x60, 0xf0, 0x31, 0x4d, 0x92, 0xef, 0x16, 0xa7, 0x70, 0x92, 0x05, 0x5b, 0x1b, 0xab, 0x4d, 0xde, 0x69, 0xe1,
0xdd, 0x3c, 0xb6, 0x54, 0xa8, 0x4b, 0xef, 0x7d, 0xd9, 0x2c, 0xc6, 0x75, 0x94, 0x0b, 0xd5, 0x9b, 0xe9, 0x97, 0xd2,
0xa2, 0x15, 0x6a, 0xd8, 0xa9, 0x79, 0x36, 0x4f, 0xba, 0xdd, 0xf1, 0x54, 0x09, 0x87, 0x13, 0x77, 0x2d, 0x61, 0xb7,
0xf8, 0xbc, 0xb6, 0x4e, 0xd4, 0xfb, 0x68, 0xdc, 0xc9, 0xb9, 0xed, 0x28, 0x83, 0x68, 0x09, 0x6e, 0x0b, 0xa0, 0x16,
0xbd, 0x8d, 0x48, 0x38, 0x68, 0xed, 0x98, 0xa7, 0xb3, 0x9a, 0xbe, 0x60, 0x9f, 0xea, 0xfa, 0x5f, 0xdc, 0xbe, 0x8a,
0x0e, 0x2d, 0x35, 0x2b, 0xa1, 0xa2, 0xa5, 0x76, 0x4e, 0xb7, 0x79, 0x74, 0xdd, 0xed, 0x16, 0xe3, 0x91, 0x57, 0x96,
0xa7, 0xde, 0xcd, 0x7e, 0xd7, 0x9e, 0xf2, 0x9d, 0x76, 0x3b, 0x64, 0xb5, 0x14, 0x7c, 0xe4, 0xeb, 0x59, 0x50, 0x72,
0x4e, 0x4f, 0x3a, 0xeb, 0x76, 0xc8, 0x9f, 0x9d, 0x52, 0x7d, 0x55, 0xbf, 0xa6, 0x69, 0xf2, 0x37, 0x37, 0xc2, 0xeb,
0x3a, 0x5b, 0xd6, 0xe7, 0x4c, 0xf9, 0xb5, 0xe9, 0x57, 0x4b, 0x5f, 0x5a, 0x45, 0x3c, 0xbc, 0x6e, 0x7c, 0x65, 0x54,
0x85, 0xcf, 0x70, 0x55, 0x34, 0x29, 0x12, 0xbc, 0x6c, 0x29, 0xab, 0x2e, 0x66, 0x5b, 0x11, 0x37, 0xe9, 0x89, 0xd4,
0xa4, 0xd5, 0x93, 0xb9, 0x35, 0xd0, 0x7a, 0xef, 0xab, 0x1b, 0xad, 0x14, 0x30, 0x27, 0xd4, 0x0a, 0x39, 0x8d, 0xc6,
0x14, 0x10, 0x42, 0x8a, 0xa5, 0xa9, 0xde, 0x4b, 0xa0, 0x16, 0xd0, 0x96, 0x0a, 0x47, 0x8a, 0x78, 0xe0, 0x1f, 0x3a,
0x5d, 0xf0, 0x52, 0x81, 0x3b, 0xf7, 0x76, 0x33, 0x1d, 0x0c, 0xdc, 0x82, 0xf3, 0x9a, 0xbc, 0x81, 0x69, 0x55, 0xf8,
0x15, 0x8c, 0x68, 0xdf, 0xa5, 0x65, 0xbc, 0x15, 0xb5, 0xf0, 0x4f, 0x98, 0xaa, 0xe8, 0x8b, 0xdc, 0x6b, 0xf0, 0x79,
0x1e, 0x9e, 0x5b, 0x3d, 0x24, 0x41, 0xad, 0x5c, 0x53, 0x4e, 0x33, 0xd4, 0x49, 0xca, 0xa0, 0xd1, 0x92, 0x83, 0x29,
0x6f, 0x6f, 0x7f, 0xfb, 0x67, 0xe5, 0x9d, 0x79, 0x94, 0xeb, 0xec, 0xc3, 0x20, 0xe6, 0x81, 0x51, 0x6a, 0x7e, 0x35,
0x3c, 0xb2, 0x3a, 0x6a, 0xed, 0x56, 0x1b, 0xfe, 0x44, 0xc7, 0xfb, 0xf1, 0x70, 0xd0, 0xd3, 0xff, 0xfb, 0x56, 0xa9,
0x6e, 0xe9, 0x06, 0x8a, 0x78, 0x44, 0x8a, 0xd8, 0x3b, 0x3c, 0xd0, 0x9b, 0x91, 0xaf, 0x49, 0xab, 0x3f, 0xef, 0xde,
0xa2, 0xbf, 0x3a, 0x4e, 0x1d, 0x0c, 0x69, 0xeb, 0xa3, 0x6a, 0xc1, 0x35, 0x9a, 0x97, 0xef, 0xff, 0xbc, 0xbd, 0x3b,
0x47, 0xb8, 0xee, 0x99, 0x10, 0x28, 0x36, 0x3c, 0xf7, 0xd6, 0xd2, 0x89, 0x8e, 0x1a, 0xd7, 0xab, 0x8d, 0xfc, 0x14,
0x39, 0xc5, 0xd0, 0xd3, 0x6b, 0x21, 0x61, 0x08, 0x63, 0x10, 0xac, 0xd0, 0xc9, 0xab, 0x93, 0xb5, 0x67, 0x7e, 0xc5,
0xc3, 0x6d, 0xc7, 0xc3, 0xd5, 0xc7, 0xfd, 0xcb, 0xf7, 0xbf, 0x81, 0xdb, 0x13, 0xb5, 0x09, 0x0b, 0x00, 0x00};
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x95, 0x56, 0x6d, 0x8f, 0xdb, 0x36, 0x0c, 0xfe, 0xbe,
0x5f, 0xa1, 0x79, 0xdd, 0x6a, 0xaf, 0xb1, 0xfc, 0x92, 0x4b, 0xda, 0x3a, 0x96, 0x8b, 0xee, 0xd6, 0x62, 0x03, 0xd6,
0xad, 0xc0, 0xdd, 0xba, 0x0f, 0x45, 0x01, 0x2b, 0x32, 0x1d, 0xab, 0x27, 0x4b, 0x9e, 0xa4, 0xbc, 0x35, 0xc8, 0x7e,
0xfb, 0x20, 0xdb, 0xc9, 0xe5, 0x8a, 0x16, 0xd8, 0x10, 0xc4, 0xa0, 0x44, 0xf2, 0xe1, 0x8b, 0x28, 0x52, 0xf9, 0xb7,
0x95, 0x62, 0x76, 0xdf, 0x01, 0x6a, 0x6c, 0x2b, 0x8a, 0xdc, 0x7d, 0x91, 0xa0, 0x72, 0x45, 0x40, 0x16, 0x79, 0x03,
0xb4, 0x2a, 0xf2, 0x16, 0x2c, 0x45, 0xac, 0xa1, 0xda, 0x80, 0x25, 0x7f, 0xde, 0xbe, 0x0e, 0x9f, 0x15, 0xb9, 0xe0,
0xf2, 0x0e, 0x69, 0x10, 0x84, 0x33, 0x25, 0x51, 0xa3, 0xa1, 0x26, 0x15, 0xb5, 0x34, 0xe3, 0x2d, 0x5d, 0xc1, 0xa8,
0x22, 0x69, 0x0b, 0x64, 0xc3, 0x61, 0xdb, 0x29, 0x6d, 0x11, 0x53, 0xd2, 0x82, 0xb4, 0xc4, 0xdb, 0xf2, 0xca, 0x36,
0xa4, 0x82, 0x0d, 0x67, 0x10, 0xf6, 0x8b, 0x09, 0x97, 0xdc, 0x72, 0x2a, 0x42, 0xc3, 0xa8, 0x00, 0x92, 0x4c, 0xd6,
0x06, 0x74, 0xbf, 0xa0, 0x4b, 0x01, 0x44, 0x2a, 0xaf, 0xc8, 0x0d, 0xd3, 0xbc, 0xb3, 0xc8, 0xb9, 0x4a, 0x5a, 0x55,
0xad, 0x05, 0x20, 0xa6, 0x95, 0x31, 0x4a, 0xf3, 0x15, 0x97, 0x45, 0xa5, 0xd8, 0xba, 0x05, 0x69, 0xb1, 0x50, 0x8c,
0x5a, 0xae, 0x24, 0x36, 0x40, 0x35, 0x6b, 0x08, 0x21, 0xe5, 0x0b, 0x43, 0x37, 0x50, 0xfe, 0xf0, 0x83, 0x7f, 0x16,
0x5a, 0x81, 0x7d, 0x25, 0xc0, 0x91, 0xe6, 0xa7, 0xfd, 0x2d, 0x5d, 0xfd, 0x4e, 0x5b, 0xf0, 0x4b, 0x6a, 0x78, 0x05,
0x65, 0xf0, 0x3e, 0xfe, 0x80, 0x8d, 0xdd, 0x0b, 0xc0, 0x15, 0x37, 0x9d, 0xa0, 0x7b, 0x52, 0x2e, 0x85, 0x62, 0x77,
0x65, 0xb0, 0xa8, 0xd7, 0x92, 0x39, 0x70, 0x04, 0x3e, 0x04, 0x07, 0x01, 0x16, 0x49, 0xf2, 0x86, 0xda, 0x06, 0xb7,
0x74, 0xe7, 0x0f, 0x04, 0x97, 0x7e, 0xfa, 0xa3, 0x0f, 0x4f, 0x92, 0x38, 0x0e, 0x26, 0xfd, 0x27, 0x0e, 0xa2, 0x24,
0x8e, 0x17, 0x1a, 0xec, 0x5a, 0x4b, 0x64, 0xfd, 0x32, 0xef, 0xa8, 0x6d, 0x50, 0x45, 0xbc, 0x37, 0x49, 0x8a, 0x92,
0xe7, 0x38, 0x9d, 0xfd, 0x86, 0x9f, 0xa2, 0x2b, 0x9c, 0xce, 0xd8, 0xd3, 0x70, 0x86, 0x92, 0xab, 0x70, 0x86, 0xd2,
0x14, 0xcf, 0x50, 0xfc, 0xc9, 0x43, 0x35, 0x17, 0x82, 0x78, 0x52, 0x49, 0xf0, 0x90, 0xb1, 0x5a, 0xdd, 0x01, 0xf1,
0xd8, 0x5a, 0x6b, 0x90, 0xf6, 0x5a, 0x09, 0xa5, 0xbd, 0xa8, 0xf8, 0xe6, 0x7f, 0x01, 0x5a, 0x4d, 0xa5, 0xa9, 0x95,
0x6e, 0x89, 0xd7, 0xa7, 0xdb, 0x7f, 0x74, 0x90, 0x47, 0xe4, 0x3e, 0xc1, 0x05, 0x33, 0x1c, 0xf2, 0x4a, 0x3c, 0x87,
0xf8, 0xcc, 0x8b, 0x8a, 0x32, 0x38, 0x9e, 0xa3, 0xb7, 0x2e, 0xfa, 0x31, 0x1e, 0xed, 0xbf, 0x2f, 0x73, 0xb3, 0x59,
0xa1, 0x5d, 0x2b, 0xa4, 0x21, 0x5e, 0x63, 0x6d, 0x97, 0x45, 0xd1, 0x76, 0xbb, 0xc5, 0xdb, 0x29, 0x56, 0x7a, 0x15,
0xa5, 0x71, 0x1c, 0x47, 0x66, 0xb3, 0xf2, 0xd0, 0x70, 0xf2, 0x5e, 0x7a, 0xe5, 0xa1, 0x06, 0xf8, 0xaa, 0xb1, 0x3d,
0x5d, 0x3c, 0x3a, 0xc0, 0x31, 0x77, 0x12, 0x45, 0xf9, 0xe1, 0xc2, 0x8a, 0xbc, 0xb0, 0x02, 0x2f, 0x2e, 0xf2, 0xf6,
0xb8, 0x0f, 0xf3, 0x29, 0x4d, 0x51, 0x8a, 0xe2, 0xfe, 0x97, 0x86, 0x8e, 0x1e, 0x57, 0xe1, 0x67, 0x2b, 0x74, 0xb1,
0x72, 0x54, 0x3b, 0x0f, 0x9f, 0x9f, 0x75, 0x13, 0xb7, 0xb3, 0x49, 0xe2, 0xfb, 0x0d, 0xa7, 0xf0, 0xcb, 0xfc, 0x72,
0x1d, 0xa6, 0xef, 0x2e, 0x05, 0x9c, 0xb5, 0x26, 0x79, 0x37, 0xa7, 0x33, 0x34, 0x1b, 0x77, 0x66, 0xa1, 0xa3, 0xcf,
0x2b, 0x34, 0xdb, 0xa4, 0x4d, 0xd2, 0x86, 0xf3, 0x70, 0x46, 0xa7, 0x68, 0x3a, 0x3a, 0x32, 0x45, 0xd3, 0x4d, 0xda,
0xcc, 0xdf, 0xcd, 0x2f, 0xf7, 0xc2, 0xe9, 0xa7, 0xc7, 0x2e, 0xb9, 0x59, 0x59, 0xde, 0x47, 0xae, 0x2f, 0x23, 0xc7,
0x1f, 0x15, 0x97, 0x7e, 0xe9, 0xf2, 0x0f, 0x96, 0x35, 0x7e, 0x19, 0x31, 0x25, 0x6b, 0xbe, 0xc2, 0x1f, 0x8d, 0x92,
0x65, 0x80, 0x6d, 0x03, 0xd2, 0x3f, 0xa9, 0xfa, 0x36, 0x38, 0xd8, 0x9e, 0xe3, 0x7f, 0x81, 0x73, 0xae, 0x7f, 0xcb,
0xad, 0x00, 0x62, 0xb1, 0xbb, 0xa1, 0x93, 0x2f, 0xdc, 0x8a, 0x9f, 0xf6, 0xbf, 0x56, 0x7e, 0xd9, 0x52, 0x56, 0x06,
0x98, 0x4b, 0x09, 0xfa, 0x16, 0x76, 0x96, 0x94, 0x6f, 0x5e, 0x5e, 0xa3, 0x97, 0x55, 0xa5, 0xc1, 0x98, 0x0c, 0x95,
0x4f, 0x2c, 0x6e, 0x29, 0xfb, 0xba, 0x7a, 0x93, 0x3c, 0xd4, 0xfe, 0x8b, 0xbf, 0xe6, 0xe8, 0x77, 0xb0, 0x5b, 0xa5,
0xef, 0x46, 0x7d, 0x67, 0x7f, 0xe1, 0xae, 0x91, 0x26, 0x5f, 0x85, 0x91, 0x60, 0xcb, 0x60, 0xc2, 0xbf, 0x2e, 0x60,
0x0c, 0xaf, 0xca, 0x60, 0x42, 0xbf, 0x2e, 0xd1, 0x19, 0x77, 0x79, 0x2d, 0xa6, 0x9d, 0xc1, 0x46, 0x70, 0x06, 0x7e,
0x12, 0xe0, 0x5a, 0xe9, 0x57, 0x94, 0x35, 0x0f, 0x12, 0xe4, 0x5c, 0x51, 0xf7, 0x38, 0x4c, 0x03, 0xb5, 0x30, 0x42,
0xf9, 0x65, 0xc5, 0x37, 0x65, 0xb0, 0x50, 0x98, 0x09, 0x6a, 0x8c, 0x6b, 0x19, 0xc4, 0x39, 0xe7, 0xc2, 0x29, 0x27,
0x6a, 0x88, 0xf4, 0x97, 0xdb, 0x37, 0xbf, 0x91, 0x32, 0xa7, 0x43, 0x47, 0xf4, 0xbe, 0xf3, 0x50, 0x2f, 0x4c, 0xbc,
0x51, 0x30, 0x14, 0x50, 0xdb, 0xbe, 0xe2, 0x7d, 0x8b, 0xb5, 0x31, 0x3c, 0x38, 0xe6, 0xa6, 0xa3, 0xf2, 0x73, 0x31,
0x17, 0x93, 0x57, 0xe4, 0x91, 0xe3, 0x15, 0x79, 0x44, 0x8b, 0x47, 0x07, 0xe9, 0xf7, 0xcd, 0xed, 0x2e, 0x38, 0x3a,
0x6b, 0x7f, 0xaf, 0x41, 0xef, 0x6f, 0x40, 0x00, 0xb3, 0x4a, 0xfb, 0x25, 0xbe, 0x54, 0x74, 0x45, 0x01, 0x3b, 0x7b,
0x3d, 0x36, 0x5c, 0x8b, 0xdd, 0xe6, 0x44, 0x61, 0x25, 0x99, 0xe0, 0xec, 0x8e, 0x9c, 0x23, 0x0e, 0x0e, 0x1c, 0x6f,
0xa8, 0x58, 0xc3, 0x49, 0x86, 0xe2, 0x5a, 0xb1, 0xb5, 0xf1, 0x83, 0xe3, 0x44, 0x63, 0xda, 0x75, 0x20, 0xab, 0xeb,
0x86, 0x8b, 0xca, 0x57, 0xc1, 0x31, 0xb8, 0x3f, 0xe9, 0xcf, 0x8c, 0xbb, 0x59, 0xf0, 0x5e, 0x83, 0xf8, 0x87, 0x3c,
0x76, 0xd3, 0xe0, 0xf1, 0x87, 0x32, 0xc0, 0x7d, 0xfc, 0xe5, 0xfd, 0x48, 0x70, 0xd7, 0xfb, 0xc9, 0xae, 0x15, 0x13,
0x17, 0x7a, 0x38, 0x9f, 0x05, 0xc7, 0xf2, 0x18, 0x1c, 0x83, 0x45, 0x1e, 0x0d, 0x8d, 0xbd, 0xc8, 0xfb, 0x96, 0xdb,
0x8f, 0x94, 0x9e, 0x32, 0x0d, 0x80, 0x7d, 0xd0, 0xe2, 0x7f, 0x3c, 0x2c, 0xd5, 0x2e, 0x34, 0xfc, 0x13, 0x97, 0xab,
0x8c, 0xcb, 0x06, 0x34, 0xb7, 0xc7, 0x8a, 0x6f, 0x26, 0x5c, 0x76, 0x6b, 0x7b, 0xe8, 0x68, 0x55, 0x39, 0xce, 0xac,
0xdb, 0x2d, 0x6a, 0x25, 0xad, 0x93, 0x84, 0x2c, 0x81, 0xf6, 0x38, 0xf0, 0xfb, 0xe6, 0x93, 0x3d, 0x9f, 0x7d, 0x7f,
0x5c, 0xaa, 0x6a, 0x7f, 0x70, 0x19, 0x0a, 0xa9, 0xe0, 0x2b, 0x99, 0x31, 0x90, 0x16, 0xf4, 0xa0, 0x54, 0xd3, 0x96,
0x8b, 0x7d, 0x66, 0xa8, 0x34, 0xa1, 0x01, 0xcd, 0xeb, 0xe3, 0x72, 0x6d, 0xad, 0x92, 0x07, 0xe6, 0x9a, 0x6d, 0xf6,
0x5d, 0x5d, 0xd7, 0x0b, 0xb6, 0xd6, 0x46, 0xe9, 0xac, 0x53, 0xbc, 0xd7, 0x5b, 0x52, 0x76, 0xb7, 0xd2, 0x6a, 0x2d,
0xab, 0x70, 0x14, 0x4a, 0x6a, 0x3a, 0x05, 0xb6, 0x58, 0x2a, 0x5d, 0x81, 0xce, 0xe2, 0x91, 0x08, 0x35, 0xad, 0xf8,
0xda, 0x64, 0x78, 0xaa, 0xa1, 0x5d, 0x0c, 0xee, 0x24, 0x71, 0xfc, 0xfd, 0xe2, 0xe4, 0x79, 0x7c, 0xe9, 0x37, 0x4e,
0x9d, 0x94, 0xe0, 0x12, 0xc2, 0xa1, 0x57, 0x66, 0x29, 0xbe, 0xd2, 0xd0, 0x1e, 0x5b, 0xca, 0xe5, 0xa5, 0xf7, 0xae,
0xa2, 0x16, 0x2d, 0x97, 0xc3, 0x28, 0xcd, 0xd2, 0x79, 0xdc, 0xed, 0x16, 0xe3, 0xe4, 0xca, 0xb8, 0xec, 0x11, 0xfa,
0xf9, 0x75, 0x3c, 0x15, 0xc9, 0xe1, 0xe3, 0xda, 0x58, 0x5e, 0xef, 0xc3, 0x71, 0x24, 0x67, 0xa6, 0xa3, 0x0c, 0xc2,
0x25, 0xd8, 0x2d, 0x80, 0x5c, 0xf4, 0xb0, 0x21, 0xb7, 0xd0, 0x9a, 0x53, 0x6a, 0x4e, 0x70, 0xb5, 0x80, 0xdd, 0x19,
0xa6, 0xaf, 0xe5, 0xc3, 0x7f, 0x96, 0x76, 0x05, 0x76, 0x68, 0xa9, 0x5e, 0x71, 0x19, 0x2e, 0x95, 0xb5, 0xaa, 0xcd,
0xc2, 0xa7, 0xdd, 0x6e, 0x31, 0x6e, 0x39, 0xb0, 0x2c, 0x89, 0xbb, 0xdd, 0xb1, 0x1f, 0xc3, 0xa7, 0x7c, 0x5f, 0xd5,
0xcf, 0x68, 0x12, 0x7f, 0x21, 0xc7, 0x55, 0x5d, 0xa7, 0xcb, 0xfa, 0x94, 0xe3, 0xa4, 0xdb, 0x21, 0xa3, 0x04, 0xaf,
0x46, 0xb8, 0x1e, 0x09, 0xc5, 0xe7, 0xd4, 0x26, 0xb3, 0x6e, 0x87, 0x92, 0xcb, 0xcc, 0xb8, 0x89, 0xea, 0xa6, 0x8e,
0xab, 0xb5, 0x22, 0x8f, 0x86, 0x97, 0x8e, 0xab, 0x8c, 0x22, 0x77, 0x19, 0x2e, 0xf2, 0x26, 0x41, 0xbc, 0x22, 0x2d,
0x65, 0xc5, 0x45, 0xdb, 0xcb, 0xa3, 0x26, 0x39, 0xb1, 0x9a, 0xa4, 0x78, 0xd0, 0xd2, 0x06, 0x5e, 0xef, 0x7d, 0x71,
0xad, 0xa4, 0x04, 0x66, 0xb9, 0x5c, 0x21, 0xab, 0xd0, 0x98, 0x02, 0x8c, 0x71, 0xbe, 0xd4, 0xc5, 0x5b, 0x01, 0xd4,
0x00, 0xda, 0x52, 0x6e, 0x71, 0x1e, 0x0d, 0xf2, 0x43, 0x13, 0xe0, 0x15, 0x91, 0x60, 0xcf, 0xd7, 0xbe, 0x99, 0x0e,
0x06, 0x6e, 0xc0, 0x3a, 0x24, 0x67, 0x60, 0x5a, 0xe4, 0x6e, 0x3a, 0x23, 0xda, 0x5f, 0x60, 0x12, 0x6d, 0x79, 0xcd,
0xdd, 0xeb, 0xa6, 0xc8, 0xfb, 0x22, 0x77, 0x08, 0x2e, 0xcf, 0xc3, 0xd3, 0xab, 0xa7, 0x04, 0xc8, 0x95, 0x6d, 0xc8,
0x34, 0x45, 0x9d, 0xa0, 0x0c, 0x1a, 0x25, 0x2a, 0xd0, 0xe4, 0xe6, 0xe6, 0xd7, 0x9f, 0x0b, 0xe7, 0xcc, 0xbd, 0x5e,
0x67, 0xee, 0x06, 0x35, 0x47, 0x8c, 0x5a, 0xf3, 0xab, 0xe1, 0xc1, 0xd5, 0x51, 0x63, 0xb6, 0x4a, 0x57, 0x0f, 0x30,
0xde, 0x8e, 0x9b, 0x03, 0x4e, 0xff, 0xef, 0xaf, 0x4a, 0x71, 0x43, 0x37, 0x90, 0x47, 0xe3, 0x22, 0x8f, 0x9c, 0xc3,
0x03, 0xbf, 0x19, 0xe5, 0x9a, 0xa4, 0xf8, 0xe3, 0xf6, 0x25, 0xfa, 0xb3, 0xab, 0xa8, 0x85, 0x21, 0x6d, 0x7d, 0x54,
0x2d, 0xd8, 0x46, 0x55, 0xe4, 0xed, 0x1f, 0x37, 0xb7, 0xe7, 0x08, 0xd7, 0xbd, 0x10, 0x02, 0xc9, 0x86, 0xa7, 0xdf,
0x5a, 0x58, 0xde, 0x51, 0x6d, 0x7b, 0xd8, 0xd0, 0x35, 0x98, 0x53, 0x0c, 0x3d, 0xbf, 0xe6, 0x02, 0x86, 0x30, 0x06,
0xc5, 0x02, 0x9d, 0xbc, 0x3a, 0x59, 0xfb, 0xcc, 0xaf, 0x68, 0x38, 0xed, 0x68, 0x38, 0xfa, 0xa8, 0x7f, 0x05, 0xff,
0x0b, 0x54, 0xcf, 0x54, 0x8b, 0x15, 0x0b, 0x00, 0x00};
#else // Brotli (default, smaller)
constexpr uint8_t INDEX_BR[] PROGMEM = {
0x1b, 0x08, 0x0b, 0x00, 0xe4, 0x7f, 0x9b, 0xad, 0xbb, 0x97, 0x53, 0xde, 0xb7, 0x25, 0x0e, 0x69, 0xd4, 0x69, 0x89,
0xba, 0xa5, 0x55, 0x22, 0x04, 0x27, 0xeb, 0x00, 0x52, 0xac, 0xbc, 0xec, 0x5f, 0xfb, 0xb5, 0x7a, 0x12, 0x0a, 0xd6,
0x48, 0x84, 0x4a, 0x48, 0x5a, 0xcb, 0xbd, 0xb7, 0x72, 0x66, 0x4e, 0x62, 0xd8, 0xbd, 0x7f, 0x88, 0x68, 0x86, 0x28,
0xd6, 0xf1, 0xda, 0x2c, 0xa2, 0x32, 0x5c, 0xdd, 0xab, 0xb2, 0x15, 0xbc, 0x24, 0x13, 0xe6, 0xbd, 0x0c, 0x52, 0x63,
0x82, 0x88, 0x6d, 0x74, 0x71, 0x51, 0x9c, 0xe2, 0x40, 0x56, 0xea, 0xb0, 0x5c, 0xb7, 0x1d, 0x81, 0x3a, 0x0c, 0xf2,
0xd7, 0xa8, 0x5c, 0x35, 0x2d, 0x43, 0xb3, 0x42, 0x37, 0x7c, 0x60, 0xd8, 0xc1, 0x95, 0x91, 0x94, 0x3c, 0x29, 0x20,
0x96, 0x20, 0xf6, 0x24, 0xb7, 0x83, 0x4a, 0x06, 0xf1, 0xc3, 0x2f, 0x88, 0x50, 0x55, 0x35, 0x2d, 0xe8, 0xb6, 0x21,
0x38, 0x61, 0x8e, 0x44, 0x2a, 0xac, 0x39, 0xcf, 0x74, 0xaa, 0x31, 0x7f, 0x62, 0x32, 0x9b, 0x99, 0x42, 0x0a, 0xf6,
0x6f, 0xd8, 0x89, 0x3f, 0x49, 0xb1, 0xa2, 0x52, 0x0a, 0x8e, 0xb3, 0x27, 0x0b, 0x07, 0x07, 0x58, 0xb0, 0x8f, 0xaa,
0xdc, 0x68, 0x12, 0x0f, 0x95, 0xbf, 0xc7, 0x36, 0xd5, 0x60, 0x5a, 0xc7, 0x80, 0xac, 0x52, 0xf7, 0xa7, 0x2d, 0xb6,
0x64, 0xda, 0xda, 0x11, 0x8d, 0x6a, 0xe5, 0xba, 0xe9, 0xda, 0xdc, 0x62, 0xc3, 0xcb, 0xae, 0xc1, 0xe1, 0x11, 0xb6,
0x33, 0x29, 0x04, 0x09, 0xfe, 0x09, 0x08, 0xc2, 0x1f, 0x98, 0x16, 0x26, 0x0d, 0xce, 0xd7, 0x75, 0xfb, 0xd7, 0xa5,
0x82, 0xd7, 0x32, 0x44, 0x72, 0xc1, 0xc2, 0xe4, 0x15, 0xcb, 0x50, 0xfc, 0xd3, 0x58, 0x64, 0x34, 0x41, 0x32, 0xbe,
0x1f, 0x08, 0x43, 0x16, 0x14, 0xf7, 0x70, 0x2c, 0x1c, 0x61, 0x09, 0x78, 0x73, 0xd1, 0x30, 0xf6, 0xed, 0x85, 0x55,
0x50, 0x02, 0xf0, 0x68, 0x50, 0x5c, 0x1a, 0xd7, 0x3b, 0x8e, 0xf2, 0x23, 0xc8, 0x88, 0x39, 0xd0, 0x84, 0xf7, 0xa6,
0xd1, 0xa3, 0xfb, 0xe5, 0x44, 0xc0, 0x4c, 0x2f, 0x6f, 0x19, 0x70, 0x75, 0xf6, 0x1c, 0xb8, 0xce, 0x89, 0x4f, 0x01,
0x8d, 0xa1, 0x71, 0xf2, 0x97, 0xf8, 0xe7, 0xd3, 0xf1, 0xe1, 0xfa, 0xfc, 0xc8, 0x03, 0x78, 0x14, 0xa0, 0x3d, 0x28,
0xb7, 0xeb, 0x26, 0x52, 0x59, 0xa8, 0xb5, 0x0d, 0x5c, 0x8a, 0x6b, 0xe5, 0xf6, 0x30, 0xd6, 0xf7, 0x71, 0x31, 0xe8,
0xbd, 0xc9, 0xfa, 0xf5, 0x71, 0x9d, 0xff, 0xf6, 0x69, 0x62, 0x5f, 0xb5, 0xc7, 0x06, 0x43, 0x54, 0xb5, 0x87, 0xf1,
0xcc, 0x84, 0xe8, 0xb7, 0x56, 0x38, 0x43, 0x6a, 0xa6, 0x2f, 0x53, 0xd1, 0x89, 0x48, 0x8d, 0x72, 0x75, 0x4a, 0x17,
0xf6, 0x8d, 0xb2, 0xeb, 0xb1, 0x6b, 0x29, 0x1e, 0xd5, 0x74, 0xc7, 0xb3, 0xf4, 0xf1, 0x04, 0x0d, 0xbf, 0x08, 0x81,
0x8f, 0xfe, 0x8d, 0xfc, 0xf2, 0x35, 0x92, 0xa0, 0x24, 0x88, 0x12, 0x6a, 0xe6, 0x2f, 0x01, 0x94, 0x5c, 0x4b, 0xf9,
0x6b, 0x9a, 0xf6, 0x1a, 0x35, 0x11, 0x8a, 0xc6, 0x04, 0x8d, 0x50, 0x64, 0x48, 0x2d, 0x8b, 0xdf, 0xdf, 0xa1, 0xd1,
0xfd, 0x21, 0xd7, 0x40, 0x96, 0x00, 0xb1, 0x9f, 0x54, 0xc2, 0xe1, 0x00, 0x79, 0x15, 0x80, 0xf8, 0xca, 0x8e, 0xc5,
0x06, 0x03, 0xdf, 0x72, 0x49, 0xc0, 0x08, 0x27, 0x90, 0xe3, 0x14, 0xc2, 0xac, 0xc3, 0x83, 0xc9, 0xf6, 0x9d, 0x12,
0xab, 0x46, 0xae, 0x03, 0x74, 0x1d, 0x27, 0xce, 0x91, 0x1d, 0x4e, 0xb4, 0xbe, 0x80, 0xe7, 0x6a, 0x6d, 0x0a, 0x20,
0x47, 0x6b, 0x00, 0x4e, 0x25, 0x52, 0xe6, 0xf5, 0xe9, 0x43, 0x84, 0xc1, 0x0d, 0x4b, 0x04, 0xb3, 0x91, 0x49, 0xf5,
0xe9, 0xc4, 0x2e, 0x1b, 0xf9, 0x98, 0xf9, 0xea, 0x9e, 0xb8, 0xf6, 0x53, 0xf8, 0x42, 0xc2, 0x60, 0x5c, 0xa9, 0xd2,
0xfe, 0x0a, 0xe5, 0xd4, 0x7d, 0x36, 0x76, 0x04, 0x12, 0x38, 0xa1, 0xc4, 0x30, 0xb8, 0xf2, 0x69, 0x86, 0x5b, 0xe7,
0xe5, 0xa0, 0xc0, 0xfe, 0x91, 0x19, 0x03, 0x1e, 0xa9, 0x93, 0x26, 0x49, 0x58, 0xd5, 0xf6, 0x33, 0x4e, 0x0a, 0x89,
0x64, 0x1e, 0xb4, 0x7a, 0x5d, 0x0d, 0x12, 0xcf, 0x2b, 0xdd, 0x35, 0x90, 0x55, 0xc3, 0x0e, 0xcd, 0x0e, 0xad, 0x6b,
0x8f, 0x43, 0xd0, 0xb4, 0x6a, 0xdb, 0x4b, 0xe5, 0xa4, 0x9b, 0x09, 0x23, 0x1a, 0x43, 0xa9, 0xff, 0x61, 0x8b, 0x07,
0xd6, 0x0f, 0x83, 0x23, 0xbe, 0x8a, 0x66, 0xa2, 0x10, 0x2f, 0x11, 0x01, 0x0d, 0xc6, 0x96, 0xba, 0x87, 0xaa, 0xa8,
0x44, 0x7c, 0x1e, 0x34, 0xdb, 0xba, 0x41, 0x90, 0xf0, 0x7a, 0xdb, 0x63, 0x60, 0x96, 0x8d, 0x64, 0xcb, 0x2f, 0x28,
0x96, 0x04, 0xd5, 0xc0, 0x7a, 0xe8, 0xec, 0xd1, 0x61, 0x1b, 0x09, 0x4b, 0x4c, 0x6e, 0x1b, 0x31, 0x98, 0x9c, 0x6d,
0xdb, 0xd0, 0xec, 0xa4, 0x0f, 0x0a, 0xe0, 0xc8, 0x35, 0xc4, 0x93, 0xdc, 0xee, 0x19, 0x00, 0x80, 0x07, 0xf5, 0xcf,
0xe0, 0x7f, 0x75, 0xf8, 0x52, 0x3a, 0xfc, 0x0d, 0x84, 0xa5, 0xc1, 0xb2, 0x1c, 0x25, 0x40, 0xc5, 0x8b, 0xb3, 0xdb,
0x7a, 0x1b, 0x44, 0xff, 0x79, 0x9a, 0x26, 0xc4, 0xe7, 0x9e, 0x78, 0x4c, 0xa5, 0x94, 0xab, 0x78, 0x34, 0x9d, 0xb5,
0xb7, 0x24, 0x26, 0xe7, 0x9a, 0xf3, 0xe5, 0x2a, 0x35, 0x4b, 0xbe, 0x74, 0xd7, 0x01, 0xbc, 0x71, 0x72, 0x03, 0x5c,
0x40, 0x24, 0x17, 0x61, 0x5b, 0x7b, 0x19, 0xc2, 0x7f, 0x4e, 0x5b, 0x24, 0xfb, 0x8b, 0xc3, 0x51, 0x00, 0x3d, 0x09,
0x04, 0x05, 0x72, 0x64, 0xf6, 0xf0, 0x6b, 0x99, 0x38, 0x93, 0x5b, 0xac, 0x0c, 0xff, 0x40, 0x7b, 0x53, 0xba, 0xab,
0x61, 0xc9, 0xa2, 0xde, 0xd6, 0x2b, 0x0c, 0xbd, 0x85, 0xac, 0x4c, 0x64, 0x8b, 0xe6, 0x69, 0x2b, 0x56, 0x10, 0x1b,
0x08, 0x59, 0x6c, 0x55, 0x0a, 0xaa, 0x93, 0x85, 0x1d, 0x45, 0xda, 0xc6, 0x39, 0xe6, 0x6a, 0xab, 0x71, 0x73, 0x46,
0x0f, 0xf7, 0xab, 0x4e, 0x88, 0xae, 0x8c, 0xe0, 0x91, 0xda, 0x35, 0x8c, 0xd3, 0x18, 0x92, 0x3d, 0xab, 0x2f, 0x0d,
0xd6, 0xc9, 0x06, 0xdb, 0xc2, 0x62, 0xcb, 0x8a, 0x23, 0x5b, 0x28, 0x2e, 0x9b, 0x96, 0xc4, 0xc1, 0x42, 0xa9, 0x8d,
0x69, 0xe5, 0x8f, 0x89, 0x12, 0x11, 0x9a, 0x56, 0x3b, 0x3c, 0x2b, 0xb4, 0xb2, 0x7b, 0xfd, 0xdb, 0x80, 0x92, 0xb4,
0xca, 0x44, 0x37, 0x8c, 0x94, 0x2f, 0x4a, 0xb4, 0xc4, 0x28, 0x83, 0x8a, 0x78, 0xcb, 0xd2, 0x5c, 0x5c, 0x35, 0x29,
0x95, 0x35, 0x2f, 0x99, 0x28, 0x4f, 0x22, 0x18, 0x70, 0x85, 0xee, 0x2c, 0xb9, 0xef, 0x14, 0x57, 0x73, 0x23, 0x45,
0xae, 0xdc, 0x54, 0x56, 0x55, 0x78, 0x56, 0xad, 0x48, 0x26, 0x27, 0xbf, 0xcb, 0xd7, 0x54, 0xa9, 0xa8, 0xd7, 0xb5,
0x71, 0x9c, 0xe7, 0x79, 0x89, 0x5c, 0xa9, 0x6a, 0x53, 0xe8, 0xfa, 0x0d, 0x69, 0x36, 0xed, 0xad, 0x33, 0xc9, 0x75,
0x17, 0xe9, 0x8f, 0x31, 0x58, 0xa6, 0x27, 0xfc, 0x79, 0xc6, 0xb6, 0xd5, 0x65, 0x90, 0x31, 0xc6, 0x9d, 0x29, 0x95,
0x83, 0xe5, 0x4e, 0xbb, 0xd7, 0xdc, 0x8e, 0xd0, 0x3a, 0x54, 0x27, 0xce, 0xf5, 0xdb, 0xbd, 0x89, 0x3c, 0x61, 0xb3,
0x71, 0x23, 0xc7, 0x55, 0x9e, 0xea, 0x50, 0xe4, 0x37, 0xae, 0x72, 0x34, 0x66, 0xb9, 0x6e, 0x2c, 0x0a, 0x69, 0x8d,
0x94, 0x8b, 0x98, 0x08, 0x05, 0x3c, 0x45, 0x45, 0xe1, 0x6c, 0x22, 0xc5, 0x8f, 0x1f, 0x9f, 0x3f, 0xd2, 0x01, 0x12,
0xec, 0x86, 0x2f, 0x87, 0x8b, 0x47, 0x30, 0xd0, 0x77, 0x77, 0x1a, 0x13, 0x2d, 0xc6, 0x31, 0x65, 0x77, 0xd8, 0x8c,
0xe7, 0xe0, 0x16, 0xf9, 0x4b, 0xd4, 0xc5, 0xa8, 0x67, 0x79, 0xe7, 0xc3, 0x07, 0x94, 0x46, 0xa3, 0x8c, 0x67, 0xd3,
0xff, 0x5f, 0x96, 0xfa, 0xed, 0xa7, 0xd3, 0xdd, 0x4f, 0x27, 0x49, 0x27, 0xcf, 0xa4, 0x02, 0xc3, 0x27, 0x3c, 0x96,
0x64, 0x24, 0x55, 0xc8, 0x36, 0x45, 0x68, 0x90, 0xea, 0x03, 0x0b, 0x46, 0xa7, 0x8d, 0xb4, 0x26, 0x91, 0x07, 0x4c,
0x80, 0x7b, 0x93, 0xa8, 0x10, 0xcb, 0x5e, 0x8d, 0x42, 0x86, 0x3e, 0x2a, 0x7c, 0x99, 0x79, 0x8e, 0xcb, 0x43, 0x28};
0x1b, 0x14, 0x0b, 0x00, 0xe4, 0x6f, 0xcd, 0xfc, 0x7b, 0x2e, 0x27, 0xd2, 0x21, 0x2b, 0x58, 0xea, 0x16, 0xf8, 0xa5,
0xb6, 0x47, 0x14, 0xb3, 0x4c, 0x56, 0xcc, 0x20, 0x5b, 0x1d, 0xfe, 0x7d, 0xfb, 0xb5, 0x7a, 0x12, 0x0a, 0xd6, 0x48,
0x84, 0xa2, 0x21, 0x69, 0x0d, 0x77, 0x33, 0xf3, 0x77, 0xcf, 0x4c, 0x21, 0xf1, 0xde, 0xee, 0x7d, 0x44, 0xbc, 0xd3,
0xc4, 0x33, 0x89, 0x1a, 0x69, 0x68, 0x48, 0x57, 0xa7, 0x17, 0x0b, 0x4d, 0x91, 0xac, 0x30, 0x48, 0x21, 0x4d, 0x4c,
0x10, 0x57, 0x46, 0x14, 0x17, 0xd5, 0x21, 0x0e, 0xb4, 0x50, 0x87, 0xad, 0x62, 0xda, 0x11, 0x68, 0xc3, 0x20, 0x7f,
0x2d, 0xdc, 0x57, 0xeb, 0x2c, 0x36, 0xdb, 0xc4, 0x84, 0x0f, 0xac, 0xec, 0x08, 0x91, 0xa3, 0x92, 0x27, 0x45, 0xc4,
0x1e, 0xa4, 0x9e, 0x72, 0x3b, 0xc2, 0xe3, 0x20, 0x7d, 0xf8, 0x05, 0x09, 0xea, 0xe3, 0xa6, 0x3f, 0x11, 0x8b, 0x16,
0x11, 0x6b, 0x26, 0x91, 0x1a, 0x2c, 0x19, 0x24, 0xf7, 0x5d, 0x44, 0x82, 0x49, 0x44, 0x15, 0xce, 0x39, 0xdc, 0xcb,
0x8f, 0x5b, 0xe1, 0xe2, 0x42, 0x96, 0xa2, 0x10, 0x3c, 0x20, 0xa5, 0x65, 0x85, 0x49, 0x6a, 0x06, 0x08, 0xd1, 0xcb,
0x41, 0xb8, 0x49, 0x20, 0x73, 0x71, 0xfe, 0x55, 0x61, 0x45, 0xc6, 0x95, 0x72, 0xc8, 0xf0, 0xa5, 0xe9, 0xe6, 0x3a,
0xb9, 0xc3, 0x8a, 0x9f, 0xb5, 0xc1, 0xc9, 0x15, 0x56, 0x93, 0x38, 0x8a, 0x48, 0xf0, 0x4f, 0x38, 0x22, 0xe1, 0x8d,
0x55, 0xbb, 0x8c, 0xc3, 0xb0, 0x68, 0xcc, 0x7f, 0x6f, 0xf8, 0xc9, 0x9b, 0x38, 0x41, 0xf1, 0x94, 0x25, 0xf9, 0x6b,
0x56, 0xa2, 0xec, 0xa7, 0xbd, 0x2e, 0x69, 0x8e, 0xe2, 0xec, 0x36, 0x94, 0x24, 0x2c, 0x12, 0x1d, 0x4e, 0x76, 0x7e,
0x23, 0xcc, 0xf2, 0x6e, 0x35, 0x1a, 0x9c, 0xed, 0x6f, 0x15, 0x3f, 0xc9, 0x72, 0xdc, 0xfd, 0x13, 0x0f, 0x16, 0x8a,
0x23, 0x4f, 0xf9, 0x2e, 0x63, 0x44, 0x91, 0x77, 0xe0, 0xb3, 0xd1, 0x78, 0x74, 0xdb, 0x4a, 0x2c, 0xd8, 0xe8, 0xf9,
0x2c, 0x03, 0xbe, 0xae, 0xac, 0x4e, 0x42, 0x01, 0xc4, 0x4b, 0x40, 0xe7, 0x94, 0x34, 0x85, 0x2c, 0xfe, 0xf5, 0x74,
0x75, 0xd8, 0xdc, 0xec, 0x6a, 0x00, 0x3f, 0x3f, 0x81, 0x77, 0xa8, 0xcd, 0xde, 0x6d, 0x5a, 0x47, 0xa1, 0x99, 0x36,
0x87, 0xb6, 0x78, 0x35, 0x3c, 0x9a, 0x64, 0x15, 0x7c, 0x56, 0x76, 0x22, 0xce, 0x46, 0xe5, 0x17, 0x57, 0x05, 0xfc,
0x09, 0x69, 0xae, 0x39, 0xaa, 0xee, 0xc9, 0x4e, 0x7f, 0x99, 0x2a, 0x65, 0x82, 0x7e, 0xeb, 0x23, 0x4f, 0x42, 0xd5,
0xca, 0xcb, 0x42, 0x74, 0x2e, 0xd2, 0xa2, 0x62, 0x57, 0xd0, 0xa9, 0x7b, 0x4b, 0xac, 0x7b, 0x65, 0x13, 0x47, 0x0f,
0x2d, 0x3d, 0xf6, 0xbc, 0x78, 0x5c, 0xa3, 0xc9, 0x57, 0x4b, 0x10, 0x62, 0x68, 0x19, 0x7f, 0xfd, 0x1a, 0xcb, 0x51,
0x1e, 0x41, 0x39, 0x55, 0xf3, 0x97, 0x30, 0xca, 0x37, 0xb6, 0x42, 0x1d, 0x2d, 0x8c, 0xc6, 0x65, 0x8a, 0xd2, 0x89,
0x88, 0xa6, 0x28, 0xbd, 0x56, 0x7c, 0x2d, 0x5e, 0x0d, 0x2f, 0x9e, 0xc3, 0xa5, 0x80, 0xaf, 0xcc, 0x00, 0xde, 0xe6,
0x5a, 0x8b, 0xda, 0xff, 0x1f, 0x16, 0xc8, 0x83, 0x5e, 0xe5, 0xea, 0x25, 0x86, 0x70, 0x55, 0x25, 0x01, 0x14, 0x0c,
0x77, 0xd8, 0x31, 0x21, 0xcc, 0x79, 0x15, 0x3b, 0x32, 0x3a, 0xd3, 0xc5, 0x30, 0xaf, 0x03, 0xd8, 0x3e, 0x44, 0xb8,
0x63, 0xb5, 0x74, 0x4f, 0x41, 0x4b, 0xf0, 0x24, 0x74, 0xb2, 0x06, 0xb2, 0x7b, 0x06, 0x60, 0xdc, 0xc1, 0x3e, 0x0e,
0x6f, 0x1e, 0x3c, 0x42, 0xa0, 0xdb, 0x36, 0x43, 0x30, 0x71, 0xcc, 0xd6, 0x1a, 0xbd, 0x38, 0x61, 0x19, 0x3f, 0xf2,
0xdf, 0xf4, 0x53, 0x3d, 0xc1, 0x14, 0xbe, 0x50, 0x1c, 0x2c, 0xf3, 0xaa, 0x74, 0x36, 0xcb, 0xbd, 0x7a, 0x4b, 0xa3,
0x1c, 0x90, 0x40, 0x5b, 0x4a, 0x0f, 0x83, 0x6e, 0x9e, 0x96, 0x28, 0x3d, 0x77, 0x43, 0x05, 0x0e, 0x39, 0x26, 0x15,
0xb8, 0x6b, 0x4e, 0x3a, 0x62, 0xc2, 0xda, 0xde, 0x8e, 0x29, 0x29, 0x0b, 0x09, 0xa2, 0xb3, 0xa7, 0x1e, 0x9a, 0x0c,
0x8f, 0xa1, 0x46, 0x6f, 0x92, 0xf3, 0x9e, 0xb5, 0xc5, 0x7e, 0x0e, 0x8d, 0xeb, 0x55, 0x08, 0xfa, 0xc9, 0xd8, 0x4e,
0xe3, 0xd0, 0xca, 0x2a, 0x96, 0x11, 0x7e, 0xb1, 0xd4, 0x7f, 0x8f, 0x1d, 0xb3, 0xc3, 0xa0, 0x89, 0x6f, 0x93, 0x99,
0x55, 0x48, 0x97, 0x08, 0x79, 0x66, 0xe9, 0x4a, 0x6b, 0xa0, 0x29, 0xf2, 0x10, 0x1f, 0x22, 0xa2, 0x1b, 0x41, 0xdf,
0xa3, 0xde, 0x62, 0x60, 0x8e, 0xa1, 0x60, 0xc4, 0xd5, 0xce, 0x81, 0x47, 0x84, 0x3b, 0x66, 0x60, 0x74, 0xa7, 0x74,
0xcc, 0x48, 0xe0, 0xe1, 0xd5, 0x0c, 0x15, 0x98, 0x3d, 0xa7, 0x9c, 0xb2, 0xec, 0xba, 0x0f, 0x2c, 0x52, 0x14, 0x7b,
0xe2, 0x49, 0x6e, 0x0f, 0x8c, 0x00, 0xe0, 0x81, 0xf6, 0x57, 0xe4, 0x3f, 0xbf, 0x7c, 0xa9, 0x5e, 0xfe, 0x01, 0xc2,
0x64, 0xb0, 0x05, 0x60, 0x01, 0xaa, 0x78, 0x65, 0xb2, 0xeb, 0x56, 0x41, 0xf2, 0xbf, 0xa3, 0x45, 0x4e, 0x3c, 0x78,
0xe2, 0xa1, 0x0d, 0xa9, 0x2a, 0xc0, 0x8a, 0xc0, 0x6f, 0xe4, 0x66, 0xbe, 0x72, 0x35, 0x5e, 0xf7, 0x3b, 0x42, 0x53,
0xd4, 0xe6, 0x66, 0xb6, 0x78, 0xcd, 0xaa, 0x6f, 0xf4, 0x26, 0x80, 0x3a, 0x4e, 0x74, 0x80, 0x17, 0x88, 0x44, 0x23,
0xa6, 0x3a, 0x6f, 0x87, 0xb8, 0xd0, 0x4d, 0xd3, 0xfc, 0x7c, 0xd6, 0x38, 0x2a, 0x00, 0x28, 0x01, 0xa2, 0x40, 0x94,
0x6c, 0x1e, 0x8a, 0xed, 0xe3, 0x92, 0x9d, 0x90, 0xe3, 0x0d, 0x02, 0x4e, 0x15, 0x30, 0xed, 0x8f, 0x5b, 0x99, 0xaa,
0x7a, 0x4e, 0xb9, 0xec, 0x91, 0xe2, 0x9f, 0xd4, 0xca, 0x46, 0xaf, 0x87, 0x19, 0x4b, 0xad, 0xea, 0xe6, 0x6c, 0x8d,
0x53, 0x4b, 0x29, 0xee, 0x1e, 0x96, 0xd8, 0x94, 0x30, 0x3a, 0x9c, 0xb0, 0x4c, 0xdb, 0xe2, 0xa1, 0x7f, 0xc7, 0x11,
0xdd, 0xe3, 0x9d, 0x36, 0x44, 0xd3, 0x93, 0x14, 0x9c, 0x4c, 0x9d, 0xdd, 0x3a, 0x7c, 0x41, 0xb1, 0x8f, 0x14, 0xd9,
0x4e, 0x61, 0xd9, 0x3a, 0xe3, 0x0d, 0x76, 0xca, 0x6c, 0xac, 0x73, 0xaf, 0xad, 0x94, 0x87, 0x30, 0xf1, 0x30, 0x2f,
0x8b, 0xed, 0x4a, 0xed, 0xbc, 0xc2, 0xf2, 0x7c, 0x30, 0xa3, 0x0b, 0x28, 0x64, 0x3b, 0x8c, 0xd4, 0xc3, 0x42, 0xb9,
0xa3, 0x44, 0x51, 0x80, 0x07, 0x5a, 0x3d, 0x14, 0x33, 0x99, 0xbf, 0x2a, 0x6b, 0x2b, 0x19, 0x47, 0x72, 0x9e, 0xd4,
0xb4, 0x6d, 0x72, 0xdd, 0x8a, 0x4b, 0x33, 0x55, 0xbc, 0xb4, 0xcd, 0xc8, 0x2b, 0x17, 0x2f, 0x74, 0xeb, 0x22, 0x17,
0x94, 0x08, 0x27, 0x27, 0xc2, 0x5b, 0x17, 0xb4, 0xa9, 0x22, 0x16, 0x9d, 0xd4, 0xfc, 0xc7, 0x15, 0xa3, 0x9b, 0x86,
0x1f, 0xad, 0x45, 0xd3, 0x87, 0x94, 0x5b, 0x31, 0x36, 0xaa, 0xe4, 0x66, 0x8d, 0xcc, 0x31, 0x05, 0x5b, 0xc4, 0x40,
0xc0, 0xb8, 0xeb, 0x91, 0x18, 0x22, 0x8c, 0x31, 0x1e, 0xad, 0xd0, 0x3a, 0x98, 0x07, 0xb5, 0x6f, 0x11, 0xba, 0x11,
0xa6, 0x14, 0x35, 0x5a, 0xe7, 0x55, 0xdf, 0xb7, 0x4c, 0x03, 0x61, 0xa3, 0x74, 0x23, 0xdf, 0x55, 0x1f, 0x02, 0x51,
0x09, 0xb7, 0xba, 0xd5, 0x0c, 0x67, 0xab, 0x98, 0x70, 0x14, 0x64, 0x8d, 0xf4, 0x8b, 0x54, 0x44, 0x07, 0x6f, 0xe0,
0x69, 0x32, 0xca, 0x48, 0xe5, 0xd3, 0xa7, 0x17, 0x8f, 0x45, 0x84, 0x04, 0xb7, 0xd1, 0xbb, 0xe1, 0xf6, 0x01, 0x0a,
0xf6, 0xee, 0x2b, 0x32, 0xd2, 0xc5, 0xf8, 0xa6, 0xec, 0x0f, 0x1b, 0x09, 0x1d, 0xfc, 0xa2, 0xbf, 0x54, 0x5d, 0x2c,
0x62, 0xf4, 0x77, 0xde, 0xad, 0xa0, 0x50, 0x6a, 0xb4, 0xe3, 0x5f, 0xda, 0xff, 0x6b, 0xb1, 0x78, 0xf7, 0xf9, 0xc1,
0xa6, 0xa8, 0x93, 0xe8, 0xe4, 0x11, 0x56, 0xa0, 0x5b, 0x85, 0xa7, 0x92, 0x7a, 0x58, 0x45, 0x95, 0xa9, 0x63, 0x83,
0xb4, 0x1f, 0x18, 0x31, 0x7a, 0x6d, 0xa1, 0x8d, 0x8c, 0xdc, 0x91, 0x02, 0x3c, 0x9c, 0x92, 0x42, 0x8e, 0x03, 0x02,
0xc5, 0x0c, 0x43, 0x54, 0xf9, 0xb2, 0x85, 0x39, 0x2e, 0x77, 0xad, 0x00};
// Backwards compatibility alias
#define INDEX_GZ INDEX_BR
@@ -6,6 +6,9 @@
#include "esphome/core/string_ref.h"
#include "esphome/components/wifi/scan_list.h"
#include "esphome/components/wifi/wifi_component.h"
#ifdef USE_PROVISIONING
#include "esphome/components/provisioning/provisioning.h"
#endif
#include "captive_index.h"
namespace esphome::captive_portal {
@@ -78,6 +81,20 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) {
void CaptivePortal::setup() {
// Disable loop by default - will be enabled when captive portal starts
this->disable_loop();
#ifdef USE_PROVISIONING
// The captive portal is a provisioning surface: once the provisioning window
// has closed, stop serving it. WiFi's own closed-callback shuts down the
// access point the portal runs on, and the gated fallback in WiFiComponent's
// loop() ensures neither is started again afterwards.
if (provisioning::global_provisioning_manager != nullptr) {
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
if (this->active_) {
ESP_LOGD(TAG, "Provisioning window closed; stopping captive portal");
this->end();
}
});
}
#endif
}
void CaptivePortal::start() {
this->base_->init();
+8 -1
View File
@@ -551,7 +551,14 @@ ClimateCall ClimateDeviceRestoreState::to_call(Climate *climate) {
void ClimateDeviceRestoreState::apply(Climate *climate) {
auto traits = climate->get_traits();
climate->mode = this->mode;
// A saved mode the device no longer offers cannot be selected again, so skip it and leave the
// entity on the mode it already has. The other saved fields are still restored.
if (traits.supports_mode(this->mode)) {
climate->mode = this->mode;
} else {
ESP_LOGW(TAG, "'%s' - Saved mode %s is no longer supported, keeping %s", climate->get_name().c_str(),
LOG_STR_ARG(climate_mode_to_string(this->mode)), LOG_STR_ARG(climate_mode_to_string(climate->mode)));
}
if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE |
CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {
climate->target_temperature_low = this->target_temperature_low;
@@ -13,9 +13,11 @@ CONF_HEADER_LOW = "header_low"
CONF_BIT_HIGH = "bit_high"
CONF_BIT_ONE_LOW = "bit_one_low"
CONF_BIT_ZERO_LOW = "bit_zero_low"
CONF_ADVANCED_COMMANDS_SUPPORT = "advanced_commands_support"
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(LgIrClimate).extend(
{
cv.Optional(CONF_ADVANCED_COMMANDS_SUPPORT, default=False): cv.boolean,
cv.Optional(
CONF_HEADER_HIGH, default="8000us"
): cv.positive_time_period_microseconds,
@@ -38,6 +40,7 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(LgIrClimate).extend(
async def to_code(config: ConfigType) -> None:
var = await climate_ir.new_climate_ir(config)
cg.add(var.set_advanced_commands_support(config[CONF_ADVANCED_COMMANDS_SUPPORT]))
cg.add(var.set_header_high(config[CONF_HEADER_HIGH]))
cg.add(var.set_header_low(config[CONF_HEADER_LOW]))
cg.add(var.set_bit_high(config[CONF_BIT_HIGH]))
@@ -5,11 +5,85 @@ namespace esphome::climate_ir_lg {
static const char *const TAG = "climate.climate_ir_lg";
// Commands
const uint32_t COMMAND_MASK = 0xFF000;
const uint32_t COMMAND_OFF = 0xC0000;
const uint32_t COMMAND_SWING = 0x10000;
// All codes provided here are missing the checksum (last 4 bits)
// this checksum needs to be calculated before sending (look at `calc_checksum_()`)
const uint32_t LG_HEADER = 0x8800000;
// Commands
const uint32_t COMMAND_HEADER_MASK = 0xFF000;
const uint32_t COMMAND_DATA_MASK = 0x00FF0;
const uint32_t CHECKSUM_MASK = 0xF;
enum CommandBasic : uint32_t {
HEADER_BASIC = 0x10000,
BASIC_SWING_TOGGLE = 0x000,
// JET MODE (only for cooling/drying/heating modes)
// For 30 minutes: max airflow (stronger than F5 aka FAN_MAX) + PO (min/min/max temperature respectively)
// After 30 minutes: F5 aka FAN_MAX + min/min/max temperature respectively
BASIC_JET = 0x080,
};
enum CommandSys : uint32_t {
HEADER_SYS = 0xC0000,
COMMAND_OFF = 0x050,
// Also known as 'auto-dry'
AUTO_CLEAN_ON = 0x0B0,
AUTO_CLEAN_OFF = 0x0C0,
PURIFY_ON = 0x000, // From either OFF or Mode -> Purify
PURIFY_OFF = 0x080, // From Mode + Purify -> Mode
QUIET_OUTDOOR_ON = 0xA60,
QUIET_OUTDOOR_OFF = 0xA70,
// ENERGY CTRL (only in Cooling mode)
COOL_ENERG_CTRL_80 = 0x7D0, // 80%
COOL_ENERG_CTRL_60 = 0x7E0, // 60%
COOL_ENERG_CTRL_40 = 0x800, // 40%
COOL_ENERG_CTRL_OFF = 0x7F0, // OFF
DISPLAY_KW = 0x460,
LIGHT_ON_OFF = 0x0A0,
TEMP_UNIT_F = 0x170,
TEMP_UNIT_C = 0x160,
};
enum CommandAdvSwing : uint32_t {
HEADER_ADV_SWING = 0x13000,
// Only 5 bits are relevant, I got 0x13952 once - not sure what is the 8th bit so ignoring that.
ADV_SWING_DATA_MASK = 0x1F0,
// Commands for Advanced Vertical Control: Swing + 6 fixed positions
VERT_FIX_1 = 0x040, // Down
VERT_FIX_2 = 0x050,
VERT_FIX_3 = 0x060,
VERT_FIX_4 = 0x070,
VERT_FIX_5 = 0x080,
VERT_FIX_6 = 0x090, // Up
VERT_SWING_ON = 0x140, // Swing between 1 and 6
VERT_SWING_OFF = 0x150, // Stops immediately
// Commands for Advanced Horizontal Control: Swing (3 modes) + 5 fixed positions
HORI_FIX_1 = 0x0B0, // Left
HORI_FIX_2 = 0x0C0,
HORI_FIX_3 = 0x0D0,
HORI_FIX_4 = 0x0E0,
HORI_FIX_5 = 0x0F0, // Right
HORI_SWING_ON_LEFT = 0x100, // Swing between 1 and 3
HORI_SWING_ON_RIGHT = 0x110, // Swing between 3 and 5
HORI_SWING_ON_FULL = 0x160, // Swing between 1 and 5
HORI_SWING_OFF = 0x170, // Stops immediately
};
// Following commands contain mode, fan speed and temperature
// Modes
const uint32_t COMMAND_ON_COOL = 0x00000;
const uint32_t COMMAND_ON_DRY = 0x01000;
const uint32_t COMMAND_ON_FAN_ONLY = 0x02000;
@@ -23,11 +97,13 @@ const uint32_t COMMAND_AI = 0x0B000;
const uint32_t COMMAND_HEAT = 0x0C000;
// Fan speed
const uint32_t FAN_MASK = 0xF0;
const uint32_t FAN_SPEED_MASK = 0xF0;
const uint32_t FAN_AUTO = 0x50;
const uint32_t FAN_MIN = 0x00;
const uint32_t FAN_MED = 0x20;
const uint32_t FAN_MAX = 0x40;
const uint32_t FAN_MIN = 0x00; // AKA F1
const uint32_t FAN_F2 = 0x90;
const uint32_t FAN_MED = 0x20; // AKA F3
const uint32_t FAN_F4 = 0xA0;
const uint32_t FAN_MAX = 0x40; // AKA F5
// Temperature
const uint8_t TEMP_RANGE = TEMP_MAX - TEMP_MIN + 1;
@@ -37,16 +113,37 @@ const uint32_t TEMP_SHIFT = 8;
const uint16_t BITS = 28;
void LgIrClimate::transmit_state() {
uint32_t remote_state = 0x8800000;
uint32_t remote_state = LG_HEADER;
// ESP_LOGD(TAG, "climate_lg_ir mode_before_ code: 0x%02X", modeBefore_);
// ESP_LOGD(TAG, "climate_lg_ir mode_before_ code: 0x%02X", this->modeBefore_);
// Set command
if (this->send_swing_cmd_) {
this->send_swing_cmd_ = false;
remote_state |= COMMAND_SWING;
} else {
bool climate_is_off = (this->mode_before_ == climate::CLIMATE_MODE_OFF);
if (this->advanced_commands_support_) {
switch (this->swing_mode) {
case climate::CLIMATE_SWING_VERTICAL:
ESP_LOGD(TAG, "setting swing vertical");
remote_state |= CommandAdvSwing::HEADER_ADV_SWING;
remote_state |= CommandAdvSwing::VERT_SWING_ON;
break;
case climate::CLIMATE_SWING_OFF:
ESP_LOGD(TAG, "setting swing off");
remote_state |= CommandAdvSwing::HEADER_ADV_SWING;
remote_state |= CommandAdvSwing::VERT_SWING_OFF;
break;
default:
return;
}
this->transmit_(remote_state);
this->publish_state();
return;
} else { // just toggle swing when advanced_commands_support is not set
remote_state |= HEADER_BASIC;
remote_state |= BASIC_SWING_TOGGLE;
}
} else { // Mode commands
const bool climate_is_off = (this->mode_before_ == climate::CLIMATE_MODE_OFF);
switch (this->mode) {
case climate::CLIMATE_MODE_COOL:
remote_state |= climate_is_off ? COMMAND_ON_COOL : COMMAND_COOL;
@@ -65,8 +162,8 @@ void LgIrClimate::transmit_state() {
break;
case climate::CLIMATE_MODE_OFF:
default:
remote_state |= COMMAND_OFF;
break;
remote_state |= CommandSys::HEADER_SYS;
remote_state |= CommandSys::COMMAND_OFF;
}
}
@@ -75,9 +172,8 @@ void LgIrClimate::transmit_state() {
ESP_LOGD(TAG, "climate_lg_ir mode code: 0x%02X", this->mode);
// Set fan speed
if (this->mode == climate::CLIMATE_MODE_OFF) {
remote_state |= FAN_AUTO;
} else {
if (this->mode !=
climate::CLIMATE_MODE_OFF) { // https://github.com/esphome/esphome/pull/10875#issuecomment-5042765948
switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) {
case climate::CLIMATE_FAN_HIGH:
remote_state |= FAN_MAX;
@@ -95,10 +191,20 @@ void LgIrClimate::transmit_state() {
}
}
// Set temperature
if (this->mode == climate::CLIMATE_MODE_COOL || this->mode == climate::CLIMATE_MODE_HEAT) {
auto temp = (uint8_t) roundf(clamp<float>(this->target_temperature, TEMP_MIN, TEMP_MAX));
remote_state |= ((temp - 15) << TEMP_SHIFT);
uint8_t temp;
switch (this->mode) {
case climate::CLIMATE_MODE_HEAT_COOL:
if (!this->advanced_commands_support_) { // Keep previous behavior
break;
}
[[fallthrough]];
case climate::CLIMATE_MODE_COOL:
case climate::CLIMATE_MODE_HEAT:
temp = static_cast<uint8_t>(roundf(clamp<float>(this->target_temperature, TEMP_MIN, TEMP_MAX)));
remote_state |= (temp - 15) << TEMP_SHIFT;
break;
default:
break;
}
this->transmit_(remote_state);
@@ -124,62 +230,134 @@ bool LgIrClimate::on_receive(remote_base::RemoteReceiveData data) {
}
}
ESP_LOGD(TAG, "Decoded 0x%02" PRIX32, remote_state);
if ((remote_state & 0xFF00000) != 0x8800000)
ESP_LOGD(TAG, "Received 0x%02" PRIX32, remote_state);
if ((remote_state & 0xFF00000) != LG_HEADER)
return false;
// Get command
if ((remote_state & COMMAND_MASK) == COMMAND_OFF) {
this->mode = climate::CLIMATE_MODE_OFF;
} else if ((remote_state & COMMAND_MASK) == COMMAND_SWING) {
this->swing_mode =
this->swing_mode == climate::CLIMATE_SWING_OFF ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF;
} else {
switch (remote_state & COMMAND_MASK) {
case COMMAND_DRY:
case COMMAND_ON_DRY:
this->mode = climate::CLIMATE_MODE_DRY;
break;
case COMMAND_FAN_ONLY:
case COMMAND_ON_FAN_ONLY:
this->mode = climate::CLIMATE_MODE_FAN_ONLY;
break;
case COMMAND_AI:
case COMMAND_ON_AI:
this->mode = climate::CLIMATE_MODE_HEAT_COOL;
break;
case COMMAND_HEAT:
case COMMAND_ON_HEAT:
this->mode = climate::CLIMATE_MODE_HEAT;
break;
case COMMAND_COOL:
case COMMAND_ON_COOL:
default:
this->mode = climate::CLIMATE_MODE_COOL;
break;
}
// Get fan speed
if (this->mode == climate::CLIMATE_MODE_HEAT_COOL) {
this->fan_mode = climate::CLIMATE_FAN_AUTO;
} else if (this->mode == climate::CLIMATE_MODE_COOL || this->mode == climate::CLIMATE_MODE_DRY ||
this->mode == climate::CLIMATE_MODE_FAN_ONLY || this->mode == climate::CLIMATE_MODE_HEAT) {
if ((remote_state & FAN_MASK) == FAN_AUTO) {
this->fan_mode = climate::CLIMATE_FAN_AUTO;
} else if ((remote_state & FAN_MASK) == FAN_MIN) {
this->fan_mode = climate::CLIMATE_FAN_LOW;
} else if ((remote_state & FAN_MASK) == FAN_MED) {
this->fan_mode = climate::CLIMATE_FAN_MEDIUM;
} else if ((remote_state & FAN_MASK) == FAN_MAX) {
this->fan_mode = climate::CLIMATE_FAN_HIGH;
// Decode commands
switch (remote_state & COMMAND_HEADER_MASK) {
case CommandSys::HEADER_SYS:
ESP_LOGD(TAG, "Got system command! With data: 0x%02" PRIX32, remote_state & COMMAND_DATA_MASK);
if ((remote_state & COMMAND_DATA_MASK) == CommandSys::COMMAND_OFF) {
this->mode = climate::CLIMATE_MODE_OFF;
} else {
return false;
}
break;
case CommandAdvSwing::HEADER_ADV_SWING:
ESP_LOGD(TAG, "Got advanced swing command! With data: 0x%02" PRIX32,
remote_state & CommandAdvSwing::ADV_SWING_DATA_MASK);
switch (remote_state & CommandAdvSwing::ADV_SWING_DATA_MASK) {
case CommandAdvSwing::VERT_SWING_ON:
this->swing_mode = climate::CLIMATE_SWING_VERTICAL;
break;
case CommandAdvSwing::VERT_SWING_OFF:
case CommandAdvSwing::VERT_FIX_1:
case CommandAdvSwing::VERT_FIX_2:
case CommandAdvSwing::VERT_FIX_3:
case CommandAdvSwing::VERT_FIX_4:
case CommandAdvSwing::VERT_FIX_5:
case CommandAdvSwing::VERT_FIX_6:
this->swing_mode = climate::CLIMATE_SWING_OFF;
break;
default:
return false; // Ignore all other (horizontal) swing commands
}
}
// Get temperature
if (this->mode == climate::CLIMATE_MODE_COOL || this->mode == climate::CLIMATE_MODE_HEAT) {
this->target_temperature = ((remote_state & TEMP_MASK) >> TEMP_SHIFT) + 15;
}
this->publish_state();
return true;
case HEADER_BASIC:
if ((remote_state & COMMAND_DATA_MASK) == BASIC_JET) {
switch (this->mode) {
case climate::CLIMATE_MODE_COOL:
case climate::CLIMATE_MODE_HEAT:
case climate::CLIMATE_MODE_DRY:
this->target_temperature =
this->mode == climate::CLIMATE_MODE_HEAT ? this->maximum_temperature_ : this->minimum_temperature_;
this->fan_mode = climate::CLIMATE_FAN_HIGH;
// When enabling PO(WER) also known as JET mode, swing is set to VERT_3, but after 30 mins it will switch
// back to what it was before, so let's just not change it here it at all
this->publish_state();
return true;
default:
ESP_LOGD(TAG, "Got jet command, but current mode does not support it! Ignoring.");
return false;
}
}
// Keep previous behavior in case of other BASIC command
if (this->swing_mode == climate::CLIMATE_SWING_OFF) { // Just flip between vertical and off
this->swing_mode = climate::CLIMATE_SWING_VERTICAL;
} else {
this->swing_mode = climate::CLIMATE_SWING_OFF;
}
this->publish_state();
return true;
// Following commands also contain fan speed and temperature, so no 'return' in these cases
case COMMAND_DRY:
case COMMAND_ON_DRY:
this->mode = climate::CLIMATE_MODE_DRY;
break;
case COMMAND_FAN_ONLY:
case COMMAND_ON_FAN_ONLY:
this->mode = climate::CLIMATE_MODE_FAN_ONLY;
break;
case COMMAND_AI:
case COMMAND_ON_AI:
this->mode = climate::CLIMATE_MODE_HEAT_COOL;
break;
case COMMAND_HEAT:
case COMMAND_ON_HEAT:
this->mode = climate::CLIMATE_MODE_HEAT;
break;
case COMMAND_COOL:
case COMMAND_ON_COOL:
this->mode = climate::CLIMATE_MODE_COOL;
break;
default:
ESP_LOGD(TAG, "Got unknown command! Ignoring!");
return false;
}
// Decode fan speed
switch (remote_state & FAN_SPEED_MASK) {
case FAN_AUTO:
this->fan_mode = climate::CLIMATE_FAN_AUTO;
break;
case FAN_MIN:
case FAN_F2:
this->fan_mode = climate::CLIMATE_FAN_LOW;
break;
case FAN_MED:
case FAN_F4:
this->fan_mode = climate::CLIMATE_FAN_MEDIUM;
break;
case FAN_MAX:
this->fan_mode = climate::CLIMATE_FAN_HIGH;
break;
default:
ESP_LOGD(TAG, "Got unknown fan speed! Ignoring!");
return false;
}
// Keep previous behavior
if (this->mode == climate::CLIMATE_MODE_HEAT_COOL && !(this->advanced_commands_support_)) {
this->fan_mode = climate::CLIMATE_FAN_AUTO;
}
// Decode temperature for modes that support it
switch (this->mode) {
case climate::CLIMATE_MODE_HEAT_COOL:
case climate::CLIMATE_MODE_COOL:
case climate::CLIMATE_MODE_HEAT:
this->target_temperature = ((remote_state & TEMP_MASK) >> TEMP_SHIFT) + 15;
break;
default:
break;
}
this->mode_before_ = this->mode;
this->publish_state();
return true;
@@ -207,14 +385,14 @@ void LgIrClimate::transmit_(uint32_t value) {
data->mark(this->bit_high_);
transmit.perform();
}
void LgIrClimate::calc_checksum_(uint32_t &value) {
uint32_t mask = 0xF;
uint32_t sum = 0;
for (uint8_t i = 1; i < 8; i++) {
sum += (value & (mask << (i * 4))) >> (i * 4);
sum += (value & (CHECKSUM_MASK << (i * 4))) >> (i * 4);
}
value |= (sum & mask);
value |= (sum & CHECKSUM_MASK);
}
} // namespace esphome::climate_ir_lg
@@ -21,12 +21,13 @@ class LgIrClimate final : public climate_ir::ClimateIR {
/// Override control to change settings of the climate device.
void control(const climate::ClimateCall &call) override {
this->send_swing_cmd_ = call.get_swing_mode().has_value();
// swing resets after unit powered off
// swing resets after unit powered off, except when advanced_commands_support_ is set
auto mode = call.get_mode();
if (mode.has_value() && *mode == climate::CLIMATE_MODE_OFF)
if (mode.has_value() && *mode == climate::CLIMATE_MODE_OFF && !(this->advanced_commands_support_))
this->swing_mode = climate::CLIMATE_SWING_OFF;
climate_ir::ClimateIR::control(call);
}
void set_advanced_commands_support(bool value) { this->advanced_commands_support_ = value; }
void set_header_high(uint32_t header_high) { this->header_high_ = header_high; }
void set_header_low(uint32_t header_low) { this->header_low_ = header_low; }
void set_bit_high(uint32_t bit_high) { this->bit_high_ = bit_high; }
@@ -44,6 +45,7 @@ class LgIrClimate final : public climate_ir::ClimateIR {
void calc_checksum_(uint32_t &value);
void transmit_(uint32_t value);
bool advanced_commands_support_{false};
uint32_t header_high_;
uint32_t header_low_;
uint32_t bit_high_;
+5 -3
View File
@@ -116,14 +116,16 @@ _CALLBACK_AUTOMATIONS = (
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
# Initialize sensor storage with count from final_validate
# Initialize sensor storage with count from final_validate before any
# await, so platform to_code() calls always see it initialized
# regardless of YAML key order.
sensor_count = _get_data().sensor_counts.get(str(config[CONF_ID]), 0)
if sensor_count > 0:
cg.add(var.init_sensors(sensor_count))
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
+5 -13
View File
@@ -1105,19 +1105,11 @@ def _check_esp_idf_versions(config: ConfigType) -> ConfigType:
return config
def _validate_toolchain(value) -> Toolchain:
return Toolchain(
cv.one_of(Toolchain.PLATFORMIO, Toolchain.ESP_IDF, lower=True)(value)
)
def _resolve_toolchain(value: ConfigType) -> ConfigType:
# Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default.
# Runs before _detect_variant so downstream validators can rely on
# CORE.toolchain instead of re-resolving it from the config dict.
if CORE.toolchain is None:
CORE.toolchain = value.get(CONF_TOOLCHAIN, Toolchain.ESP_IDF)
return value
_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.ESP_IDF)
_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS)
# Runs before _detect_variant so downstream validators can rely on
# CORE.toolchain instead of re-resolving it from the config dict.
_resolve_toolchain = cv.resolve_toolchain("ESP32", _TOOLCHAINS, Toolchain.ESP_IDF)
def _check_versions(config: ConfigType) -> ConfigType:
+8
View File
@@ -2,6 +2,7 @@
#include "esphome/core/application.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "preferences.h"
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
@@ -29,6 +30,13 @@ void loop_task(void *pv_params) {
}
extern "C" void app_main() {
// Apply the custom eFuse MAC (if burned and valid) as the base MAC before any
// interface (Wi-Fi, Ethernet, Bluetooth, 802.15.4) derives its address from it.
// The logger does not exist yet, so only log-free helpers may be used here.
uint8_t mac[MAC_ADDRESS_SIZE];
if (get_custom_mac_address(mac)) {
set_mac_address(mac);
}
initArduino();
esp32::setup_preferences();
#if CONFIG_FREERTOS_UNICORE
+17 -8
View File
@@ -71,23 +71,32 @@ static bool read_valid_mac(uint8_t *mac, esp_err_t err) { return err == ESP_OK &
static constexpr size_t MAC_ADDRESS_SIZE_BITS = MAC_ADDRESS_SIZE * 8; // 48 bits
// Must not use the ESPHome logger (may run before it exists, e.g. from app_main()).
bool get_custom_mac_address(uint8_t *mac) {
// has_custom_mac_address() checks the raw eFuse field, while the reads below select their
// method differently and may still fail (CRC), so the result must be validated again.
if (!has_custom_mac_address())
return false;
#if defined(CONFIG_SOC_IEEE802154_SUPPORTED)
return read_valid_mac(mac, esp_efuse_read_field_blob(ESP_EFUSE_MAC_CUSTOM, mac, MAC_ADDRESS_SIZE_BITS));
#else
return read_valid_mac(mac, esp_efuse_mac_get_custom(mac));
#endif
}
void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter)
if (get_custom_mac_address(mac)) {
return;
}
#if defined(CONFIG_SOC_IEEE802154_SUPPORTED)
// When CONFIG_SOC_IEEE802154_SUPPORTED is defined, esp_efuse_mac_get_default
// returns the 802.15.4 EUI-64 address, so we read directly from eFuse instead.
// Both paths already read raw eFuse bytes, so there is no CRC-bypass fallback
// This already reads raw eFuse bytes, so there is no CRC-bypass fallback
// (unlike the non-IEEE802154 path where esp_efuse_mac_get_default does CRC checks).
if (has_custom_mac_address() &&
read_valid_mac(mac, esp_efuse_read_field_blob(ESP_EFUSE_MAC_CUSTOM, mac, MAC_ADDRESS_SIZE_BITS))) {
return;
}
if (read_valid_mac(mac, esp_efuse_read_field_blob(ESP_EFUSE_MAC_FACTORY, mac, MAC_ADDRESS_SIZE_BITS))) {
return;
}
#else
if (has_custom_mac_address() && read_valid_mac(mac, esp_efuse_mac_get_custom(mac))) {
return;
}
if (read_valid_mac(mac, esp_efuse_mac_get_default(mac))) {
return;
}
@@ -1,5 +1,7 @@
#include "esp32_improv_component.h"
#include <array>
#include "esphome/components/bytebuffer/bytebuffer.h"
#include "esphome/components/esp32_ble/ble.h"
#include "esphome/components/esp32_ble_server/ble_2902.h"
@@ -19,7 +21,13 @@ using namespace bytebuffer;
static const char *const TAG = "esp32_improv.component";
static constexpr size_t IMPROV_MAX_LOG_BYTES = 128;
static const char *const ESPHOME_MY_LINK = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome";
static constexpr char ESPHOME_MY_LINK[] = "https://my.home-assistant.io/redirect/config_flow_start?domain=esphome";
// command + data length + trailing byte
static constexpr size_t RPC_RESPONSE_OVERHEAD = 3;
// Reserves the ESPHOME_MY_LINK entry; a maximal next URL displaces only the
// lower value web server URL
static constexpr size_t MAX_NEXT_URL_LEN =
improv::RPC_RESPONSE_MAX_SIZE - RPC_RESPONSE_OVERHEAD - 1 - sizeof(ESPHOME_MY_LINK);
static constexpr uint16_t STOP_ADVERTISING_DELAY =
10000; // Delay (ms) before stopping service to allow BLE clients to read the final state
static constexpr uint16_t NAME_ADVERTISING_INTERVAL = 60000; // Advertise name every 60 seconds
@@ -285,8 +293,9 @@ void ESP32ImprovComponent::set_error_(improv::Error error) {
}
}
void ESP32ImprovComponent::send_response_(std::vector<uint8_t> &&response) {
this->rpc_response_->set_value(std::move(response));
void ESP32ImprovComponent::send_response_(std::span<const uint8_t> response) {
// The BLE characteristic owns its value, so one exact-size copy is required here
this->rpc_response_->set_value(std::vector<uint8_t>(response.begin(), response.end()));
if (this->state_ != improv::STATE_STOPPED)
this->rpc_response_->notify();
}
@@ -430,40 +439,35 @@ void ESP32ImprovComponent::check_wifi_connection_() {
this->connecting_sta_ = {};
this->cancel_timeout("wifi-connect-timeout");
// Build URL list with minimal allocations
// Maximum 3 URLs: custom next_url + ESPHOME_MY_LINK + webserver URL
std::string url_strings[3];
size_t url_count = 0;
// Build the URL list directly into a stack buffer with no heap allocation
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
improv::RpcResponseBuilder builder(buf, improv::WIFI_SETTINGS);
#ifdef USE_ESP32_IMPROV_NEXT_URL
// Add next_url if configured (should be first per Improv BLE spec)
{
char url_buffer[384];
size_t len = this->get_formatted_next_url_(url_buffer, sizeof(url_buffer));
if (len > 0) {
url_strings[url_count++] = std::string(url_buffer, len);
}
}
this->add_next_url_(builder, MAX_NEXT_URL_LEN);
#endif
// Add default URLs for backward compatibility
url_strings[url_count++] = ESPHOME_MY_LINK;
// Add default URLs for backward compatibility; MAX_NEXT_URL_LEN reserves this
// entry's space, so it always fits
builder.add_string(ESPHOME_MY_LINK, sizeof(ESPHOME_MY_LINK) - 1);
#ifdef USE_WEBSERVER
for (auto &ip : wifi::global_wifi_component->wifi_sta_ip_addresses()) {
if (ip.is_ip4()) {
// "http://" (7) + IPv4 max (15) + ":" (1) + port max (5) + null = 29
char url_buffer[32];
memcpy(url_buffer, "http://", 7); // NOLINT(bugprone-not-null-terminated-result) - str_to null-terminates
ip.str_to(url_buffer + 7);
size_t len = strlen(url_buffer);
snprintf(url_buffer + len, sizeof(url_buffer) - len, ":%d", USE_WEBSERVER_PORT);
url_strings[url_count++] = url_buffer;
char ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
ip.str_to(ip_buf);
// "http://" (7) + IP (40) + ":" (1) + port (5) + null (1) = 54
char webserver_url[7 + network::IP_ADDRESS_BUFFER_SIZE + 1 + 5 + 1];
size_t len =
buf_append_printf(webserver_url, sizeof(webserver_url), 0, "http://%s:%u", ip_buf, USE_WEBSERVER_PORT);
if (!builder.add_string(webserver_url, len)) {
ESP_LOGW(TAG, "Response full; URL dropped");
}
break;
}
}
#endif
this->send_response_(improv::build_rpc_response(improv::WIFI_SETTINGS,
std::vector<std::string>(url_strings, url_strings + url_count)));
this->send_response_(builder.finish());
} else if (this->is_active() && this->state_ != improv::STATE_PROVISIONED) {
ESP_LOGD(TAG, "WiFi provisioned externally");
}
@@ -22,6 +22,7 @@
#include "esphome/components/output/binary_output.h"
#endif
#include <span>
#include <vector>
#ifdef USE_ESP32
@@ -109,7 +110,7 @@ class ESP32ImprovComponent final : public Component, public improv_base::ImprovB
void set_state_(improv::State state, bool update_advertising = true);
void set_error_(improv::Error error);
improv::State get_initial_state_() const;
void send_response_(std::vector<uint8_t> &&response);
void send_response_(std::span<const uint8_t> response);
void process_incoming_data_();
void on_wifi_connect_timeout_();
void check_wifi_connection_();
+41 -13
View File
@@ -35,7 +35,7 @@ from esphome.platformio.toolchain import copy_ccache_script
from esphome.storage_json import StorageJSON
from esphome.types import ConfigType
from .boards import BOARDS, ESP8266_LD_SCRIPTS
from .boards import BOARDS, ESP8266_LD_SCRIPTS, board_ld_script
from .const import (
CONF_EARLY_PIN_INIT,
CONF_ENABLE_SERIAL,
@@ -44,6 +44,7 @@ from .const import (
KEY_BOARD,
KEY_ESP8266,
KEY_FLASH_SIZE,
KEY_LDSCRIPT,
KEY_PIN_INITIAL_STATES,
KEY_SERIAL1_REQUIRED,
KEY_SERIAL_REQUIRED,
@@ -136,7 +137,16 @@ def _format_framework_arduino_version(ver: cv.Version) -> str:
return f"~1.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
if ver <= cv.Version(2, 6, 2):
return f"~2.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
return f"~3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
# Same encoding the native toolchain uses for its package download, so a
# version bump cannot drift between the two paths.
from esphome.arduino8266.framework import framework_package_version
try:
return f"~{framework_package_version(ver)}"
except EsphomeError as err:
# Anchor the 4.x rejection to the framework version line instead of
# aborting with a bare traceback-level error
raise cv.Invalid(str(err), path=[CONF_VERSION]) from err
# NOTE: Keep this in mind when updating the recommended version:
@@ -246,6 +256,9 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean,
}
),
# Until the native toolchain lands, PlatformIO is the only backend;
# reject a --toolchain this platform cannot serve yet.
cv.require_platformio_toolchain("ESP8266"),
set_core_data,
)
@@ -276,6 +289,31 @@ def check_rosetta() -> None:
)
def _choose_ld_script(board: str, ver: cv.Version) -> str | None:
"""The flash ld to pin for this board and core, or None for cores
without ld-script support."""
board_data = BOARDS[board]
ld_scripts = ESP8266_LD_SCRIPTS[board_data[KEY_FLASH_SIZE]]
if ver <= cv.Version(2, 3, 0):
# No ld script support
return None
if ver <= cv.Version(2, 4, 2):
# Old ld script path; the modern per-board override names do not
# exist in this core's SDK, so the override cannot be honored.
# Substituting the size default would move _FS_end and the
# preferences sector, wiping flash-backed state on flash.
if KEY_LDSCRIPT in board_data:
raise EsphomeError(
f"Board {board} requires its {board_data[KEY_LDSCRIPT]} "
f"flash layout, which Arduino core {ver} cannot honor; "
"use a core newer than 2.4.2"
)
return ld_scripts[0]
# A per-board override preserves a layout the board shipped with
# (see d1_wroom_02 in boards.py)
return board_ld_script(board_data)
@coroutine_with_priority(CoroPriority.PLATFORM)
async def to_code(config: ConfigType) -> None:
cg.add(esp8266_ns.setup_preferences())
@@ -397,17 +435,7 @@ async def to_code(config: ConfigType) -> None:
)
if config[CONF_BOARD] in BOARDS:
flash_size = BOARDS[config[CONF_BOARD]][KEY_FLASH_SIZE]
ld_scripts = ESP8266_LD_SCRIPTS[flash_size]
if ver <= cv.Version(2, 3, 0):
# No ld script support
ld_script = None
elif ver <= cv.Version(2, 4, 2):
# Old ld script path
ld_script = ld_scripts[0]
else:
ld_script = ld_scripts[1]
ld_script = _choose_ld_script(config[CONF_BOARD], ver)
if ld_script is not None:
cg.add_platformio_option("board_build.ldscript", ld_script)
+135 -1
View File
@@ -1,3 +1,5 @@
from .const import KEY_FLASH_SIZE, KEY_LDSCRIPT
FLASH_SIZE_1_MB = 2**20
FLASH_SIZE_512_KB = FLASH_SIZE_1_MB // 2
FLASH_SIZE_2_MB = 2 * FLASH_SIZE_1_MB
@@ -164,7 +166,8 @@ ESP8266_BOARD_PINS = {
}
"""
BOARDS generate with:
BOARDS generate with (preserve per-board KEY_LDSCRIPT overrides such as
d1_wroom_02; the recipe emits only name/flash_size):
git clone https://github.com/platformio/platform-espressif8266
for x in platform-espressif8266/boards/*.json; do
@@ -182,6 +185,19 @@ for x in platform-espressif8266/boards/*.json; do
done | sort
"""
def board_ld_script(board_data: dict) -> str:
"""The modern (core > 2.4.2) flash linker script for a board: its
shipped-layout override, else the size default (the no-FS layout).
Single source of truth for the PlatformIO pinning in __init__ and the
native generator's fallback, so the per-board rule cannot drift.
"""
return board_data.get(
KEY_LDSCRIPT, ESP8266_LD_SCRIPTS[board_data[KEY_FLASH_SIZE]][1]
)
BOARDS = {
"agruminolemon": {
"name": "Lifely Agrumino Lemon v4",
@@ -199,6 +215,15 @@ BOARDS = {
"name": "WeMos D1 mini Pro",
"flash_size": FLASH_SIZE_16_MB,
},
"d1_wroom_02": {
"name": "WeMos D1 ESP-WROOM-02",
"flash_size": FLASH_SIZE_2_MB,
# This board joined BOARDS after shipping with the manifest default
# (64 KB filesystem region); the flash-size default (2m.ld) would
# move _FS_end and with it the preferences sector, wiping existing
# devices' flash-backed state on update.
KEY_LDSCRIPT: "eagle.flash.2m64.ld",
},
"d1": {
"name": "WEMOS D1 R1",
"flash_size": FLASH_SIZE_4_MB,
@@ -360,3 +385,112 @@ BOARDS = {
"flash_size": FLASH_SIZE_4_MB,
},
}
# Per-board variant dir + identity defines from platform-espressif8266 4.x
# build.extra_flags; the shared -DESP8266/-DARDUINO_ARCH_ESP8266 are added
# by the generator.
#
# Regenerate ESP8266_BOARD_BUILD with (v4.2.1 is the platform version the
# native toolchain mirrors; regenerate against the tag when bumping it):
#
# git clone -b v4.2.1 https://github.com/platformio/platform-espressif8266
# python3 - <<'EOF'
# import json, glob, os
# for f in sorted(glob.glob("platform-espressif8266/boards/*.json")):
# b = json.load(open(f))["build"]
# extra = b["extra_flags"]
# extra = extra.split() if isinstance(extra, str) else extra
# defines = [
# e[2:] for e in extra if e not in ("-DESP8266", "-DARDUINO_ARCH_ESP8266")
# ]
# entries = ", ".join(f'"{d}"' for d in defines) + ("," if len(defines) == 1 else "")
# board = os.path.splitext(os.path.basename(f))[0]
# print(f' "{board}": {{"variant": "{b["variant"]}", "defines": ({entries})}},')
# EOF
ESP8266_BOARD_BUILD = {
"agruminolemon": {
"variant": "agruminolemonv4",
"defines": ("ARDUINO_ESP8266_AGRUMINO_LEMON_V4",),
},
"d1": {"variant": "d1", "defines": ("ARDUINO_ESP8266_WEMOS_D1R1",)},
"d1_mini": {"variant": "d1_mini", "defines": ("ARDUINO_ESP8266_WEMOS_D1MINI",)},
"d1_mini_lite": {
"variant": "d1_mini",
"defines": ("ARDUINO_ESP8266_WEMOS_D1MINILITE",),
},
"d1_mini_pro": {
"variant": "d1_mini",
"defines": ("ARDUINO_ESP8266_WEMOS_D1MINIPRO",),
},
"d1_wroom_02": {
"variant": "d1_mini",
"defines": ("ARDUINO_ESP8266_WEMOS_D1WROOM02",),
},
"eduinowifi": {
"variant": "eduinowifi",
"defines": ("ARDUINO_ESP8266_SCHIRMILABS_EDUINO_WIFI",),
},
"esp01": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP01",)},
"esp01_1m": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP01",)},
"esp07": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP07",)},
"esp07s": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP07",)},
"esp12e": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP12",)},
"esp210": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP210",)},
"esp8285": {"variant": "esp8285", "defines": ("ARDUINO_ESP8266_ESP01",)},
"esp_wroom_02": {
"variant": "nodemcu",
"defines": ("ARDUINO_ESP8266_ESP_WROOM_02",),
},
"espduino": {"variant": "ESPDuino", "defines": ("ARDUINO_ESP8266_ESP13",)},
"espectro": {"variant": "espectro", "defines": ("ARDUINO_ESP8266_ESPECTRO_CORE",)},
"espino": {"variant": "espino", "defines": ("ARDUINO_ESP8266_ESP12",)},
"espinotee": {"variant": "espinotee", "defines": ("ARDUINO_ESP8266_ESP13",)},
"espmxdevkit": {
"variant": "esp8285",
"defines": ("ARDUINO_ESP8266_ESP01", "LED_BUILTIN=16"),
},
"espresso_lite_v1": {
"variant": "espresso_lite_v1",
"defines": ("ARDUINO_ESP8266_ESPRESSO_LITE_V1",),
},
"espresso_lite_v2": {
"variant": "espresso_lite_v2",
"defines": ("ARDUINO_ESP8266_ESPRESSO_LITE_V2",),
},
"gen4iod": {"variant": "generic", "defines": ("ARDUINO_GEN4_IOD",)},
"heltec_wifi_kit_8": {
"variant": "wifi_kit_8",
"defines": ("ARDUINO_wifi_kit_8",),
},
"huzzah": {"variant": "adafruit", "defines": ("ARDUINO_ESP8266_ADAFRUIT_HUZZAH",)},
"inventone": {"variant": "inventone", "defines": ("ARDUINO_ESP8266_INVENT_ONE",)},
"modwifi": {"variant": "generic", "defines": ("ARDUINO_MOD_WIFI_ESP8266",)},
"nodemcu": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_NODEMCU",)},
"nodemcuv2": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_NODEMCU_ESP12E",)},
"oak": {"variant": "oak", "defines": ("ARDUINO_ESP8266_OAK",)},
"phoenix_v1": {
"variant": "phoenix_v1",
"defines": ("ARDUINO_ESP8266_PHOENIX_V1",),
},
"phoenix_v2": {
"variant": "phoenix_v2",
"defines": ("ARDUINO_ESP8266_PHOENIX_V2",),
},
"sonoff_basic": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_BASIC",)},
"sonoff_s20": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_S20",)},
"sonoff_sv": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_SV",)},
"sonoff_th": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_TH",)},
"sparkfunBlynk": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING",)},
"thing": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING",)},
"thingdev": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING_DEV",)},
"wifi_slot": {"variant": "wifi_slot", "defines": ("ARDUINO_AMPERKA_WIFI_SLOT",)},
"wifiduino": {"variant": "wifiduino", "defines": ("ARDUINO_WIFIDUINO_ESP8266",)},
"wifinfo": {"variant": "wifinfo", "defines": ("ARDUINO_WIFINFO",)},
"wio_link": {"variant": "wiolink", "defines": ("ARDUINO_ESP8266_WIO_LINK",)},
"wio_node": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP_WROOM_02",)},
"xinabox_cw01": {
"variant": "xinabox",
"defines": ("ARDUINO_ESP8266_XINABOX_CW01",),
},
}
+123
View File
@@ -0,0 +1,123 @@
"""Linker-script surgery shared with the native (PlatformIO-free) toolchain.
These mirror the PlatformIO extra scripts in this directory
(``relocate_ratetable.py.script`` and ``testing_mode.py.script``), which run
inside SCons and must stay self-contained. The native build generator applies
the same patches to the linker scripts it generates, so the logic lives here
as plain functions. Keep both in sync when changing either.
``segment_length`` is native-toolchain-only and has no script twin.
"""
from __future__ import annotations
from collections.abc import Collection
import hashlib
import re
# Move the NONOS SDK wifi rate tables from flash to DRAM; see
# relocate_ratetable.py.script for the full background (NONOS SDK issue 320).
RATETABLE_RULE = "*libnet80211.a:ieee80211_phy.o(.irom.text .irom.text.*)"
_RATETABLE_COMMENT = (
"/* ESPHome: wifi rate tables must live in DRAM, see NONOS SDK issue 320 */"
)
# Match the whole line: "_data_start" is also a substring of the
# "_dport0_data_start" line in the earlier .dport0.data section
_RATETABLE_ANCHOR = re.compile(r"^\s*_data_start = ABSOLUTE\(\.\);", re.MULTILINE)
# Memory sizes for testing mode (allow larger builds for CI component grouping)
TESTING_IRAM_SIZE = "0x200000" # 2MB
TESTING_DRAM_SIZE = "0x200000" # 2MB
TESTING_FLASH_SIZE = "0x2000000" # 32MB
def relocate_ratetable(content: str) -> str:
"""Insert the rate-table DRAM rule into a generated common linker script."""
if RATETABLE_RULE in content:
return content
match = _RATETABLE_ANCHOR.search(content)
if match is None:
raise RuntimeError(
"'_data_start' anchor not found in the generated linker script; "
"cannot apply wifi rate table DRAM relocation "
"(has the Arduino core linker script changed?)"
)
insert_pos = match.end()
return (
content[:insert_pos]
+ f"\n {_RATETABLE_COMMENT}"
+ f"\n {RATETABLE_RULE}"
+ content[insert_pos:]
)
_TESTING_SEGMENT_SIZES = {
"iram1_0_seg": TESTING_IRAM_SIZE,
"dram0_0_seg": TESTING_DRAM_SIZE,
"irom0_0_seg": TESTING_FLASH_SIZE,
}
def _segment_line_re(segment_name: str) -> re.Pattern[str]:
"""The MEMORY line for one segment: ``<seg> : org = 0x..., len = 0x...``.
Anchored to the start of the line so a name never matches inside a
longer one (``ram0_0_seg`` must not read ``dram0_0_seg``). The size
group stops at the hex digits, leaving any ``ul`` suffix (from the
preprocessed ``MMU_IRAM_SIZE``) in place.
"""
return re.compile(
rf"(^[ \t]*{re.escape(segment_name)}"
r"\s*:\s*org\s*=\s*0x[0-9a-fA-F]+\s*,\s*len\s*=\s*)"
r"(0x[0-9a-fA-F]+)",
re.MULTILINE,
)
def apply_testing_memory_patches(content: str, segments: Collection[str]) -> str:
"""Enlarge the named memory segments so grouped CI test builds can link.
Each caller passes the segments its linker script defines: the
generated common ld carries ``iram1_0_seg``; the flash ld carries
``dram0_0_seg`` and ``irom0_0_seg``. A segment that fails to match
raises, since a silently kept real memory limit would fail grouped
builds far from the cause.
"""
for segment in _TESTING_SEGMENT_SIZES:
if segment not in segments and _segment_line_re(segment).search(content):
raise RuntimeError(
f"Testing-mode segment {segment} is present in the linker "
"script but was not selected for patching"
)
for segment in segments:
if segment not in _TESTING_SEGMENT_SIZES:
raise RuntimeError(f"Unknown testing-mode segment {segment!r}")
content, count = _segment_line_re(segment).subn(
rf"\g<1>{_TESTING_SEGMENT_SIZES[segment]}", content
)
if count == 0:
raise RuntimeError(
f"Testing-mode memory patch failed: segment {segment} "
"not found (has the Arduino core linker script changed?)"
)
return content
def segment_length(content: str, segment_name: str) -> int | None:
"""Read a memory segment's length from linker script content.
Returns None for an absent segment OR an unparsable line; callers must
treat None as "no usable budget" and warn (as the Flash summary does),
never as "no limit".
"""
match = _segment_line_re(segment_name).search(content)
return int(match.group(2), 16) if match else None
def surgery_fingerprint() -> str:
"""Hash of this module's source; linker-script caches include it so an
edit here invalidates them."""
import inspect
import sys
source = inspect.getsource(sys.modules[__name__])
return hashlib.sha256(source.encode()).hexdigest()
+5
View File
@@ -15,6 +15,11 @@ CONF_ENABLE_SERIAL1 = "enable_serial1"
KEY_WAVEFORM_REQUIRED = "waveform_required"
KEY_SERIAL_REQUIRED = "serial_required"
KEY_SERIAL1_REQUIRED = "serial1_required"
# Set for the native (non-PlatformIO) toolchain's build generator
KEY_FLASH_MODE = "flash_mode"
KEY_SCANF_FLOAT = "scanf_float"
# Per-board flash-layout override consumed by board_ld_script()
KEY_LDSCRIPT = "ldscript"
# esp8266 namespace is already defined by arduino, manually prefix esphome
esp8266_ns = cg.global_ns.namespace("esphome").namespace("esp8266")
@@ -71,6 +71,33 @@ def _process_git_config(config: dict[str, Any], refresh: TimePeriodSeconds) -> P
return components_dir
def _log_overridden_components(
conf: dict[str, Any], component_names: list[str]
) -> None:
overridden = [
name
for name in component_names
if (loader.CORE_COMPONENTS_PATH / name / "__init__.py").is_file()
]
if not overridden:
return
if conf[CONF_TYPE] == TYPE_GIT:
source = conf[CONF_URL]
if ref := conf.get(CONF_REF):
source = f"{source}@{ref}"
if path := conf.get(CONF_PATH):
source = f"{source} ({path})"
else:
source = conf[CONF_PATH]
_LOGGER.info(
"External components are overriding built-in components:\n"
" source: %s\n"
" components: %s",
source,
", ".join(sorted(overridden)),
)
def _process_single_config(config: dict[str, Any]) -> None:
conf = config[CONF_SOURCE]
if conf[CONF_TYPE] == TYPE_GIT:
@@ -84,8 +111,8 @@ def _process_single_config(config: dict[str, Any]) -> None:
raise NotImplementedError
if config[CONF_COMPONENTS] == "all":
num_components = len(list(components_dir.glob("*/__init__.py")))
if num_components > 100:
component_names = [p.parent.name for p in components_dir.glob("*/__init__.py")]
if len(component_names) > 100:
# Prevent accidentally including all components from an esphome fork/branch
# In this case force the user to manually specify which components they want to include
raise cv.Invalid(
@@ -102,6 +129,9 @@ def _process_single_config(config: dict[str, Any]) -> None:
[CONF_COMPONENTS, i],
)
allowed_components = config[CONF_COMPONENTS]
component_names = allowed_components
_log_overridden_components(conf, component_names)
loader.install_meta_finder(components_dir, allowed_components=allowed_components)
@@ -0,0 +1,43 @@
import esphome.codegen as cg
from esphome.components import button
import esphome.config_validation as cv
from esphome.const import ICON_AIR_FILTER
from esphome.types import ConfigType
from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns
DEPENDENCIES = ["hoermann_hcp"]
CONF_HALF_OPEN = "half_open"
CONF_VENT = "vent"
ICON_GARAGE_OPEN_VARIANT = "mdi:garage-open-variant"
HoermannHcpVentButton = hoermann_hcp_ns.class_("HoermannHcpVentButton", button.Button)
HoermannHcpHalfOpenButton = hoermann_hcp_ns.class_(
"HoermannHcpHalfOpenButton", button.Button
)
BUTTON_KEYS = (CONF_VENT, CONF_HALF_OPEN)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp),
cv.Optional(CONF_VENT): button.button_schema(
HoermannHcpVentButton, icon=ICON_AIR_FILTER
),
cv.Optional(CONF_HALF_OPEN): button.button_schema(
HoermannHcpHalfOpenButton, icon=ICON_GARAGE_OPEN_VARIANT
),
}
),
cv.has_at_least_one_key(*BUTTON_KEYS),
)
async def to_code(config: ConfigType) -> None:
parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID])
for key in BUTTON_KEYS:
if (conf := config.get(key)) is not None:
await button.new_button(conf, parent)
@@ -0,0 +1,34 @@
#pragma once
#include "esphome/components/button/button.h"
#include "../hoermann_hcp.h"
namespace esphome::hoermann_hcp {
// The door commands the cover has no equivalent for. A refused command is already reported by the hub and
// leaves nothing to correct here, because a button carries no state of its own.
class HoermannHcpButton : public button::Button {
public:
explicit HoermannHcpButton(HoermannHcp *parent) : parent_(parent) {}
protected:
HoermannHcp *const parent_;
};
class HoermannHcpVentButton final : public HoermannHcpButton {
public:
using HoermannHcpButton::HoermannHcpButton;
protected:
void press_action() override { this->parent_->vent_door(); }
};
class HoermannHcpHalfOpenButton final : public HoermannHcpButton {
public:
using HoermannHcpButton::HoermannHcpButton;
protected:
void press_action() override { this->parent_->half_open_door(); }
};
} // namespace esphome::hoermann_hcp
@@ -22,6 +22,9 @@ static constexpr uint8_t MAX_LIGHT_TOGGLES_IN_FLIGHT = 4;
static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110};
static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120};
static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140};
// The intermediate positions are named in the second register, so the first only carries the phase.
static constexpr HoermannHcpCommand COMMAND_VENT{"vent", 0x0200, 0x0100, 0x4000, 0x4000};
static constexpr HoermannHcpCommand COMMAND_HALF_OPEN{"half open", 0x0200, 0x0100, 0x0400, 0x0400};
// The lamp is named in the second register, but its phase bytes follow no scheme the door commands share.
static constexpr HoermannHcpCommand COMMAND_TOGGLE_LAMP{"toggle light", 0x0100, 0x0800, 0x0200, 0x0200, false};
@@ -286,6 +289,8 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) {
bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); }
bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); }
bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); }
bool HoermannHcp::vent_door() { return this->queue_command_(COMMAND_VENT); }
bool HoermannHcp::half_open_door() { return this->queue_command_(COMMAND_HALF_OPEN); }
bool HoermannHcp::toggle_light() {
if (this->light_toggles_in_flight_ >= MAX_LIGHT_TOGGLES_IN_FLIGHT) {
ESP_LOGW(TAG, "Too many lamp toggles are still waiting to be confirmed, dropping this one");
@@ -22,7 +22,8 @@ enum class DoorState : uint8_t {
};
// A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a
// short delay the released value. Each half also carries a second register, which only the lamp command uses.
// short delay the released value. Each half also carries a second register, which names the buttons that do
// not fit into the first.
struct HoermannHcpCommand {
const char *name;
uint16_t pressed_value;
@@ -54,6 +55,9 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice {
bool open_door();
bool close_door();
bool impulse_door();
// The door drives to these intermediate positions on its own, so neither takes a target to be stopped at.
bool vent_door();
bool half_open_door();
bool stop_door();
bool set_position(float position);
bool toggle_light();
+2
View File
@@ -37,6 +37,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_MAC_ADDRESS, default="98:35:69:ab:f6:79"): cv.mac_address,
}
),
cv.require_platformio_toolchain("host"),
set_core_data,
)
@@ -49,6 +50,7 @@ async def to_code(config: ConfigType) -> None:
cg.add_define("USE_ESPHOME_HOST_MAC_ADDRESS", config[CONF_MAC_ADDRESS].parts)
cg.add_build_flag("-std=gnu++20")
cg.add_define("ESPHOME_BOARD", "host")
cg.add_define("ESPHOME_VARIANT", "HOST")
cg.add_define(ThreadModel.MULTI_ATOMICS)
cg.add_platformio_option("platform", "platformio/native")
cg.add_platformio_option("lib_ldf_mode", "off")
+33 -1
View File
@@ -17,12 +17,14 @@ from esphome.const import (
CONF_TIMEOUT,
CONF_URL,
CONF_WATCHDOG_TIMEOUT,
PLATFORM_ESP32,
PLATFORM_HOST,
PlatformFramework,
__version__,
)
from esphome.core import CORE, ID, Lambda
from esphome.core import CORE, ID, Lambda, TimePeriodMilliseconds
from esphome.cpp_generator import MockObj, TemplateArgsType
import esphome.final_validate as fv
from esphome.helpers import IS_MACOS
from esphome.types import ConfigType
@@ -94,6 +96,34 @@ def validate_ssl_verification(config: ConfigType) -> ConfigType:
return config
# esp_http_client_open() runs DNS, TCP connect and the TLS handshake with no
# watchdog feed in between; each can take up to `timeout` on ESP-IDF.
WATCHDOG_TIMEOUT_MULTIPLIER = 3
# Headroom over the exact worst case so a fully stalled open does not land on
# the watchdog deadline.
WATCHDOG_TIMEOUT_MARGIN_MS = 1000
def default_watchdog_timeout(config: ConfigType) -> None:
"""Arm the request watchdog on ESP32 when the user did not set it.
The default never goes below the platform task watchdog, so a user who
widened `esp32.watchdog_timeout` keeps that window during requests.
"""
if not CORE.is_esp32 or CONF_WATCHDOG_TIMEOUT in config:
return
derived_ms = (
config[CONF_TIMEOUT].total_milliseconds * WATCHDOG_TIMEOUT_MULTIPLIER
+ WATCHDOG_TIMEOUT_MARGIN_MS
)
platform_ms = fv.full_config.get()[PLATFORM_ESP32][
CONF_WATCHDOG_TIMEOUT
].total_milliseconds
config[CONF_WATCHDOG_TIMEOUT] = TimePeriodMilliseconds(
milliseconds=max(derived_ms, platform_ms)
)
def _declare_request_class(value: Any) -> ID:
if CORE.is_host:
return cv.declare_id(HttpRequestHost)(value)
@@ -153,6 +183,8 @@ CONFIG_SCHEMA = cv.All(
validate_ssl_verification,
)
FINAL_VALIDATE_SCHEMA = default_watchdog_timeout
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
@@ -142,12 +142,13 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const std::string &url, c
const char *buf = body.c_str();
while (write_left > 0) {
int written = esp_http_client_write(client, buf + write_index, write_left);
if (written < 0) {
if (written <= 0) {
err = ESP_FAIL;
break;
}
write_left -= written;
write_index += written;
container->feed_wdt();
}
}
+1 -1
View File
@@ -43,4 +43,4 @@ async def setup_improv_core(var: MockObj, config: ConfigType, component: str) ->
cg.add(var.set_next_url(_process_next_url(next_url)))
cg.add_define(f"USE_{component.upper()}_NEXT_URL")
cg.add_library("improv/Improv", "1.2.6")
cg.add_library("improv/Improv", "1.2.7")
@@ -4,10 +4,13 @@
#include "esphome/components/network/util.h"
#include "esphome/core/application.h"
#include "esphome/core/defines.h"
#include "esphome/core/log.h"
namespace esphome::improv_base {
#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL)
static const char *const TAG = "improv_base";
static constexpr const char DEVICE_NAME_PLACEHOLDER[] = "{{device_name}}";
static constexpr size_t DEVICE_NAME_PLACEHOLDER_LEN = sizeof(DEVICE_NAME_PLACEHOLDER) - 1;
static constexpr const char IP_ADDRESS_PLACEHOLDER[] = "{{ip_address}}";
@@ -62,6 +65,21 @@ size_t ImprovBase::get_formatted_next_url_(char *buffer, size_t buffer_size) {
*out = '\0';
return out - buffer;
}
void ImprovBase::add_next_url_(improv::RpcResponseBuilder &builder, size_t max_len) {
// The builder rejects strings above 254 bytes, so anything longer than this
// buffer could never be sent anyway
char url_buffer[256];
size_t len = this->get_formatted_next_url_(url_buffer, sizeof(url_buffer));
if (len == 0) {
return;
}
// max_len is the transport's budget for this entry; skipping an over-long URL
// here keeps the rest of the response sendable instead of oversizing the frame
if (len > max_len || !builder.add_string(url_buffer, len)) {
ESP_LOGW(TAG, "Next URL too long; skipping");
}
}
#endif
} // namespace esphome::improv_base
@@ -3,6 +3,10 @@
#include <cstddef>
#include "esphome/core/defines.h"
#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL)
#include <improv.h>
#endif
namespace esphome::improv_base {
class ImprovBase {
@@ -15,6 +19,8 @@ class ImprovBase {
#if defined(USE_ESP32_IMPROV_NEXT_URL) || defined(USE_IMPROV_SERIAL_NEXT_URL)
/// Format next_url_ into buffer, replacing placeholders. Returns length written.
size_t get_formatted_next_url_(char *buffer, size_t buffer_size);
/// Append the formatted next_url to the RPC response, warning if it does not fit.
void add_next_url_(improv::RpcResponseBuilder &builder, size_t max_len);
const char *next_url_{nullptr};
#endif
};
+36 -5
View File
@@ -1,9 +1,15 @@
import esphome.codegen as cg
from esphome.components import improv_base
from esphome.components import improv_base, uart
from esphome.components.esp32 import VARIANT_ESP32S3, get_esp32_variant
from esphome.components.logger import USB_CDC
import esphome.config_validation as cv
from esphome.const import CONF_BAUD_RATE, CONF_HARDWARE_UART, CONF_ID, CONF_LOGGER
from esphome.const import (
CONF_BAUD_RATE,
CONF_HARDWARE_UART,
CONF_ID,
CONF_LOGGER,
CONF_UART_ID,
)
from esphome.core import CORE
import esphome.final_validate as fv
from esphome.types import ConfigType
@@ -17,13 +23,35 @@ improv_serial_ns = cg.esphome_ns.namespace("improv_serial")
ImprovSerialComponent = improv_serial_ns.class_("ImprovSerialComponent", cg.Component)
CONFIG_SCHEMA = (
cv.Schema({cv.GenerateID(): cv.declare_id(ImprovSerialComponent)})
cv.Schema(
{
cv.GenerateID(): cv.declare_id(ImprovSerialComponent),
# YAML only: rewiring Improv onto another UART is not a knob for a
# visual editor and the device builder must not expose it
cv.Optional(CONF_UART_ID, visibility=cv.Visibility.YAML_ONLY): cv.use_id(
uart.UARTComponent
),
}
)
.extend(improv_base.IMPROV_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
)
def validate_logger(config: ConfigType) -> None:
_UART_FINAL_VALIDATE = uart.final_validate_device_schema(
"improv_serial", require_tx=True, require_rx=True
)
def validate_transport(config: ConfigType) -> None:
if CONF_UART_ID in config:
# A dedicated UART bus is used; the logger's serial settings are irrelevant,
# but the bus itself must be bidirectional and not claimed by another device
_UART_FINAL_VALIDATE(config)
return
# The host logger has no serial port for Improv to share
if CORE.is_host:
raise cv.Invalid("improv_serial on the host platform requires uart_id")
logger_conf = fv.full_config.get()[CONF_LOGGER]
if logger_conf[CONF_BAUD_RATE] == 0:
raise cv.Invalid("improv_serial requires the logger baud_rate to be not 0")
@@ -36,7 +64,7 @@ def validate_logger(config: ConfigType) -> None:
)
FINAL_VALIDATE_SCHEMA = validate_logger
FINAL_VALIDATE_SCHEMA = validate_transport
async def to_code(config: ConfigType) -> None:
@@ -44,3 +72,6 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await improv_base.setup_improv_core(var, config, "improv_serial")
cg.add_define("USE_IMPROV_SERIAL")
if (uart_id := config.get(CONF_UART_ID)) is not None:
cg.add(var.set_uart(await cg.get_variable(uart_id)))
cg.add_define("USE_IMPROV_SERIAL_UART")
@@ -9,13 +9,17 @@
#include "esphome/components/logger/logger.h"
#include "esphome/components/wifi/scan_list.h"
#include <array>
namespace esphome::improv_serial {
static const char *const TAG = "improv_serial";
void ImprovSerialComponent::setup() {
global_improv_serial_component = this;
#ifdef USE_ESP32
#ifdef USE_IMPROV_SERIAL_UART
// Transport is a dedicated UART bus set via set_uart() in generated code
#elif defined(USE_ESP32)
this->uart_num_ = logger::global_logger->get_uart_num();
this->uart_selection_ = logger::global_logger->get_uart();
#elif defined(USE_ARDUINO)
@@ -59,8 +63,7 @@ void ImprovSerialComponent::loop() {
this->cancel_timeout("wifi-connect-timeout");
this->set_state_(improv::STATE_PROVISIONED);
std::vector<uint8_t> url = this->build_rpc_settings_response_(improv::WIFI_SETTINGS);
this->send_response_(url);
this->send_settings_response_(improv::WIFI_SETTINGS);
}
}
}
@@ -89,7 +92,13 @@ void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size)
}
this->tx_header_[TX_CHECKSUM_IDX] = checksum;
#ifdef USE_ESP32
#ifdef USE_IMPROV_SERIAL_UART
this->uart_->write_array(this->tx_header_, header_tx_len);
if (there_is_data) {
this->uart_->write_array(data, size);
this->uart_->write_array(&this->tx_header_[TX_CHECKSUM_IDX], 2); // Footer: checksum and newline
}
#elif defined(USE_ESP32)
switch (this->uart_selection_) {
case logger::UART_SELECTION_UART0:
case logger::UART_SELECTION_UART1:
@@ -134,16 +143,11 @@ void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size)
#endif
}
std::vector<uint8_t> ImprovSerialComponent::build_rpc_settings_response_(improv::Command command) {
std::vector<std::string> urls;
void ImprovSerialComponent::send_settings_response_(improv::Command command) {
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
improv::RpcResponseBuilder builder(buf, command);
#ifdef USE_IMPROV_SERIAL_NEXT_URL
{
char url_buffer[384];
size_t len = this->get_formatted_next_url_(url_buffer, sizeof(url_buffer));
if (len > 0) {
urls.emplace_back(url_buffer, len);
}
}
this->add_next_url_(builder, MAX_NEXT_URL_LEN);
#endif
#ifdef USE_WEBSERVER
for (auto &ip : wifi::global_wifi_component->wifi_sta_ip_addresses()) {
@@ -152,25 +156,63 @@ std::vector<uint8_t> ImprovSerialComponent::build_rpc_settings_response_(improv:
ip.str_to(ip_buf);
// "http://" (7) + IP (40) + ":" (1) + port (5) + null (1) = 54
char webserver_url[7 + network::IP_ADDRESS_BUFFER_SIZE + 1 + 5 + 1];
snprintf(webserver_url, sizeof(webserver_url), "http://%s:%u", ip_buf, USE_WEBSERVER_PORT);
urls.emplace_back(webserver_url);
// buf_append_printf keeps the format string in flash on ESP8266
size_t len =
buf_append_printf(webserver_url, sizeof(webserver_url), 0, "http://%s:%u", ip_buf, USE_WEBSERVER_PORT);
if (!builder.add_string(webserver_url, len)) {
ESP_LOGW(TAG, "Response full; URL dropped");
}
break;
}
}
#endif
std::vector<uint8_t> data = improv::build_rpc_response(command, urls, false);
return data;
this->send_response_(builder.finish(false));
}
std::vector<uint8_t> ImprovSerialComponent::build_version_info_() {
void ImprovSerialComponent::send_version_info_() {
// Entry cost per field is sizeof(lit): a length byte plus the string
#ifdef ESPHOME_PROJECT_NAME
std::vector<std::string> infos = {ESPHOME_PROJECT_NAME, ESPHOME_PROJECT_VERSION, ESPHOME_VARIANT, App.get_name()};
static constexpr size_t INFO_ENTRIES_LEN =
sizeof(ESPHOME_PROJECT_NAME) + sizeof(ESPHOME_PROJECT_VERSION) + sizeof(ESPHOME_VARIANT);
#else
std::vector<std::string> infos = {"ESPHome", ESPHOME_VERSION, ESPHOME_VARIANT, App.get_name()};
static constexpr size_t INFO_ENTRIES_LEN = sizeof("ESPHome") + sizeof(ESPHOME_VERSION) + sizeof(ESPHOME_VARIANT);
#endif
std::vector<uint8_t> data = improv::build_rpc_response(improv::GET_DEVICE_INFO, infos, false);
return data;
};
static_assert(INFO_ENTRIES_LEN < MAX_SERIAL_PAYLOAD,
"esphome project name and version too long for the improv_serial device info frame");
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
improv::RpcResponseBuilder builder(buf, improv::GET_DEVICE_INFO);
#ifdef USE_ESP8266
// Keep each literal in flash and copy it through an exact size stack buffer,
// so a long project name or version can never be truncated
#define IMPROV_ADD_INFO(lit) \
do { \
static const char progmem_str[] PROGMEM = lit; \
char tmp[sizeof(lit)]; \
progmem_memcpy(tmp, progmem_str, sizeof(lit)); \
builder.add_string(tmp, sizeof(lit) - 1); \
} while (0)
#else
// Literals are directly flash mapped on all other platforms
#define IMPROV_ADD_INFO(lit) builder.add_string(lit, sizeof(lit) - 1)
#endif
#ifdef ESPHOME_PROJECT_NAME
IMPROV_ADD_INFO(ESPHOME_PROJECT_NAME);
IMPROV_ADD_INFO(ESPHOME_PROJECT_VERSION);
#else
IMPROV_ADD_INFO("ESPHome");
IMPROV_ADD_INFO(ESPHOME_VERSION);
#endif
IMPROV_ADD_INFO(ESPHOME_VARIANT);
#undef IMPROV_ADD_INFO
// Only the device name length is unknown at compile time
const auto &name = App.get_name();
if (INFO_ENTRIES_LEN + 1 + name.size() <= MAX_SERIAL_PAYLOAD) {
builder.add_string(name.c_str(), name.size());
} else {
ESP_LOGW(TAG, "Response full; device name dropped");
}
this->send_response_(builder.finish(false));
}
bool ImprovSerialComponent::parse_improv_serial_byte_(uint8_t byte) {
size_t at = this->rx_buffer_.size();
@@ -221,32 +263,35 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command
}
this->set_state_(this->state_);
if (this->state_ == improv::STATE_PROVISIONED) {
std::vector<uint8_t> url = this->build_rpc_settings_response_(improv::GET_CURRENT_STATE);
this->send_response_(url);
this->send_settings_response_(improv::GET_CURRENT_STATE);
}
return true;
case improv::GET_DEVICE_INFO: {
std::vector<uint8_t> info = this->build_version_info_();
this->send_response_(info);
this->send_version_info_();
return true;
}
case improv::GET_WIFI_NETWORKS: {
const auto &results = wifi::global_wifi_component->get_scan_result();
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
for (const auto &scan : results) {
bool with_auth = false;
if (!wifi::should_show_scan_entry(results, scan, with_auth))
continue;
// Send each ssid separately to avoid overflowing the buffer
char rssi_buf[5]; // int8_t: -128 to 127, max 4 chars + null
*int8_to_str(rssi_buf, scan.get_rssi()) = '\0';
std::vector<uint8_t> data = improv::build_rpc_response(
improv::GET_WIFI_NETWORKS, {scan.get_ssid().str(), rssi_buf, YESNO(with_auth)}, false);
this->send_response_(data);
char *rssi_end = int8_to_str(rssi_buf, scan.get_rssi());
*rssi_end = '\0';
improv::RpcResponseBuilder builder(buf, improv::GET_WIFI_NETWORKS);
// SSID(32) + RSSI(4) + YESNO(3) entries always fit the payload
const auto &ssid = scan.get_ssid();
builder.add_string(ssid.c_str(), ssid.size());
builder.add_string(rssi_buf, rssi_end - rssi_buf);
builder.add_string(YESNO(with_auth));
this->send_response_(builder.finish(false));
}
// Send empty response to signify the end of the list.
std::vector<uint8_t> data =
improv::build_rpc_response(improv::GET_WIFI_NETWORKS, std::vector<std::string>{}, false);
this->send_response_(data);
improv::RpcResponseBuilder builder(buf, improv::GET_WIFI_NETWORKS);
this->send_response_(builder.finish(false));
return true;
}
default: {
@@ -274,7 +319,14 @@ void ImprovSerialComponent::set_error_(improv::Error error) {
this->write_data_();
}
void ImprovSerialComponent::send_response_(std::vector<uint8_t> &response) {
void ImprovSerialComponent::send_response_(std::span<const uint8_t> response) {
// The serial frame length field is a single byte
if (response.size() > MAX_SERIAL_RESPONSE) {
ESP_LOGE(TAG, "Response too long");
// Fail fast instead of leaving the client to wait out its timeout
this->set_error_(improv::ERROR_UNKNOWN);
return;
}
this->tx_header_[TX_TYPE_IDX] = TYPE_RPC_RESPONSE;
this->write_data_(response.data(), response.size());
}
@@ -8,9 +8,12 @@
#include "esphome/core/helpers.h"
#ifdef USE_WIFI
#include <improv.h>
#include <span>
#include <vector>
#ifdef USE_ESP32
#ifdef USE_IMPROV_SERIAL_UART
#include "esphome/components/uart/uart_component.h"
#elif defined(USE_ESP32)
#include <driver/uart.h>
#ifdef USE_LOGGER_USB_SERIAL_JTAG
#include <driver/usb_serial_jtag.h>
@@ -45,6 +48,22 @@ enum ImprovSerialType : uint8_t {
static const uint16_t IMPROV_SERIAL_TIMEOUT = 100;
static const uint8_t IMPROV_SERIAL_VERSION = 1;
// The serial frame length field is one byte
static constexpr size_t MAX_SERIAL_RESPONSE = 255;
// command + data length + trailing byte
static constexpr size_t RPC_RESPONSE_OVERHEAD = 3;
static constexpr size_t MAX_SERIAL_PAYLOAD = MAX_SERIAL_RESPONSE - RPC_RESPONSE_OVERHEAD;
#ifdef USE_WEBSERVER
// length byte + "http://" + IPv4 + ":" + port
static constexpr size_t WEBSERVER_URL_RESERVE = 1 + 7 + 15 + 1 + 5;
#else
static constexpr size_t WEBSERVER_URL_RESERVE = 0;
#endif
// Entry budget minus its own length byte
static constexpr size_t MAX_NEXT_URL_LEN = MAX_SERIAL_PAYLOAD - WEBSERVER_URL_RESERVE - 1;
static_assert(MAX_SERIAL_RESPONSE <= improv::RPC_RESPONSE_MAX_SIZE, "builder buffer too small for the frame");
class ImprovSerialComponent final : public Component, public improv_base::ImprovBase {
public:
void setup() override;
@@ -53,6 +72,10 @@ class ImprovSerialComponent final : public Component, public improv_base::Improv
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
#ifdef USE_IMPROV_SERIAL_UART
void set_uart(uart::UARTComponent *uart) { this->uart_ = uart; }
#endif
protected:
bool parse_improv_serial_byte_(uint8_t byte);
bool parse_improv_payload_(improv::ImprovCommand &command);
@@ -60,16 +83,20 @@ class ImprovSerialComponent final : public Component, public improv_base::Improv
void set_state_(improv::State state);
void send_current_state_(improv::State state);
void set_error_(improv::Error error);
void send_response_(std::vector<uint8_t> &response);
void send_response_(std::span<const uint8_t> response);
void on_wifi_connect_timeout_();
std::vector<uint8_t> build_rpc_settings_response_(improv::Command command);
std::vector<uint8_t> build_version_info_();
void send_settings_response_(improv::Command command);
void send_version_info_();
ESPHOME_ALWAYS_INLINE optional<uint8_t> read_byte_() {
optional<uint8_t> byte;
uint8_t data = 0;
#ifdef USE_ESP32
#ifdef USE_IMPROV_SERIAL_UART
if (this->uart_->available() && this->uart_->read_byte(&data)) {
byte = data;
}
#elif defined(USE_ESP32)
switch (this->uart_selection_) {
case logger::UART_SELECTION_UART0:
case logger::UART_SELECTION_UART1:
@@ -129,7 +156,9 @@ class ImprovSerialComponent final : public Component, public improv_base::Improv
'\n',
};
#ifdef USE_ESP32
#ifdef USE_IMPROV_SERIAL_UART
uart::UARTComponent *uart_{nullptr};
#elif defined(USE_ESP32)
uart_port_t uart_num_;
logger::UARTSelection uart_selection_{logger::UART_SELECTION_UART0};
#elif defined(USE_ARDUINO)
+2 -1
View File
@@ -300,7 +300,7 @@ FRAMEWORK_SCHEMA = cv.All(
_check_debug_order,
)
CONFIG_SCHEMA = cv.All(_notify_old_style)
CONFIG_SCHEMA = cv.All(_notify_old_style, cv.require_platformio_toolchain("LibreTiny"))
BASE_SCHEMA = cv.Schema(
{
@@ -314,6 +314,7 @@ BASE_SCHEMA = cv.Schema(
)
BASE_SCHEMA.add_extra(_detect_variant)
BASE_SCHEMA.add_extra(cv.require_platformio_toolchain("LibreTiny"))
BASE_SCHEMA.add_extra(_update_core_data)
+50 -4
View File
@@ -525,6 +525,52 @@ void IndicatorLine::update_length_() {
}
#endif
#ifdef USE_LVGL_TABLE
uint32_t lv_table_get_selected_row(lv_obj_t *obj) {
uint32_t row;
uint32_t column;
lv_table_get_selected_cell(obj, &row, &column);
return row;
}
uint32_t lv_table_get_selected_column(lv_obj_t *obj) {
uint32_t row;
uint32_t column;
lv_table_get_selected_cell(obj, &row, &column);
return column;
}
void LvTableType::set_obj(lv_obj_t *lv_obj) {
LvCompound::set_obj(lv_obj);
lv_obj_add_event_cb(
lv_obj,
[](lv_event_t *e) {
auto *table = static_cast<LvTableType *>(lv_event_get_user_data(e));
table->update_column_widths_();
},
LV_EVENT_SIZE_CHANGED, this);
}
void LvTableType::add_column_width_pct(uint32_t col, uint8_t pct) {
for (auto &i : this->column_pct_) {
if (i.col == col) {
i.pct = pct;
this->update_column_widths_();
return;
}
}
this->column_pct_.push_back({col, pct});
this->update_column_widths_();
}
void LvTableType::update_column_widths_() {
auto content_width = lv_obj_get_content_width(this->obj);
for (const auto &col : this->column_pct_) {
lv_table_set_column_width(this->obj, col.col, content_width * col.pct / 100);
}
}
#endif // USE_LVGL_TABLE
#ifdef USE_LVGL_KEY_LISTENER
LVEncoderListener::LVEncoderListener(lv_indev_type_t type, uint16_t long_press_time, uint16_t long_press_repeat_time) {
this->drv_ = lv_indev_create();
@@ -551,21 +597,21 @@ std::string LvSelectable::get_selected_text() {
return this->options_[selected];
}
static std::string join_string(std::vector<std::string> options) {
static std::string join_string(const FixedVector<const char *> &options) {
return std::accumulate(
options.begin(), options.end(), std::string(),
[](const std::string &a, const std::string &b) -> std::string { return a + (!a.empty() ? "\n" : "") + b; });
[](const std::string &a, const char *b) -> std::string { return a + (!a.empty() ? "\n" : "") + b; });
}
void LvSelectable::set_selected_text(const std::string &text, lv_anim_enable_t anim) {
auto index = std::find(this->options_.begin(), this->options_.end(), text);
auto *index = std::find(this->options_.begin(), this->options_.end(), text);
if (index != this->options_.end()) {
this->set_selected_index(index - this->options_.begin(), anim);
lv_obj_send_event(this->obj, lv_update_event, nullptr);
}
}
void LvSelectable::set_options(std::vector<std::string> options) {
void LvSelectable::set_options(FixedVector<const char *> options) {
auto index = this->get_selected_index();
if (index >= options.size())
index = options.size() - 1;
+28 -3
View File
@@ -58,6 +58,10 @@ lv_obj_t *lv_container_create(lv_obj_t *parent);
void lv_scale_draw_event_cb(lv_event_t *e, int16_t range_start, int16_t range_end, lv_color_t color_start,
lv_color_t color_end, int width, bool local);
#endif
#ifdef USE_LVGL_TABLE
uint32_t lv_table_get_selected_row(lv_obj_t *obj);
uint32_t lv_table_get_selected_column(lv_obj_t *obj);
#endif
#if LV_COLOR_DEPTH == 16
static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BITNESS_565;
#elif LV_COLOR_DEPTH == 32
@@ -511,6 +515,27 @@ class LvLineType : public LvCompound {
FixedVector<lv_point_precise_t> points_{};
};
#endif
#ifdef USE_LVGL_TABLE
// Unlike most size properties, lv_table_set_column_width() only accepts a literal pixel
// count, so percentage column widths must be recomputed by hand whenever the table's own
// content width changes.
class LvTableType : public LvCompound {
public:
void set_obj(lv_obj_t *lv_obj) override;
// count is the number of percentage-width columns, known at code-generation time.
void init_column_pct(size_t count) { this->column_pct_.init(count); }
void add_column_width_pct(uint32_t col, uint8_t pct);
protected:
void update_column_widths_();
struct ColumnPct {
uint32_t col;
uint8_t pct;
};
FixedVector<ColumnPct> column_pct_{};
};
#endif // USE_LVGL_TABLE
#if defined(USE_LVGL_DROPDOWN) || defined(LV_USE_ROLLER)
class LvSelectable : public LvCompound {
public:
@@ -518,12 +543,12 @@ class LvSelectable : public LvCompound {
virtual void set_selected_index(size_t index, lv_anim_enable_t anim) = 0;
void set_selected_text(const std::string &text, lv_anim_enable_t anim);
std::string get_selected_text();
const std::vector<std::string> &get_options() { return this->options_; }
void set_options(std::vector<std::string> options);
const FixedVector<const char *> &get_options() { return this->options_; }
void set_options(FixedVector<const char *> options);
protected:
virtual void set_option_string(const char *options) = 0;
std::vector<std::string> options_{};
FixedVector<const char *> options_{};
};
#ifdef USE_LVGL_DROPDOWN
+3 -12
View File
@@ -50,19 +50,10 @@ class LVGLSelect final : public select::Select, public Component {
protected:
void control(size_t index) override {
this->widget_->set_selected_index(index, this->anim_);
this->publish();
}
void set_options_() {
// Widget uses std::vector<std::string>, SelectTraits uses FixedVector<const char*>
// Convert by extracting c_str() pointers
const auto &opts = this->widget_->get_options();
FixedVector<const char *> opt_ptrs;
opt_ptrs.init(opts.size());
for (const auto &opt : opts) {
opt_ptrs.push_back(opt.c_str());
}
this->traits.set_options(opt_ptrs);
// The update event fires the widget's on_value/on_update triggers
lv_obj_send_event(this->widget_->obj, lv_update_event, nullptr);
}
void set_options_() { this->traits.set_options(this->widget_->get_options()); }
LvSelectable *widget_;
lv_anim_enable_t anim_;
+3
View File
@@ -3,6 +3,8 @@ from esphome.const import CONF_TEXT, CONF_VALUE
from esphome.cpp_generator import MockObj
from esphome.cpp_types import Component, esphome_ns
from .defines import CONF_SELECTED_INDEX
class LvType(cg.MockObjClass):
def __init__(self, *args, **kwargs):
@@ -112,3 +114,4 @@ class LvSelect(LvType):
parents=parens,
**kwargs,
)
self.value_property = CONF_SELECTED_INDEX
+280
View File
@@ -0,0 +1,280 @@
from contextlib import ExitStack
from esphome import automation
import esphome.codegen as cg
from esphome.components.const import CONF_ROWS
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_ITEMS, CONF_ROW, CONF_TEXT, CONF_WIDTH
from esphome.core import ID
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.schema_extractors import SCHEMA_EXTRACT
from esphome.types import ConfigFragmentType, ConfigType, SafeExpType
from ..automation import action_to_code
from ..defines import CONF_COLUMN, CONF_MAIN, LValidator, literal
from ..lv_validation import lv_int, lv_text, pixels_or_percent, pixels_validator
from ..lvcode import LocalVariable, lv, lv_add, lv_expr
from ..types import LvCompound, LvType, ObjUpdateAction, lv_coord_t
from . import Widget, WidgetType, get_widgets
from .label import CONF_LABEL
CONF_TABLE = "table"
CONF_CELLS = "cells"
CONF_COLUMNS = "columns"
CONF_ROW_COUNT = "row_count"
CONF_COLUMN_COUNT = "column_count"
CONF_MERGE_RIGHT = "merge_right"
CONF_TEXT_CROP = "text_crop"
CONF_SELECTED_ROW = "selected_row"
CONF_SELECTED_COLUMN = "selected_column"
CELL_SCHEMA = cv.Schema(
{
cv.Optional(CONF_TEXT, default=""): lv_text,
# Not templatable: the value selects between two different LVGL calls
# (set/clear cell ctrl), so a runtime lambda can't be mapped to a single call.
cv.Optional(CONF_MERGE_RIGHT): cv.boolean,
cv.Optional(CONF_TEXT_CROP): cv.boolean,
}
)
# A cell can be given as a bare piece of text, or a dict for more control
TABLE_CELL_SCHEMA = cv.maybe_simple_value(CELL_SCHEMA, key=CONF_TEXT)
# A row can be given as a bare list of cells, or a dict for future extension
ROW_SCHEMA = cv.maybe_simple_value(
cv.Schema({cv.Required(CONF_CELLS): cv.ensure_list(TABLE_CELL_SCHEMA)}),
key=CONF_CELLS,
)
def _column_width_validator(value: ConfigFragmentType) -> int | float | list[str]:
"""Like pixels_or_percent, but rejects negative widths, which would
defeat the 100%-total check and wrap around in the generated uint8_t pct."""
if value == SCHEMA_EXTRACT:
return ["pixels", "..%"]
return cv.Any(pixels_validator, cv.percentage)(value)
column_width = LValidator(
_column_width_validator,
lv_coord_t,
retmapper=pixels_or_percent.retmapper,
animatable=True,
)
COLUMN_SCHEMA = cv.Schema(
{
cv.Optional(CONF_WIDTH): column_width,
}
)
def _validate_table(config: ConfigType) -> ConfigType:
rows = config.get(CONF_ROWS)
min_row_count = len(rows) if rows else 0
min_column_count = max(len(row[CONF_CELLS]) for row in rows) if rows else 0
row_count = config.get(CONF_ROW_COUNT)
if row_count is not None and row_count < min_row_count:
raise cv.Invalid(
f"{CONF_ROW_COUNT} must be at least {min_row_count} to hold all the given rows",
path=[CONF_ROW_COUNT],
)
column_count = config.get(CONF_COLUMN_COUNT)
if column_count is not None and column_count < min_column_count:
raise cv.Invalid(
f"{CONF_COLUMN_COUNT} must be at least {min_column_count} to hold all the cells in a row",
path=[CONF_COLUMN_COUNT],
)
column_count = column_count if column_count is not None else min_column_count
columns = config.get(CONF_COLUMNS)
if columns and column_count and len(columns) > column_count:
raise cv.Invalid(
f"{CONF_COLUMNS} defines {len(columns)} columns, but the table has only {column_count}",
path=[CONF_COLUMNS],
)
total_pct = sum(
width
for column in columns or ()
if isinstance((width := column.get(CONF_WIDTH)), float)
)
if total_pct > 1.0:
raise cv.Invalid(
f"{CONF_COLUMNS} percentage widths add up to {total_pct * 100:.0f}%, which exceeds 100%",
path=[CONF_COLUMNS],
)
return config
TABLE_SCHEMA = cv.Schema(
{
cv.Optional(CONF_ROWS): cv.ensure_list(ROW_SCHEMA),
cv.Optional(CONF_ROW_COUNT): cv.positive_int,
cv.Optional(CONF_COLUMN_COUNT): cv.positive_int,
cv.Optional(CONF_COLUMNS): cv.ensure_list(COLUMN_SCHEMA),
cv.Optional(CONF_SELECTED_ROW): lv_int,
cv.Optional(CONF_SELECTED_COLUMN): lv_int,
}
).add_extra(_validate_table)
lv_table_t = LvType(
"LvTableType",
parents=(LvCompound,),
largs=[(cg.uint32, "row"), (cg.uint32, "column")],
lvalue=lambda w: [
lv_expr.table_get_selected_row(w.obj),
lv_expr.table_get_selected_column(w.obj),
],
has_on_value=True,
)
async def set_cell_ctrl(
w: Widget, row: SafeExpType, column: SafeExpType, cell: ConfigType
) -> None:
for key, ctrl in (
(CONF_MERGE_RIGHT, "LV_TABLE_CELL_CTRL_MERGE_RIGHT"),
(CONF_TEXT_CROP, "LV_TABLE_CELL_CTRL_TEXT_CROP"),
):
if key not in cell:
continue
if cell[key]:
lv.table_set_cell_ctrl(w.obj, row, column, literal(ctrl))
else:
lv.table_clear_cell_ctrl(w.obj, row, column, literal(ctrl))
async def set_selected_cell(w: Widget, config: ConfigType) -> None:
selected_row = config.get(CONF_SELECTED_ROW)
selected_column = config.get(CONF_SELECTED_COLUMN)
if selected_row is None and selected_column is None:
return
# LV_TABLE_CELL_NONE selects the whole column/row when only one index is given
row_value = (
await lv_int.process(selected_row)
if selected_row is not None
else literal("LV_TABLE_CELL_NONE")
)
column_value = (
await lv_int.process(selected_column)
if selected_column is not None
else literal("LV_TABLE_CELL_NONE")
)
lv.table_set_selected_cell(w.obj, row_value, column_value)
TABLE_MODIFY_SCHEMA = cv.Schema(
{
cv.Optional(CONF_SELECTED_ROW): lv_int,
cv.Optional(CONF_SELECTED_COLUMN): lv_int,
}
)
class TableType(WidgetType):
def __init__(self):
super().__init__(
CONF_TABLE,
lv_table_t,
(CONF_MAIN, CONF_ITEMS),
TABLE_SCHEMA,
modify_schema=TABLE_MODIFY_SCHEMA,
)
def get_uses(self) -> tuple[str]:
return (CONF_LABEL,)
async def to_code(self, w: Widget, config: dict) -> None:
rows = config.get(CONF_ROWS)
row_count = config.get(CONF_ROW_COUNT)
column_count = config.get(CONF_COLUMN_COUNT)
if rows is not None:
if row_count is None:
row_count = len(rows)
if column_count is None:
column_count = max((len(row[CONF_CELLS]) for row in rows), default=0)
if row_count is not None:
lv.table_set_row_count(w.obj, row_count)
if column_count is not None:
lv.table_set_column_count(w.obj, column_count)
columns = config.get(CONF_COLUMNS, ())
pct_column_count = sum(
1 for column in columns if isinstance(column.get(CONF_WIDTH), float)
)
if pct_column_count:
lv_add(w.var.init_column_pct(pct_column_count))
for index, column in enumerate(columns):
if (width := column.get(CONF_WIDTH)) is None:
continue
if isinstance(width, float):
# A percentage: column_width validation leaves it as a 0.0-1.0
# fraction. LVGL's table widget only accepts a literal pixel width, so
# the actual width is recomputed at runtime from the table's own size.
lv_add(w.var.add_column_width_pct(index, round(width * 100)))
else:
lv.table_set_column_width(
w.obj, index, await column_width.process(width)
)
for row_index, row in enumerate(rows or ()):
for column_index, cell in enumerate(row[CONF_CELLS]):
lv.table_set_cell_value(
w.obj,
row_index,
column_index,
await lv_text.process(cell[CONF_TEXT]),
)
await set_cell_ctrl(w, row_index, column_index, cell)
await set_selected_cell(w, config)
table_spec = TableType()
@automation.register_action(
"lvgl.table.cell.update",
ObjUpdateAction,
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(lv_table_t),
cv.Required(CONF_ROW): lv_int,
cv.Required(CONF_COLUMN): lv_int,
cv.Optional(CONF_TEXT): lv_text,
cv.Optional(CONF_MERGE_RIGHT): cv.boolean,
cv.Optional(CONF_TEXT_CROP): cv.boolean,
}
).add_extra(cv.has_at_least_one_key(CONF_TEXT, CONF_MERGE_RIGHT, CONF_TEXT_CROP)),
synchronous=True,
)
async def table_cell_update_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
widgets = await get_widgets(config)
async def do_update(w: Widget):
row = await lv_int.process(config[CONF_ROW])
column = await lv_int.process(config[CONF_COLUMN])
fields_set = sum(
key in config for key in (CONF_TEXT, CONF_MERGE_RIGHT, CONF_TEXT_CROP)
)
with ExitStack() as stack:
if fields_set > 1:
# row/column feed more than one generated call below: cache them in
# local variables so a !lambda value is only evaluated once.
row = stack.enter_context(
LocalVariable("row", cg.int_, row, modifier="")
)
column = stack.enter_context(
LocalVariable("column", cg.int_, column, modifier="")
)
if CONF_TEXT in config:
lv.table_set_cell_value(
w.obj, row, column, await lv_text.process(config[CONF_TEXT])
)
await set_cell_ctrl(w, row, column, config)
return await action_to_code(
widgets, do_update, action_id, template_arg, args, config
)
+13 -1
View File
@@ -41,7 +41,19 @@ static void register_esp8266(MDNSComponent *, StaticVector<MDNSService, MDNS_SER
#ifdef USE_MDNS_EVENT_DRIVEN_POLLING
void MDNSComponent::start_polling_window_() {
// uint32_t-ID set_interval/set_timeout already does atomic cancel-and-add.
this->set_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); });
this->set_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() {
#ifdef USE_MDNS_WIFI_LISTENER
// MDNS.update() can suspend the loop in UdpContext::sendTimeout() while a send is
// failing (radio off-channel during a roam scan, or mid reconnect); an incoming
// packet then re-enters LEAmDNS from lwIP and corrupts shared UdpContext state.
// Skip the tick while the radio cannot transmit (#18760), but keep polling while
// the AP is serving clients (AP-only or fallback AP with the STA down).
auto *wifi = wifi::global_wifi_component;
if (wifi->is_roaming() || (!wifi->is_connected() && !wifi->is_ap_active()))
return;
#endif
MDNS.update();
});
this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); });
}
#endif
-2
View File
@@ -266,8 +266,6 @@ DriverChip(
"JC3636W518V2",
height=360,
width=360,
offset_height=1,
draw_rounding=1,
cs_pin=10,
reset_pin=47,
invert_colors=True,
+3
View File
@@ -3,6 +3,9 @@ import esphome.codegen as cg
modbus_ns = cg.esphome_ns.namespace("modbus")
modbus_helpers_ns = modbus_ns.namespace("helpers")
RegisterValues = modbus_ns.class_("RegisterValues")
PduBuffer = modbus_helpers_ns.class_("PduBuffer")
FunctionCode_ns = modbus_ns.namespace("FunctionCode")
FunctionCode = FunctionCode_ns.enum("FunctionCode")
@@ -20,7 +20,9 @@ const uint8_t FUNCTION_CODE_USER_DEFINED_SPACE_2_END = 110; // 0x6E
enum class FunctionCode : uint8_t {
INVALID = 0x00, // 0x00 is not a valid function code (even for custom functions).
CUSTOM = 0x00, // The CUSTOM alias should be removed in future.
// Remove before 2027.3.0
CUSTOM ESPDEPRECATED("0x00 is not a function code; use FunctionCode::INVALID for the sentinel. Removed in 2027.3.0",
"2026.9.0") = 0x00,
READ_COILS = 0x01,
READ_DISCRETE_INPUTS = 0x02,
READ_HOLDING_REGISTERS = 0x03,
+44 -28
View File
@@ -153,6 +153,50 @@ inline std::span<const uint8_t> server_pdu_payload(std::span<const uint8_t> pdu)
inline uint8_t client_frame_data_offset(const uint8_t *, size_t) { return 2; }
/** Extract data from modbus response buffer
* @param T one of supported integer data types int_8,int_16,int_32,int_64
* @param data modbus response buffer (uint8_t)
* @param buffer_offset offset in bytes.
* @return value of type T extracted from buffer
*/
template<typename T> T get_data(const uint8_t *data, size_t buffer_offset) {
if (sizeof(T) == sizeof(uint8_t)) {
return T(data[buffer_offset]);
}
if (sizeof(T) == sizeof(uint16_t)) {
return T((uint16_t(data[buffer_offset + 0]) << 8) | (uint16_t(data[buffer_offset + 1]) << 0));
}
if (sizeof(T) == sizeof(uint32_t)) {
return static_cast<uint32_t>(get_data<uint16_t>(data, buffer_offset)) << 16 |
static_cast<uint32_t>(get_data<uint16_t>(data, buffer_offset + 2));
}
if (sizeof(T) == sizeof(uint64_t)) {
return static_cast<uint64_t>(get_data<uint32_t>(data, buffer_offset)) << 32 |
(static_cast<uint64_t>(get_data<uint32_t>(data, buffer_offset + 4)));
}
static_assert(sizeof(T) == sizeof(uint8_t) || sizeof(T) == sizeof(uint16_t) || sizeof(T) == sizeof(uint32_t) ||
sizeof(T) == sizeof(uint64_t),
"Unsupported type size in get_data; only 1, 2, 4, or 8-byte integer types are supported.");
return T{};
}
/// Function code of a PDU, exception flag masked; 0 for an empty PDU.
inline uint8_t pdu_function_code(std::span<const uint8_t> pdu) {
return pdu.empty() ? 0 : (pdu[0] & FUNCTION_CODE_MASK);
}
/// Start address of a standard client request PDU ([fc, addr_hi, addr_lo, ...]). Empty when the PDU is
/// too short or its function code has no known layout - custom-frame bytes are not misread as an address.
inline std::optional<uint16_t> client_pdu_start_address(std::span<const uint8_t> pdu) {
if (pdu.size() < 3 || is_function_code_unknown_length(pdu[0]))
return std::nullopt;
const auto fc = static_cast<FunctionCode>(pdu[0]);
// The file-record PDUs are known-length but carry a byte count, not a start address.
if (fc == FunctionCode::READ_FILE_RECORD || fc == FunctionCode::WRITE_FILE_RECORD)
return std::nullopt;
return get_data<uint16_t>(pdu.data(), 1);
}
enum class SensorValueType : uint8_t {
RAW = 0x00, // variable length
U_WORD = 0x1, // 1 Register unsigned
@@ -256,34 +300,6 @@ inline uint64_t qword_from_hex_str(const std::string &value, uint8_t pos) {
return static_cast<uint64_t>(dword_from_hex_str(value, pos)) << 32 | dword_from_hex_str(value, pos + 4);
}
// Extract data from modbus response buffer
/** Extract data from modbus response buffer
* @param T one of supported integer data types int_8,int_16,int_32,int_64
* @param data modbus response buffer (uint8_t)
* @param buffer_offset offset in bytes.
* @return value of type T extracted from buffer
*/
template<typename T> T get_data(const uint8_t *data, size_t buffer_offset) {
if (sizeof(T) == sizeof(uint8_t)) {
return T(data[buffer_offset]);
}
if (sizeof(T) == sizeof(uint16_t)) {
return T((uint16_t(data[buffer_offset + 0]) << 8) | (uint16_t(data[buffer_offset + 1]) << 0));
}
if (sizeof(T) == sizeof(uint32_t)) {
return static_cast<uint32_t>(get_data<uint16_t>(data, buffer_offset)) << 16 |
static_cast<uint32_t>(get_data<uint16_t>(data, buffer_offset + 2));
}
if (sizeof(T) == sizeof(uint64_t)) {
return static_cast<uint64_t>(get_data<uint32_t>(data, buffer_offset)) << 32 |
(static_cast<uint64_t>(get_data<uint32_t>(data, buffer_offset + 4)));
}
static_assert(sizeof(T) == sizeof(uint8_t) || sizeof(T) == sizeof(uint16_t) || sizeof(T) == sizeof(uint32_t) ||
sizeof(T) == sizeof(uint64_t),
"Unsupported type size in get_data; only 1, 2, 4, or 8-byte integer types are supported.");
return T{};
}
template<typename T> T get_data(const std::vector<uint8_t> &data, size_t buffer_offset) {
return get_data<T>(data.data(), buffer_offset);
}
@@ -191,7 +191,7 @@ ModbusItemBaseSchema = cv.Schema(
)
def validate_modbus_register(config):
def validate_modbus_register(config: ConfigType) -> ConfigType:
# custom_command is the deprecated alias for custom_pdu (migrated later in final validate); treat
# either as "a custom frame is configured" so the address/register_type rules match.
has_custom = CONF_CUSTOM_PDU in config or CONF_CUSTOM_COMMAND in config
@@ -278,7 +278,22 @@ def _final_validate(config: ConfigType) -> None:
FINAL_VALIDATE_SCHEMA = _final_validate
def modbus_calc_properties(config):
def reject_odd_holding_write_offset(config: ConfigType) -> ConfigType:
"""Reject an odd byte offset on a holding-register write entity.
A 16-bit register write cannot target half a register, so the residual byte is inexpressible.
"""
key = CONF_BYTE_OFFSET if CONF_BYTE_OFFSET in config else CONF_OFFSET
if config.get(key, 0) % 2:
raise cv.Invalid(
f"An odd '{key}' cannot be used with holding-register writes: a 16-bit register "
"write cannot target half a register. Use an even offset, or fold it into 'address'",
path=[key],
)
return config
def modbus_calc_properties(config: ConfigType) -> tuple[int, int]:
byte_offset = 0
reg_count = 0
if CONF_OFFSET in config:
@@ -307,8 +322,12 @@ def modbus_calc_properties(config):
async def add_modbus_base_properties(
var, config, sensor_type, lambda_param_type=cg.float_, lambda_return_type=float
):
var: cg.MockObj,
config: ConfigType,
sensor_type: cg.MockObjClass,
lambda_param_type: cg.MockObj = cg.float_,
lambda_return_type: Any = float,
) -> None:
if CONF_CUSTOM_PDU in config:
cg.add(var.set_custom_pdu(config[CONF_CUSTOM_PDU]))
@@ -347,8 +366,11 @@ _CALLBACK_AUTOMATIONS = (
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
async def to_code(config: ConfigType) -> None:
# Await the hub first, so no entity can bind to a controller that doesn't have one yet.
hub = await cg.get_variable(config[modbus.CONF_MODBUS_ID])
var = cg.new_Pvariable(config[CONF_ID], hub, config[CONF_ADDRESS])
await cg.register_component(var, config)
cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES]))
cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES]))
cg.add(
@@ -356,17 +378,22 @@ async def to_code(config):
modbus.command_options_expression(config, direction="read")
)
)
await register_modbus_device(var, config)
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
async def register_modbus_device(var, config):
async def register_modbus_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj:
# Remove before 2027.3.0
_LOGGER.warning(
"'modbus_controller.register_modbus_device' is deprecated, use "
"'modbus.register_modbus_client_device' and set the address on your own "
"class instead. Will be removed in 2027.3.0"
)
cg.add(var.set_address(config[CONF_ADDRESS]))
await cg.register_component(var, config)
return await modbus.register_modbus_client_device(var, config)
def function_code_to_register(function_code):
def function_code_to_register(function_code: str) -> cg.MockObj:
FUNCTION_CODE_TYPE_MAP = {
"read_coils": EntityType.COIL,
"read_discrete_inputs": EntityType.DISCRETE_INPUT,
@@ -10,6 +10,119 @@ static const char *const TAG = "modbus_controller";
void ModbusController::setup() { this->create_polling_commands_(); }
void WriterDevice::warn_write_buffer_deprecated(const LogString *platform, uint16_t address) {
if (this->write_buffer_deprecated_warned_)
return;
this->write_buffer_deprecated_warned_ = true;
ESP_LOGW(TAG,
"Modbus %s (address 0x%X): filling the write_lambda buffer parameter is deprecated; call a write helper / "
"queue_pdu() on the entity (item) instead. The buffer parameter is removed in 2027.3.0",
LOG_STR_ARG(platform), address);
}
bool WriterDevice::send_raw_frame_deprecated(std::span<const uint8_t> frame) {
if (frame.empty())
return false;
return this->parent_->queue_pdu(frame[0], frame.subspan(1), this);
}
void ControllerDevice::set_controller(ModbusController *controller) {
this->controller_ = controller;
this->set_parent(controller->hub());
this->set_address(controller->device_address());
}
// A request whose layout carries no start address (a custom PDU) reports -1; 0 stays a real address.
static int trigger_address(std::span<const uint8_t> request_pdu) {
const auto addr = modbus::helpers::client_pdu_start_address(request_pdu);
return addr.has_value() ? *addr : -1;
}
void ControllerDevice::notify_online_(std::span<const uint8_t> request_pdu) {
if (this->controller_ != nullptr) {
this->controller_->set_online(true, modbus::helpers::pdu_function_code(request_pdu), trigger_address(request_pdu));
}
}
void ControllerDevice::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
this->notify_online_(request_pdu);
}
void ControllerDevice::on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) {
ESP_LOGW(TAG, "Modbus error function code: 0x%X register %d exception: %d",
modbus::helpers::pdu_function_code(request_pdu), trigger_address(request_pdu),
static_cast<uint8_t>(exception_code));
this->notify_online_(request_pdu); // an exception is still a legitimate reply -> device is online
}
void WriterDevice::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
ControllerDevice::on_response(request_pdu, response_pdu);
this->dispatch_response_(request_pdu, response_pdu, std::nullopt);
}
void WriterDevice::on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) {
ControllerDevice::on_error(request_pdu, exception_code);
this->dispatch_response_(request_pdu, {}, exception_code);
}
// Fired once per wire transmission (including hub re-queues from a retry), so the on_command_sent trigger
// reflects when the frame actually went out, not when it was queued.
void ControllerDevice::on_sent(std::span<const uint8_t> request_pdu) {
if (this->controller_ != nullptr) {
this->controller_->command_sent(modbus::helpers::pdu_function_code(request_pdu), trigger_address(request_pdu));
}
}
void ControllerDevice::on_not_sent(std::span<const uint8_t> request_pdu) {
const uint8_t fc = modbus::helpers::pdu_function_code(request_pdu);
const int addr = trigger_address(request_pdu);
// Only the offline teardown reaches this (a supersede retires silently), so the frame is genuinely
// lost; a dropped write was already published optimistically, so surface it.
if (modbus::helpers::is_function_code_write(fc)) {
ESP_LOGW(TAG, "Write not sent: function 0x%X register %d", fc, addr);
} else {
ESP_LOGD(TAG, "Request not sent: function 0x%X register %d", fc, addr);
}
}
bool ControllerDevice::on_no_response(std::span<const uint8_t> request_pdu) {
if (this->controller_ == nullptr)
return false;
this->controller_->increment_non_response_count();
if (this->controller_->can_send())
return true; // the hub re-queues the frame it is holding; on_sent fires again on the retry
this->controller_->set_online(false, modbus::helpers::pdu_function_code(request_pdu), trigger_address(request_pdu));
return false;
}
PollingDevice::PollingDevice(ModbusController &controller, RegisterRange &&range)
: ControllerDevice(&controller), range_(std::move(range)) {}
bool PollingDevice::queue(modbus::CommandOptions options) {
bool accepted;
if (this->range_.custom_pdu != nullptr) {
accepted = this->queue_pdu(std::span<const uint8_t>(*this->range_.custom_pdu), options);
} else {
accepted = this->read_entities(this->range_.register_type, this->range_.start_address, this->range_.register_count,
options);
}
if (accepted) {
ESP_LOGV(TAG, "Poll queued type=%u 0x%X %d", static_cast<uint8_t>(this->range_.register_type),
this->range_.start_address, this->range_.register_count);
}
return accepted;
}
void PollingDevice::on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) {
this->notify_online_(request_pdu);
auto data = modbus::helpers::server_pdu_payload(response_pdu);
for (auto *sensor : this->range_.sensors)
sensor->parse_and_publish(data);
}
// ModbusCommandItem's machinery stays as-is until its removal in 2027.3.0; silence its self-references.
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address,
RegisterRange &&range)
: modbus::ModbusClientDevice(parent, address),
@@ -140,6 +253,8 @@ bool ModbusCommandItem::on_no_response(std::span<const uint8_t> request_pdu) {
return false;
}
#pragma GCC diagnostic pop
void ModbusController::set_online(bool online, int function_code, int register_address) {
if (online) {
this->cmd_non_responses_ = 0;
@@ -161,6 +276,8 @@ void ModbusController::set_online(bool online, int function_code, int register_a
}
}
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
void ModbusController::queue_command(ModbusCommandItem command) {
this->sweep_completed_one_shots_(); // reclaim finished one-shots before adding a new one
// Duplicates are the caller's to manage; the controller only holds the item until its terminal callback.
@@ -195,6 +312,8 @@ void ModbusController::sweep_completed_one_shots_() {
[](const std::unique_ptr<ModbusCommandItem> &item) { return item->pending_removal; });
}
#pragma GCC diagnostic pop
void ModbusController::update() {
this->sweep_completed_one_shots_(); // reclaim one-shots deferred out of their own callbacks
if (this->module_offline_) {
@@ -203,11 +322,11 @@ void ModbusController::update() {
if (offline_retry_due(this->update_counter_, this->module_offline_at_, this->offline_skip_updates_)) {
ESP_LOGV(TAG, "Module offline - retrying");
this->cmd_non_responses_ = 0; // allow the probe through can_send()
for (auto &cmd : this->polling_command_items_) {
for (auto &poll : this->polling_devices_) {
// Probes carry the read-side options too, so a recovering device resumes streaming on the
// probe itself rather than waiting for the next update_interval.
if (!cmd.send(this->read_options_)) {
ESP_LOGD(TAG, "Probe refused by hub for range 0x%X", cmd.register_address());
if (!poll.queue(this->read_options_)) {
ESP_LOGD(TAG, "Probe refused by hub for range 0x%X", poll.register_address());
}
}
} else {
@@ -218,12 +337,12 @@ void ModbusController::update() {
}
if (this->can_send()) {
for (auto &cmd : this->polling_command_items_) {
ESP_LOGVV(TAG, "Updating range 0x%X", cmd.register_address());
for (auto &poll : this->polling_devices_) {
ESP_LOGVV(TAG, "Updating range 0x%X", poll.register_address());
// read_options_ carries the controller's continuous flag (the offline probe above sends it too).
// A refusal is already logged by the hub; note the affected range for controller-level diagnostics.
if (!cmd.send(this->read_options_)) {
ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", cmd.register_address());
if (!poll.queue(this->read_options_)) {
ESP_LOGD(TAG, "Poll refused by hub for range 0x%X", poll.register_address());
}
}
}
@@ -241,6 +360,10 @@ void ModbusController::create_polling_commands_() {
// force_new_range ahead of the rest, then address - so the walk is not purely address-ordered.
// Each keeps the address it was configured with; what is resolved here is its `offset`, the position
// of its data within the response of whichever range it ends up in.
// One range per sensor is a strict upper bound: each walk step closes at most one range, plus one
// closed after the walk. Sized to that bound so no push is ever silently dropped, then handed on by move.
FixedVector<RegisterRange> ranges;
ranges.init(this->sensorset_.size());
RegisterRange r = {};
bool have_range = false;
// Set while the open range belongs to a force_new_range sensor: a range the user asked to keep
@@ -323,7 +446,7 @@ void ModbusController::create_polling_commands_() {
if (!join) {
if (have_range) {
ESP_LOGV(TAG, "Add range 0x%X %d", r.start_address, r.register_count);
this->create_polling_command_(std::move(r));
ranges.push_back(std::move(r));
}
r = {};
range_bytes = curr->get_register_size();
@@ -334,6 +457,8 @@ void ModbusController::create_polling_commands_() {
r.start_address = curr->start_address;
r.register_count = curr->register_count;
r.register_type = curr->register_type;
if (curr->register_type == modbus::EntityType::CUSTOM)
r.custom_pdu = &curr->custom_pdu;
have_range = true;
}
@@ -345,11 +470,13 @@ void ModbusController::create_polling_commands_() {
}
if (have_range) {
ESP_LOGV(TAG, "Add last range 0x%X %d", r.start_address, r.register_count);
this->create_polling_command_(std::move(r));
ranges.push_back(std::move(r));
}
// Staged in a setup-time vector so the device storage can be sized exactly (see polling_devices_).
this->polling_devices_.init(ranges.size());
for (auto &range : ranges) {
this->polling_devices_.emplace_back(*this, std::move(range));
}
// Reclaim growth slack; safe here because nothing has registered with the hub yet (see the
// lifetime note on polling_command_items_).
this->polling_command_items_.shrink_to_fit();
}
void ModbusController::dump_config() {
@@ -368,13 +495,15 @@ void ModbusController::dump_config() {
it->get_register_size());
}
ESP_LOGCONFIG(TAG, "ranges");
for (auto &it : this->polling_command_items_) {
for (auto &it : this->polling_devices_) {
ESP_LOGCONFIG(TAG, " Range type=%u start=0x%X count=%d", static_cast<uint8_t>(it.register_type()),
it.register_address(), it.register_count());
}
#endif
}
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
void ModbusController::on_write_register_response(EntityType register_type, uint16_t start_address,
std::span<const uint8_t> data) {
// A well-formed write ACK echoes address and value, but a truncated PDU yields a short/empty span.
@@ -531,5 +660,6 @@ bool ModbusCommandItem::send(modbus::CommandOptions options) {
}
return accepted;
}
#pragma GCC diagnostic pop
} // namespace esphome::modbus_controller
@@ -4,6 +4,7 @@
#include "esphome/components/modbus/modbus.h"
#include "esphome/components/modbus/modbus_helpers.h"
#include "esphome/core/helpers.h"
#include "esphome/core/automation.h"
#include <list>
@@ -230,12 +231,147 @@ struct RegisterRange {
modbus::EntityType register_type;
uint8_t register_count;
SensorSet sensors; // all sensors of this range
/// A custom range polls this PDU, referenced from the sensor that opened the range.
const SmallInlineBuffer<8> *custom_pdu{nullptr};
};
/// The shared feedback half of a controller-owned hub device: online/offline tracking, retry counting
/// and the on_command_sent trigger all route to the controller from here. The hub base is inherited
/// protected, so a subclass chooses exactly what request API it exposes.
class ControllerDevice : protected modbus::ModbusClientDevice {
public:
// Public: only the owner can reach this instance, so reachability is the access gate.
void set_controller(ModbusController *controller);
protected:
ControllerDevice() = default; // WriterEntity's member is wired later via set_controller()
explicit ControllerDevice(ModbusController *controller) { this->set_controller(controller); }
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override;
void on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) override;
void on_sent(std::span<const uint8_t> request_pdu) override;
void on_not_sent(std::span<const uint8_t> request_pdu) override;
bool on_no_response(std::span<const uint8_t> request_pdu) override;
void notify_online_(std::span<const uint8_t> request_pdu);
/// Write-path state owned by WriterEntity's forwarders, stored here so both bools land in the base's
/// tail padding instead of adding a word to every writer entity. The warn flag leaves in 2027.3.0.
bool dispatched_{false};
bool write_buffer_deprecated_warned_{false};
ModbusController *controller_{nullptr};
};
/// The write side of a ControllerDevice, owned by the writer entities through WriterEntity, whose
/// forwarders re-expose exactly the request API a write lambda may use and record every dispatch.
class WriterDevice final : public ControllerDevice {
public:
using modbus::ModbusClientDevice::clear_tx_queue_for_device;
using modbus::ModbusClientDevice::queue_pdu;
using modbus::ModbusClientDevice::write_multiple_coils;
using modbus::ModbusClientDevice::write_multiple_registers;
using modbus::ModbusClientDevice::write_single_coil;
using modbus::ModbusClientDevice::write_single_register;
/// Send a legacy raw frame (address + function code + data) to the frame's own address.
/// Serves only the deprecated write_lambda buffer path. Remove before 2027.3.0.
bool send_raw_frame_deprecated(std::span<const uint8_t> frame);
bool dispatched() const { return this->dispatched_; }
void set_dispatched() { this->dispatched_ = true; }
void clear_dispatched() { this->dispatched_ = false; }
/// Warn once per entity that filling the write_lambda buffer parameter is deprecated (the entity is now the
/// command - call a write helper / queue_pdu() on `item` instead). The buffer parameter is removed in 2027.3.0.
void warn_write_buffer_deprecated(const LogString *platform, uint16_t address);
protected:
// Only the write side forwards to the typed callbacks (for item->queue_pdu() replies): a poll parses
// its own response, and dispatching its errors would trip the base unhandled-custom-response warning.
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override;
void on_error(std::span<const uint8_t> request_pdu, modbus::ExceptionCode exception_code) override;
};
/// Gives a writer entity the write API of the WriterDevice it owns. The device is a member, not a base:
/// the mixin declares no virtual function, so an entity mixing it in gains no second vtable and all the
/// writer platforms share the single WriterDevice vtable instead of each emitting its own copy.
/// The forwarders keep `item->write_*()` working unchanged inside a write_lambda, and record every
/// dispatch, so the write path can tell "the lambda sent it itself" from "use the default write".
class WriterEntity {
public:
/// Whether the lambda called a request helper since the last clear_dispatched_(). Deliberately records
/// the call, not the hub's accept/refuse: a refused lambda write must not fall through to the default write.
bool dispatched() const { return this->device_.dispatched(); }
bool write_single_register(uint16_t address, uint16_t value) {
this->device_.set_dispatched();
return this->device_.write_single_register(address, value);
}
bool write_single_coil(uint16_t address, bool value) {
this->device_.set_dispatched();
return this->device_.write_single_coil(address, value);
}
bool write_multiple_registers(uint16_t address, std::span<const uint16_t> values) {
this->device_.set_dispatched();
return this->device_.write_multiple_registers(address, values);
}
bool write_multiple_coils(uint16_t address, std::span<const bool> values) {
this->device_.set_dispatched();
return this->device_.write_multiple_coils(address, values);
}
bool write_multiple_coils(uint16_t address, modbus::PackedBits bits) {
this->device_.set_dispatched();
return this->device_.write_multiple_coils(address, bits);
}
bool queue_pdu(std::span<const uint8_t> pdu, modbus::CommandOptions options = {}) {
this->device_.set_dispatched();
return this->device_.queue_pdu(pdu, options);
}
void clear_tx_queue_for_device() { this->device_.clear_tx_queue_for_device(); }
protected:
bool send_raw_frame_deprecated_(std::span<const uint8_t> frame) {
this->device_.set_dispatched();
return this->device_.send_raw_frame_deprecated(frame);
}
void set_controller_(ModbusController *controller) { this->device_.set_controller(controller); }
void clear_dispatched_() { this->device_.clear_dispatched(); }
void warn_write_buffer_deprecated_(const LogString *platform, uint16_t address) {
this->device_.warn_write_buffer_deprecated(platform, address);
}
private:
// Private so a derived entity cannot reach the device except through the recording forwarders above.
WriterDevice device_;
};
/// A persistent hub device that polls one register range - the read-side mirror of WriterDevice.
/// Owned by the controller, one per range; the response is parsed straight to the range's sensors.
class PollingDevice final : public ControllerDevice {
public:
PollingDevice(ModbusController &controller, RegisterRange &&range);
/// Queue this range's read (or its sensor's custom PDU) on the hub. False = refused, no callback follows.
bool queue(modbus::CommandOptions options = {});
uint16_t register_address() const { return this->range_.start_address; }
uint16_t register_count() const { return this->range_.register_count; }
EntityType register_type() const { return this->range_.register_type; }
protected:
void on_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu) override;
RegisterRange range_;
};
/// A single modbus command. Each command is its own ModbusClientDevice: it sends its frame to the hub
/// and the hub routes the response back to this object's on_modbus_* callbacks, so the controller no
/// longer has to match responses to a FIFO queue.
class ModbusCommandItem : public modbus::ModbusClientDevice {
// The deprecated class references other deprecated names. Remove before 2027.3.0.
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
class ESPDEPRECATED(
"One-shot writes go through the entity write helpers (WriterDevice) or the modbus_client actions, and "
"polling runs through PollingDevice. Removed in 2027.3.0",
"2026.9.0") ModbusCommandItem : public modbus::ModbusClientDevice {
public:
/// Empty command with no controller connection (kept for source compatibility with value-type usage).
ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address)
@@ -380,6 +516,7 @@ class ModbusCommandItem : public modbus::ModbusClientDevice {
const SmallInlineBuffer<8> *custom_pdu_{nullptr};
ModbusController *controller_{nullptr};
};
#pragma GCC diagnostic pop
/// Whether an offline probe is due this update cycle: every offline_skip_updates + 1 cycles,
/// anchored at the cycle the device went offline. Pure so the cadence (including update_counter
@@ -398,30 +535,39 @@ inline bool offline_retry_due(uint16_t update_counter, uint16_t module_offline_a
class ModbusController final : public PollingComponent {
public:
// The controller is not itself a modbus device - its commands and writer entities send as their own
// devices, built against this hub + address.
ModbusController(modbus::ModbusClientHub *hub, uint8_t address) : hub_(hub), address_(address) {}
void dump_config() override;
// No loop() override: the hub owns transmit/receive timing and each command routes its own
// response, so the controller never joins the looping components at all.
void setup() override;
void update() override;
// The controller is not itself a modbus device - its commands and writer entities send as their own
// devices. It only owns the hub + address so those senders can be built against them.
void set_parent(modbus::ModbusClientHub *hub) { this->hub_ = hub; }
void set_address(uint8_t address) { this->address_ = address; }
/// The hub and modbus address this controller talks to. Used to build commands/entities that send as
/// their own device.
modbus::ModbusClientHub *hub() const { return this->hub_; }
uint8_t device_address() const { return this->address_; }
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
/// Queues a one-shot modbus command (writes, custom commands); taken by value, so std::move to avoid a copy.
/// Remove with ModbusCommandItem before 2027.3.0.
ESPDEPRECATED("Use the entity write helpers or the modbus_client actions instead. Removed in 2027.3.0", "2026.9.0")
void queue_command(ModbusCommandItem command);
/// Flags a finished one-shot command for removal. Called by the command as the last action of its own
/// callback, so the item is not destroyed here (send() and the hub still touch it) but swept later.
/// Remove with ModbusCommandItem before 2027.3.0.
ESPDEPRECATED("Serves only ModbusCommandItem's own callbacks. Removed in 2027.3.0", "2026.9.0")
void unqueue_command(const ModbusCommandItem *command);
#pragma GCC diagnostic pop
/// Registers a sensor with the controller. Called by esphomes code generator
void add_sensor_item(SensorItem *item) { sensorset_.insert(item); }
/// Handles a write command acknowledgement (used by write command on_data_func handlers).
/// Remove with ModbusCommandItem before 2027.3.0.
ESPDEPRECATED("Write acknowledgements are handled by the writing entity's own device. Removed in 2027.3.0",
"2026.9.0")
void on_write_register_response(EntityType register_type, uint16_t start_address, std::span<const uint8_t> data);
/// Update the online/offline state after a response or a run of timeouts, firing the callbacks.
void set_online(bool online, int function_code, int register_address);
@@ -460,32 +606,22 @@ class ModbusController final : public PollingComponent {
const modbus::CommandOptions &read_options() const { return this->read_options_; }
protected:
/// parse sensormap_ and create range of sequential addresses
/// Group the registered sensors into contiguous ranges and create one polling command per range.
/// Group the registered sensors into contiguous ranges and create one PollingDevice per range.
void create_polling_commands_();
/// build one persistent polling command from a range and add it to polling_command_items_
void create_polling_command_(RegisterRange &&range) {
// A custom range polls the first sensor's custom_pdu (referenced, not copied); the sensor constructor
// decodes the real function code. The response still dispatches to every sensor in the range.
if (range.register_type == EntityType::CUSTOM && !range.sensors.empty()) {
auto &cmd = this->polling_command_items_.emplace_back(*this, this->hub_, this->address_, *range.sensors.begin());
cmd.sensors = std::move(range.sensors);
} else {
this->polling_command_items_.emplace_back(*this, this->hub_, this->address_, std::move(range));
}
}
/// The hub this controller's commands/entities send through, and the modbus address they target.
modbus::ModbusClientHub *hub_{nullptr};
uint8_t address_{0};
/// Collection of all sensors for this component
SensorSet sensorset_;
/// One persistent command per register range, each its own ModbusClientDevice. Built once in setup()
/// (create_polling_commands_ feeds each range straight in; the vector may reallocate as it grows, which
/// is safe because no command has registered with the hub yet) and never appended to afterward, so the
/// hub's device pointers stay valid once commands start sending.
std::vector<ModbusCommandItem> polling_command_items_{};
/// One persistent PollingDevice per register range. Built once in setup() with the exact count
/// (FixedVector never reallocates), so the hub's device pointers stay valid once polls start sending.
FixedVector<PollingDevice> polling_devices_;
/// Dynamically queued one-shot commands (writes, custom commands). std::list keeps stable addresses.
/// Remove with ModbusCommandItem before 2027.3.0.
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
std::list<std::unique_ptr<ModbusCommandItem>> one_shot_command_items_;
#pragma GCC diagnostic pop
/// Erases one-shot commands flagged by unqueue_command(). Safe even when reached from inside a hub
/// callback (via an on_online/on_offline/on_command_sent automation that queues a command): the
/// destructor detaches via clear_tx_queue_for_device(), which the hub allows from callbacks, and the
@@ -3,6 +3,7 @@ from esphome.components import number
from esphome.components.modbus.helpers import (
MODBUS_WRITE_REGISTER_TYPE,
SENSOR_VALUE_TYPE,
RegisterValues,
)
import esphome.config_validation as cv
from esphome.const import (
@@ -13,6 +14,7 @@ from esphome.const import (
CONF_MULTIPLY,
CONF_STEP,
)
from esphome.types import ConfigType
from .. import (
ModbusItemBaseSchema,
@@ -43,7 +45,7 @@ ModbusNumber = modbus_controller_ns.class_(
)
def validate_min_max(config):
def validate_min_max(config: ConfigType) -> ConfigType:
if config[CONF_MAX_VALUE] <= config[CONF_MIN_VALUE]:
raise cv.Invalid("max_value must be greater than min_value")
if config[CONF_MIN_VALUE] < -16777215:
@@ -53,7 +55,7 @@ def validate_min_max(config):
return config
def validate_modbus_number(config):
def validate_modbus_number(config: ConfigType) -> ConfigType:
# custom_command is the deprecated alias for custom_pdu (migrated later in final validate).
has_custom = CONF_CUSTOM_PDU in config or CONF_CUSTOM_COMMAND in config
if not has_custom and CONF_ADDRESS not in config:
@@ -89,7 +91,7 @@ CONFIG_SCHEMA = cv.All(
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
async def to_code(config):
async def to_code(config: ConfigType) -> None:
byte_offset, reg_count = modbus_calc_properties(config)
var = cg.new_Pvariable(
config[CONF_ID],
@@ -124,7 +126,7 @@ async def to_code(config):
[
(ModbusNumber.operator("ptr"), "item"),
(cg.float_, "x"),
(cg.std_vector.template(cg.uint16).operator("ref"), "payload"),
(RegisterValues.operator("ref"), "payload"),
],
return_type=cg.optional.template(float),
)
@@ -1,4 +1,3 @@
#include <vector>
#include "modbus_number.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
@@ -29,62 +28,73 @@ void ModbusNumber::parse_and_publish(std::span<const uint8_t> data) {
}
void ModbusNumber::control(float value) {
optional<ModbusCommandItem> write_cmd;
std::vector<uint16_t> data;
this->clear_dispatched_();
// A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one)
// so a rapidly-changing value writes the latest, not every intermediate.
this->clear_tx_queue_for_device();
modbus::RegisterValues data;
float write_value = value;
// Is there are lambda configured?
if (this->write_transform_func_.has_value()) {
// data is passed by reference
// the lambda can fill the empty vector directly
// in that case the return value is ignored
// The lambda may drive the write itself via item->write_*(), override the value (return a value), or
// (deprecated) fill `data` with the register words to write.
auto val = (*this->write_transform_func_)(this, value, data);
if (val.has_value()) {
ESP_LOGV(TAG, "Value overwritten by lambda");
write_value = val.value();
} else {
if (this->dispatched()) {
this->publish_state(value);
return;
}
if (!data.empty()) {
// Deprecated buffer path (frozen): the lambda filled a legacy raw frame as words; pack it big-endian.
this->warn_write_buffer_deprecated_(LOG_STR("number"), this->start_address);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_uint16_size(MODBUS_NUMBER_MAX_LOG_REGISTERS)];
#endif
ESP_LOGV(TAG, "Modbus Number write raw: %s",
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
// Sized to hold RegisterValues at capacity, so a full buffer can never truncate into a valid frame.
StaticVector<uint8_t, modbus::MAX_NUM_OF_REGISTERS_TO_READ * 2> bytes;
for (uint16_t word : data) {
const auto word_bytes = decode_value(word);
bytes.push_back(word_bytes[0]);
bytes.push_back(word_bytes[1]);
}
if (!this->send_raw_frame_deprecated_(std::span<const uint8_t>(bytes.data(), bytes.size()))) {
ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str());
return;
}
this->publish_state(value);
return;
}
if (!val.has_value()) {
ESP_LOGV(TAG, "Communication handled by lambda - exiting control");
return;
}
ESP_LOGV(TAG, "Value overwritten by lambda");
write_value = val.value();
} else {
write_value = this->multiply_by_ * write_value;
}
if (!data.empty()) {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_uint16_size(MODBUS_NUMBER_MAX_LOG_REGISTERS)];
#endif
ESP_LOGV(TAG, "Modbus Number write raw: %s",
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
write_cmd.emplace(ModbusCommandItem::create_custom_command(
this->parent_, data,
[this](modbus::EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
this->parent_->on_write_register_response(register_type, this->start_address, data);
}));
} else {
std::vector<uint16_t> payload;
modbus::helpers::float_to_payload(payload, write_value, this->sensor_value_type);
modbus::helpers::float_to_payload(data, write_value, this->sensor_value_type);
// float_to_payload() appends nothing for RAW, so an empty payload must be caught before data[0] below.
if (data.empty()) {
ESP_LOGW(TAG, "No payload was created for updating number");
return;
}
ESP_LOGD(TAG,
"Updating register: connected Sensor=%s start address=0x%X register count=%d new value=%.02f (val=%.02f)",
this->get_name().c_str(), this->start_address, this->register_count, value, write_value);
ESP_LOGD(TAG,
"Updating register: connected Sensor=%s start address=0x%X register count=%d new value=%.02f (val=%.02f)",
this->get_name().c_str(), this->start_address, this->register_count, value, write_value);
// Create and send the write command
if (this->register_count == 1 && !this->use_write_multiple_) {
write_cmd.emplace(
ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(), payload[0]));
} else {
write_cmd.emplace(ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(),
this->register_count, payload));
}
// publish new value
write_cmd->on_data_func = [this, value](modbus::EntityType register_type, uint16_t start_address,
std::span<const uint8_t> data) {
// gets called when the write command is ack'd from the device
this->parent_->on_write_register_response(register_type, start_address, data);
this->publish_state(value);
};
bool queued;
if (this->register_count == 1 && !this->use_write_multiple_) {
queued = this->write_single_register(this->write_address(), data[0]);
} else {
queued = this->write_multiple_registers(this->write_address(), data);
}
if (!queued) {
ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str());
return;
}
this->parent_->queue_command(std::move(*write_cmd));
this->publish_state(value);
}
void ModbusNumber::dump_config() { LOG_NUMBER(TAG, "Modbus Number", this); }
@@ -10,7 +10,7 @@ namespace esphome::modbus_controller {
using value_to_data_t = std::function<float>(float);
class ModbusNumber final : public number::Number, public Component, public SensorItem {
class ModbusNumber final : public number::Number, public Component, public SensorItem, public WriterEntity {
public:
ModbusNumber(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
SensorValueType value_type, int register_count, bool force_new_range) {
@@ -26,11 +26,11 @@ class ModbusNumber final : public number::Number, public Component, public Senso
void dump_config() override;
void parse_and_publish(std::span<const uint8_t> data) override;
float get_setup_priority() const override { return setup_priority::HARDWARE; }
void set_parent(ModbusController *parent) { this->parent_ = parent; }
void set_parent(ModbusController *parent) { this->set_controller_(parent); }
void set_write_multiply(float factor) { this->multiply_by_ = factor; }
using transform_func_t = optional<float> (*)(ModbusNumber *, float, std::span<const uint8_t>);
using write_transform_func_t = optional<float> (*)(ModbusNumber *, float, std::vector<uint16_t> &);
using write_transform_func_t = optional<float> (*)(ModbusNumber *, float, modbus::RegisterValues &);
void set_template(transform_func_t f) { this->transform_func_ = f; }
void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; }
void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; }
@@ -39,7 +39,6 @@ class ModbusNumber final : public number::Number, public Component, public Senso
void control(float value) override;
optional<transform_func_t> transform_func_{nullopt};
optional<write_transform_func_t> write_transform_func_{nullopt};
ModbusController *parent_{nullptr};
float multiply_by_{1.0};
bool use_write_multiple_{false};
};
@@ -1,14 +1,20 @@
import esphome.codegen as cg
from esphome.components import output
from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE
from esphome.components.modbus.helpers import (
SENSOR_VALUE_TYPE,
PduBuffer,
RegisterValues,
)
import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_MULTIPLY
from esphome.types import ConfigType
from .. import (
ModbusItemBaseSchema,
SensorItem,
modbus_calc_properties,
modbus_controller_ns,
reject_odd_holding_write_offset,
)
from ..const import (
CONF_CUSTOM_COMMAND,
@@ -48,23 +54,26 @@ CONFIG_SCHEMA = cv.typed_schema(
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
}
),
"holding": output.FLOAT_OUTPUT_SCHEMA.extend(ModbusItemBaseSchema).extend(
{
cv.GenerateID(): cv.declare_id(ModbusFloatOutput),
cv.Required(CONF_ADDRESS): cv.positive_int,
cv.Optional(CONF_CUSTOM_PDU): cv.invalid(
"custom_pdu is not supported for outputs; use a write_lambda instead"
),
cv.Optional(CONF_CUSTOM_COMMAND): cv.invalid(
"custom_command is not supported for outputs; use a write_lambda instead"
),
cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(
SENSOR_VALUE_TYPE
),
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_,
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
}
"holding": cv.All(
output.FLOAT_OUTPUT_SCHEMA.extend(ModbusItemBaseSchema).extend(
{
cv.GenerateID(): cv.declare_id(ModbusFloatOutput),
cv.Required(CONF_ADDRESS): cv.positive_int,
cv.Optional(CONF_CUSTOM_PDU): cv.invalid(
"custom_pdu is not supported for outputs; use a write_lambda instead"
),
cv.Optional(CONF_CUSTOM_COMMAND): cv.invalid(
"custom_command is not supported for outputs; use a write_lambda instead"
),
cv.Optional(CONF_VALUE_TYPE, default="U_WORD"): cv.enum(
SENSOR_VALUE_TYPE
),
cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda,
cv.Optional(CONF_MULTIPLY, default=1.0): cv.float_,
cv.Optional(CONF_USE_WRITE_MULTIPLE, default=False): cv.boolean,
}
),
reject_odd_holding_write_offset,
),
},
lower=True,
@@ -73,7 +82,7 @@ CONFIG_SCHEMA = cv.typed_schema(
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
byte_offset, reg_count = modbus_calc_properties(config)
# Binary Output
write_template = None
@@ -89,7 +98,7 @@ async def to_code(config):
[
(ModbusBinaryOutput.operator("ptr"), "item"),
(cg.bool_, "x"),
(cg.std_vector.template(cg.uint8).operator("ref"), "payload"),
(PduBuffer.operator("ref"), "payload"),
],
return_type=cg.optional.template(bool),
)
@@ -109,7 +118,7 @@ async def to_code(config):
[
(ModbusFloatOutput.operator("ptr"), "item"),
(cg.float_, "x"),
(cg.std_vector.template(cg.uint16).operator("ref"), "payload"),
(RegisterValues.operator("ref"), "payload"),
],
return_type=cg.optional.template(float),
)
@@ -2,6 +2,8 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <array>
namespace esphome::modbus_controller {
static const char *const TAG = "modbus_controller.output";
@@ -13,25 +15,33 @@ static constexpr size_t MODBUS_OUTPUT_MAX_LOG_BYTES = 64;
*
*/
void ModbusFloatOutput::write_state(float value) {
std::vector<uint16_t> data;
this->clear_dispatched_();
// A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one)
// so a rapidly-changing value writes the latest, not every intermediate.
this->clear_tx_queue_for_device();
modbus::RegisterValues data;
auto original_value = value;
// Is there are lambda configured?
if (this->write_transform_func_.has_value()) {
// data is passed by reference
// the lambda can fill the empty vector directly
// in that case the return value is ignored
// The lambda may drive the write itself via item->write_*(), override the value (return a value), or
// (deprecated) fill `data` with the register words to write.
auto val = (*this->write_transform_func_)(this, value, data);
if (val.has_value()) {
ESP_LOGV(TAG, "Value overwritten by lambda");
value = val.value();
} else {
if (this->dispatched()) {
return;
}
if (!data.empty()) {
// Deprecated buffer path (frozen): the lambda supplied the register words for the shared write below.
this->warn_write_buffer_deprecated_(LOG_STR("float output"), this->start_address);
} else if (!val.has_value()) {
ESP_LOGV(TAG, "Communication handled by lambda - exiting control");
return;
} else {
ESP_LOGV(TAG, "Value overwritten by lambda");
value = val.value();
}
} else {
value = this->multiply_by_ * value;
}
// lambda didn't set payload
if (data.empty()) {
modbus::helpers::float_to_payload(data, value, this->sensor_value_type);
}
@@ -57,16 +67,15 @@ void ModbusFloatOutput::write_state(float value) {
return;
}
// Create and send the write command
optional<ModbusCommandItem> write_cmd;
bool queued;
if (this->register_count == 1 && !this->use_write_multiple_) {
write_cmd.emplace(
ModbusCommandItem::create_write_single_command(this->parent_, this->start_address + this->offset, data[0]));
queued = this->write_single_register(this->write_address(), data[0]);
} else {
write_cmd.emplace(ModbusCommandItem::create_write_multiple_command(
this->parent_, this->start_address + this->offset, data.size(), data));
queued = this->write_multiple_registers(this->write_address(), data);
}
if (!queued) {
ESP_LOGW(TAG, "Modbus output write (address 0x%X) was refused by the hub", this->write_address());
}
this->parent_->queue_command(std::move(*write_cmd));
}
void ModbusFloatOutput::dump_config() {
@@ -81,50 +90,52 @@ void ModbusFloatOutput::dump_config() {
// ModbusBinaryOutput
void ModbusBinaryOutput::write_state(bool state) {
// This will be called every time the user requests a state change.
optional<ModbusCommandItem> cmd;
std::vector<uint8_t> data;
this->clear_dispatched_();
// A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one)
// so a rapidly-changing value writes the latest, not every intermediate.
this->clear_tx_queue_for_device();
modbus::helpers::PduBuffer data;
// Is there are lambda configured?
if (this->write_transform_func_.has_value()) {
// data is passed by reference
// the lambda can fill the empty vector directly
// in that case the return value is ignored
// The lambda may drive the write itself via item->write_*/queue_pdu(), override the value (return a value),
// or (deprecated) fill `data` with a custom PDU.
auto val = (*this->write_transform_func_)(this, state, data);
if (val.has_value()) {
ESP_LOGV(TAG, "Value overwritten by lambda");
state = val.value();
} else {
if (this->dispatched()) {
return;
}
if (!data.empty()) {
this->warn_write_buffer_deprecated_(LOG_STR("binary output"), this->start_address);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_OUTPUT_MAX_LOG_BYTES)];
#endif
ESP_LOGV(TAG, "Modbus binary output write raw: %s",
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
// The lambda filled a legacy raw frame (device address + function code + data).
if (!this->send_raw_frame_deprecated_(data)) {
ESP_LOGW(TAG, "Modbus output write (address 0x%X) was refused by the hub", this->write_address());
}
return;
}
if (!val.has_value()) {
ESP_LOGV(TAG, "Communication handled by lambda - exiting control");
return;
}
ESP_LOGV(TAG, "Value overwritten by lambda");
state = val.value();
}
if (!data.empty()) {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_OUTPUT_MAX_LOG_BYTES)];
#endif
ESP_LOGV(TAG, "Modbus binary output write raw: %s",
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
cmd.emplace(ModbusCommandItem::create_custom_command(
this->parent_, data,
[this](modbus::EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
this->parent_->on_write_register_response(register_type, this->start_address, data);
}));
ESP_LOGV(TAG, "Write new state: value is %s, type is %d address = %X, offset = %x", ONOFF(state),
(int) this->register_type, this->start_address, this->offset);
// offset for coil and discrete inputs is the coil/register number not bytes
bool queued;
if (this->use_write_multiple_) {
std::array<bool, 1> states{state};
queued = this->write_multiple_coils(this->write_address(), states);
} else {
ESP_LOGV(TAG, "Write new state: value is %s, type is %d address = %X, offset = %x", ONOFF(state),
(int) this->register_type, this->start_address, this->offset);
// offset for coil and discrete inputs is the coil/register number not bytes
if (this->use_write_multiple_) {
std::vector<bool> states{state};
cmd.emplace(
ModbusCommandItem::create_write_multiple_coils(this->parent_, this->start_address + this->offset, states));
} else {
cmd.emplace(
ModbusCommandItem::create_write_single_coil(this->parent_, this->start_address + this->offset, state));
}
queued = this->write_single_coil(this->write_address(), state);
}
if (!queued) {
ESP_LOGW(TAG, "Modbus output write (address 0x%X) was refused by the hub", this->write_address());
}
this->parent_->queue_command(std::move(*cmd));
}
void ModbusBinaryOutput::dump_config() {
@@ -8,26 +8,25 @@
namespace esphome::modbus_controller {
class ModbusFloatOutput final : public output::FloatOutput, public Component, public SensorItem {
class ModbusFloatOutput final : public output::FloatOutput, public Component, public SensorItem, public WriterEntity {
public:
ModbusFloatOutput(uint16_t start_address, uint8_t offset, SensorValueType value_type, int register_count) {
this->register_type = modbus::EntityType::HOLDING;
this->set_address(start_address);
this->set_offset_from_start_address(offset);
// A byte offset folds into the address as whole registers; odd offsets are rejected at validation.
this->set_address(start_address + offset / 2);
this->set_offset_from_start_address(0);
this->bitmask = 0xFFFFFFFF;
this->register_count = register_count;
this->sensor_value_type = value_type;
this->set_address(this->start_address + offset);
this->set_offset_from_start_address(0);
}
void dump_config() override;
void set_parent(ModbusController *parent) { this->parent_ = parent; }
void set_parent(ModbusController *parent) { this->set_controller_(parent); }
void set_write_multiply(float factor) { this->multiply_by_ = factor; }
// Do nothing
void parse_and_publish(std::span<const uint8_t> data) override{};
using write_transform_func_t = optional<float> (*)(ModbusFloatOutput *, float, std::vector<uint16_t> &);
using write_transform_func_t = optional<float> (*)(ModbusFloatOutput *, float, modbus::RegisterValues &);
void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; }
void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; }
@@ -35,29 +34,28 @@ class ModbusFloatOutput final : public output::FloatOutput, public Component, pu
void write_state(float value) override;
optional<write_transform_func_t> write_transform_func_{nullopt};
ModbusController *parent_{nullptr};
float multiply_by_{1.0};
bool use_write_multiple_{false};
};
class ModbusBinaryOutput final : public output::BinaryOutput, public Component, public SensorItem {
class ModbusBinaryOutput final : public output::BinaryOutput, public Component, public SensorItem, public WriterEntity {
public:
ModbusBinaryOutput(uint16_t start_address, uint8_t offset) {
this->register_type = modbus::EntityType::COIL;
this->set_address(start_address);
// A coil offset is a coil count; fold it into the address.
this->set_address(start_address + offset);
this->bitmask = 0xFFFFFFFF;
this->sensor_value_type = SensorValueType::BIT;
this->register_count = 1;
this->set_address(this->start_address + offset);
this->set_offset_from_start_address(0);
}
void dump_config() override;
void set_parent(ModbusController *parent) { this->parent_ = parent; }
void set_parent(ModbusController *parent) { this->set_controller_(parent); }
// Do nothing
void parse_and_publish(std::span<const uint8_t> data) override{};
using write_transform_func_t = optional<bool> (*)(ModbusBinaryOutput *, bool, std::vector<uint8_t> &);
using write_transform_func_t = optional<bool> (*)(ModbusBinaryOutput *, bool, modbus::helpers::PduBuffer &);
void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; }
void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; }
@@ -65,7 +63,6 @@ class ModbusBinaryOutput final : public output::BinaryOutput, public Component,
void write_state(bool state) override;
optional<write_transform_func_t> write_transform_func_{nullopt};
ModbusController *parent_{nullptr};
bool use_write_multiple_{false};
};
@@ -1,8 +1,16 @@
from collections.abc import Callable
from typing import Any
import esphome.codegen as cg
from esphome.components import select
from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE, TYPE_REGISTER_MAP
from esphome.components.modbus.helpers import (
SENSOR_VALUE_TYPE,
TYPE_REGISTER_MAP,
RegisterValues,
)
import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC
from esphome.types import ConfigType
from .. import (
ModbusController,
@@ -29,8 +37,8 @@ ModbusSelect = modbus_controller_ns.class_(
)
def ensure_option_map():
def validator(value):
def ensure_option_map() -> Callable[[Any], dict[str, int]]:
def validator(value: Any) -> dict[str, int]:
cv.check_not_templatable(value)
option = cv.All(cv.string_strict)
mapping = cv.All(cv.int_range(-(2**63), 2**63 - 1))
@@ -47,7 +55,7 @@ def ensure_option_map():
return validator
def register_count_value_type_min(value):
def register_count_value_type_min(value: ConfigType) -> ConfigType:
reg_count = value.get(CONF_REGISTER_COUNT)
if reg_count is not None:
value_type = value[CONF_VALUE_TYPE]
@@ -87,7 +95,7 @@ CONFIG_SCHEMA = cv.All(
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
value_type = config[CONF_VALUE_TYPE]
reg_count = config.get(CONF_REGISTER_COUNT)
if reg_count is None:
@@ -132,7 +140,7 @@ async def to_code(config):
(ModbusSelect.operator("const_ptr"), "item"),
(cg.std_string.operator("const").operator("ref"), "x"),
(cg.int64, "value"),
(cg.std_vector.template(cg.uint16).operator("ref"), "payload"),
(RegisterValues.operator("ref"), "payload"),
],
return_type=cg.optional.template(cg.int64),
)
@@ -46,35 +46,43 @@ void ModbusSelect::control(size_t index) {
const char *option = this->option_at(index);
ESP_LOGD(TAG, "Found value %lld for option '%s'", *mapval, option);
std::vector<uint16_t> data;
this->clear_dispatched_();
// A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one)
// so a rapidly-changing value writes the latest, not every intermediate.
this->clear_tx_queue_for_device();
modbus::RegisterValues data;
if (this->write_transform_func_.has_value()) {
// Transform func requires string parameter for backward compatibility
// The lambda may drive the write itself via item->write_*(), override the mapping value (return a value),
// or (deprecated) fill `data` with the register words to write. Transform func requires string parameter
// for backward compatibility.
auto val = (*this->write_transform_func_)(this, std::string(option), *mapval, data);
if (val.has_value()) {
mapval = val;
ESP_LOGV(TAG, "write_lambda returned mapping value %lld", *mapval);
} else {
if (this->dispatched()) {
if (this->optimistic_)
this->publish_state(index);
return;
}
if (!data.empty()) {
// Deprecated buffer path (frozen): the lambda supplied the register words for the shared write below.
this->warn_write_buffer_deprecated_(LOG_STR("select"), this->start_address);
} else if (!val.has_value()) {
ESP_LOGD(TAG, "Communication handled by write_lambda - exiting control");
return;
} else {
mapval = val;
ESP_LOGV(TAG, "write_lambda returned mapping value %lld", *mapval);
}
}
if (data.empty()) {
modbus::helpers::number_to_payload(data, *mapval, this->sensor_value_type);
} else {
ESP_LOGV(TAG, "Using payload from write lambda");
// number_to_payload() appends nothing for RAW.
if (data.empty()) {
ESP_LOGW(TAG, "No payload was created for updating select");
return;
}
}
if (data.empty()) {
ESP_LOGW(TAG, "No payload was created for updating select");
return;
}
// The command declares register_count registers, so the payload must be exactly that many words:
// a value type narrower than the declared width is zero-padded (the config deliberately allows
// register_count larger than the value type). Anything else would put a byte count on the wire
// that disagrees with the quantity field, which conformant devices reject.
// register_count declares the READ range width - it may pull neighboring registers into one poll -
// so a write covers exactly the registers the value occupies: the quantity comes from the payload,
// never from register_count (padding to it would zero registers the user only declared for reading).
@@ -86,16 +94,17 @@ void ModbusSelect::control(size_t index) {
}
const uint16_t write_address = this->write_address();
optional<ModbusCommandItem> write_cmd;
bool queued;
if ((this->register_count == 1) && (!this->use_write_multiple_)) {
write_cmd.emplace(ModbusCommandItem::create_write_single_command(this->parent_, write_address, data[0]));
queued = this->write_single_register(write_address, data[0]);
} else {
write_cmd.emplace(
ModbusCommandItem::create_write_multiple_command(this->parent_, write_address, data.size(), data));
queued = this->write_multiple_registers(write_address, data);
}
this->parent_->queue_command(std::move(*write_cmd));
if (!queued) {
ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str());
return;
}
if (this->optimistic_)
this->publish_state(index);
}
@@ -9,7 +9,7 @@
namespace esphome::modbus_controller {
class ModbusSelect final : public Component, public select::Select, public SensorItem {
class ModbusSelect final : public Component, public select::Select, public SensorItem, public WriterEntity {
public:
ModbusSelect(SensorValueType sensor_value_type, uint16_t start_address, uint8_t register_count, bool force_new_range,
std::vector<int64_t> mapping) {
@@ -26,9 +26,9 @@ class ModbusSelect final : public Component, public select::Select, public Senso
using transform_func_t = optional<std::string> (*)(ModbusSelect *const, int64_t, std::span<const uint8_t>);
using write_transform_func_t = optional<int64_t> (*)(ModbusSelect *const, const std::string &, int64_t,
std::vector<uint16_t> &);
modbus::RegisterValues &);
void set_parent(ModbusController *const parent) { this->parent_ = parent; }
void set_parent(ModbusController *const parent) { this->set_controller_(parent); }
void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; }
void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; }
void set_template(transform_func_t f) { this->transform_func_ = f; }
@@ -40,7 +40,6 @@ class ModbusSelect final : public Component, public select::Select, public Senso
protected:
std::vector<int64_t> mapping_{};
ModbusController *parent_{nullptr};
bool use_write_multiple_{false};
bool optimistic_{false};
optional<transform_func_t> transform_func_{nullopt};
@@ -1,8 +1,9 @@
import esphome.codegen as cg
from esphome.components import switch
from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE
from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE, PduBuffer
import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS, CONF_ASSUMED_STATE, CONF_ID
from esphome.types import ConfigType
from .. import (
ModbusItemBaseSchema,
@@ -10,6 +11,7 @@ from .. import (
add_modbus_base_properties,
modbus_calc_properties,
modbus_controller_ns,
reject_odd_holding_write_offset,
validate_custom_pdu_item,
validate_modbus_register,
)
@@ -30,6 +32,14 @@ ModbusSwitch = modbus_controller_ns.class_(
"ModbusSwitch", cg.Component, switch.Switch, SensorItem
)
def _validate_holding_offset(config: ConfigType) -> ConfigType:
# Only a holding-register switch folds the byte offset into a 16-bit register write.
if config.get(CONF_REGISTER_TYPE) == "holding":
reject_odd_holding_write_offset(config)
return config
CONFIG_SCHEMA = cv.All(
switch.switch_schema(ModbusSwitch, default_restore_mode="DISABLED")
.extend(cv.COMPONENT_SCHEMA)
@@ -43,12 +53,13 @@ CONFIG_SCHEMA = cv.All(
}
),
validate_modbus_register,
_validate_holding_offset,
)
FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item
async def to_code(config):
async def to_code(config: ConfigType) -> None:
byte_offset, _ = modbus_calc_properties(config)
var = cg.new_Pvariable(
config[CONF_ID],
@@ -74,7 +85,7 @@ async def to_code(config):
[
(ModbusSwitch.operator("ptr"), "item"),
(cg.bool_, "x"),
(cg.std_vector.template(cg.uint8).operator("ref"), "payload"),
(PduBuffer.operator("ref"), "payload"),
],
return_type=cg.optional.template(bool),
)
@@ -3,6 +3,8 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <array>
namespace esphome::modbus_controller {
static const char *const TAG = "modbus_controller.switch";
@@ -58,57 +60,68 @@ void ModbusSwitch::parse_and_publish(std::span<const uint8_t> data) {
}
void ModbusSwitch::write_state(bool state) {
// This will be called every time the user requests a state change.
optional<ModbusCommandItem> cmd;
std::vector<uint8_t> data;
// Is there are lambda configured?
this->clear_dispatched_();
// A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one)
// so a rapidly-changing value writes the latest, not every intermediate.
this->clear_tx_queue_for_device();
modbus::helpers::PduBuffer data;
bool write_value = state;
if (this->write_transform_func_.has_value()) {
// data is passed by reference
// the lambda can fill the empty vector directly
// in that case the return value is ignored
// The lambda may drive the write itself via item->write_*/queue_pdu(), override the written value (return a
// value), or (deprecated) fill `data` with a custom PDU.
auto val = (*this->write_transform_func_)(this, state, data);
if (val.has_value()) {
ESP_LOGV(TAG, "Value overwritten by lambda");
state = val.value();
} else {
if (this->dispatched()) {
this->publish_state(state);
return;
}
if (!data.empty()) {
this->warn_write_buffer_deprecated_(LOG_STR("switch"), this->start_address);
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_SWITCH_MAX_LOG_BYTES)];
#endif
ESP_LOGV(TAG, "Modbus Switch write raw: %s",
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
// The lambda filled a legacy raw frame (device address + function code + data).
if (!this->send_raw_frame_deprecated_(data)) {
ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str());
return;
}
this->publish_state(state);
return;
}
if (!val.has_value()) {
ESP_LOGV(TAG, "Communication handled by lambda - exiting control");
return;
}
// The returned bool is the wire value only; the entity still reports the requested state. A polled
// entity needs the read lambda inverted to match, or the next poll flips the display back.
ESP_LOGV(TAG, "Value overwritten by lambda");
write_value = val.value();
}
if (!data.empty()) {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
char hex_buf[format_hex_pretty_size(MODBUS_SWITCH_MAX_LOG_BYTES)];
#endif
ESP_LOGV(TAG, "Modbus Switch write raw: %s",
format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size()));
cmd.emplace(ModbusCommandItem::create_custom_command(
this->parent_, data,
[this](modbus::EntityType register_type, uint16_t start_address, std::span<const uint8_t> data) {
this->parent_->on_write_register_response(register_type, this->start_address, data);
}));
} else {
ESP_LOGV(TAG, "write_state '%s': new value = %s type = %d address = %X offset = %x", this->get_name().c_str(),
ONOFF(state), (int) this->register_type, this->start_address, this->offset);
if (this->register_type == modbus::EntityType::COIL) {
// offset for coil and discrete inputs is the coil/register number not bytes
if (this->use_write_multiple_) {
std::vector<bool> states{state};
cmd.emplace(ModbusCommandItem::create_write_multiple_coils(this->parent_, this->write_address(), states));
} else {
cmd.emplace(ModbusCommandItem::create_write_single_coil(this->parent_, this->write_address(), state));
}
ESP_LOGV(TAG, "write_state '%s': new value = %s (wire = %s) type = %d address = %X offset = %x",
this->get_name().c_str(), ONOFF(state), ONOFF(write_value), (int) this->register_type, this->start_address,
this->offset);
bool queued;
if (this->register_type == EntityType::COIL) {
// offset for coil and discrete inputs is the coil/register number not bytes
if (this->use_write_multiple_) {
std::array<bool, 1> states{write_value};
queued = this->write_multiple_coils(this->write_address(), states);
} else {
if (this->use_write_multiple_) {
std::vector<uint16_t> bool_states(1, state ? (0xFFFF & this->bitmask) : 0);
cmd.emplace(
ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(), 1, bool_states));
} else {
cmd.emplace(ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(),
state ? 0xFFFF & this->bitmask : 0u));
}
queued = this->write_single_coil(this->write_address(), write_value);
}
} else {
if (this->use_write_multiple_) {
std::array<uint16_t, 1> states{static_cast<uint16_t>(write_value ? (0xFFFF & this->bitmask) : 0)};
queued = this->write_multiple_registers(this->write_address(), states);
} else {
queued = this->write_single_register(this->write_address(), write_value ? 0xFFFF & this->bitmask : 0u);
}
}
this->parent_->queue_command(std::move(*cmd));
if (!queued) {
ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str());
return;
}
this->publish_state(state);
}
// ModbusSwitch end
@@ -8,7 +8,7 @@
namespace esphome::modbus_controller {
class ModbusSwitch final : public Component, public switch_::Switch, public SensorItem {
class ModbusSwitch final : public Component, public switch_::Switch, public SensorItem, public WriterEntity {
public:
ModbusSwitch(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask,
bool force_new_range) {
@@ -18,8 +18,13 @@ class ModbusSwitch final : public Component, public switch_::Switch, public Sens
this->bitmask = bitmask;
this->sensor_value_type = SensorValueType::BIT;
this->register_count = 1;
if (register_type == modbus::EntityType::HOLDING || register_type == modbus::EntityType::COIL) {
this->set_address(this->start_address + offset);
// A holding byte offset folds into the address as whole registers (odd offsets are rejected at
// validation: a 16-bit register write cannot target half a register); a coil offset is a coil count.
if (register_type == modbus::EntityType::HOLDING) {
this->set_address(start_address + offset / 2);
this->set_offset_from_start_address(0);
} else if (register_type == modbus::EntityType::COIL) {
this->set_address(start_address + offset);
this->set_offset_from_start_address(0);
}
this->force_new_range = force_new_range;
@@ -30,17 +35,16 @@ class ModbusSwitch final : public Component, public switch_::Switch, public Sens
void set_assumed_state(bool assumed_state);
void set_state(bool state) { this->state = state; }
void parse_and_publish(std::span<const uint8_t> data) override;
void set_parent(ModbusController *parent) { this->parent_ = parent; }
void set_parent(ModbusController *parent) { this->set_controller_(parent); }
using transform_func_t = optional<bool> (*)(ModbusSwitch *, bool, std::span<const uint8_t>);
using write_transform_func_t = optional<bool> (*)(ModbusSwitch *, bool, std::vector<uint8_t> &);
using write_transform_func_t = optional<bool> (*)(ModbusSwitch *, bool, modbus::helpers::PduBuffer &);
void set_template(transform_func_t f) { this->publish_transform_func_ = f; }
void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; }
void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; }
protected:
bool assumed_state() override;
ModbusController *parent_{nullptr};
bool use_write_multiple_{false};
optional<transform_func_t> publish_transform_func_{nullopt};
optional<write_transform_func_t> write_transform_func_{nullopt};
+5 -1
View File
@@ -41,7 +41,11 @@ MQTTClientComponent::MQTTClientComponent() {
global_mqtt_client = this;
char mac_addr[MAC_ADDRESS_BUFFER_SIZE];
get_mac_address_into_buffer(mac_addr);
this->credentials_.client_id = make_name_with_suffix(App.get_name(), '-', mac_addr, MAC_ADDRESS_BUFFER_SIZE - 1);
const StringRef &name = App.get_name();
char client_id[MAX_NAME_WITH_SUFFIX_SIZE];
size_t len = make_name_with_suffix_to(client_id, sizeof(client_id), name.c_str(), name.size(), '-', mac_addr,
MAC_ADDRESS_BUFFER_SIZE - 1);
this->credentials_.client_id.assign(client_id, len);
}
// Connection
+2
View File
@@ -188,6 +188,8 @@ struct IPAddress {
}
IPAddress(const std::string &in_address) { inet_aton(in_address.c_str(), &ip_addr_); }
IPAddress(const ip_addr_t *other_ip) { ip_addr_ = *other_ip; }
bool is_ip4() const { return true; }
bool is_ip6() const { return false; }
/// Write IP address to buffer. Buffer must be at least IP_ADDRESS_BUFFER_SIZE bytes.
char *str_to(char *buf) const {
inet_ntop(AF_INET, &ip_addr_, buf, IP_ADDRESS_BUFFER_SIZE);
+3 -8
View File
@@ -125,10 +125,8 @@ def set_core_data(config: ConfigType) -> ConfigType:
return config
def _resolve_toolchain(config: ConfigType) -> ConfigType:
if CORE.toolchain is None:
CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.SDK_NRF)
return config
_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.SDK_NRF)
_resolve_toolchain = cv.resolve_toolchain("nRF52", _TOOLCHAINS, Toolchain.SDK_NRF)
def set_framework(config: ConfigType) -> ConfigType:
@@ -170,10 +168,7 @@ BOOTLOADERS = [
]
def _validate_toolchain(value) -> Toolchain:
return Toolchain(
cv.one_of(Toolchain.PLATFORMIO, Toolchain.SDK_NRF, lower=True)(value)
)
_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS)
def _detect_bootloader(config: ConfigType) -> ConfigType:
+4 -12
View File
@@ -6,8 +6,7 @@ import platform
import shutil
import sys
import platformdirs
from esphome.build_helpers.tools_cache import SDK_NRF_TOOLS_CACHE, tools_cache_path
import esphome.config_validation as cv
from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION
from esphome.core import CORE, EsphomeError
@@ -19,7 +18,6 @@ from esphome.framework_helpers import (
run_command_ok,
str_to_lst_of_str,
)
from esphome.helpers import get_str_env
_LOGGER = logging.getLogger(__name__)
@@ -49,15 +47,9 @@ SDK_NG_MINIMAL_MIRRORS = str_to_lst_of_str(
def get_sdk_nrf_tools_path() -> Path:
# A blank ESPHOME_SDK_NRF_PREFIX must be treated as unset: Path("")
# resolves to the CWD, which clean-all would then delete.
if prefix := get_str_env("ESPHOME_SDK_NRF_PREFIX", "").strip():
path = Path(prefix).expanduser()
else:
# Machine-global (OS user cache dir) so all projects share one install;
# see espidf.framework.get_idf_tools_path for the location rationale.
path = Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf"
return path.resolve()
# Machine-global (OS user cache dir) so all projects share one install;
# see espidf.framework.get_idf_tools_path for the location rationale.
return tools_cache_path(*SDK_NRF_TOOLS_CACHE)
def _needs_venv_rebuild(
+37 -19
View File
@@ -4,6 +4,7 @@
#include <openthread/cli.h>
#include <openthread/instance.h>
#include <openthread/ip6.h>
#include <openthread/logging.h>
#include <openthread/netdata.h>
#include <openthread/tasklet.h>
@@ -229,26 +230,43 @@ void *OpenThreadSrpComponent::pool_alloc_(size_t size) {
void OpenThreadSrpComponent::set_mdns(esphome::mdns::MDNSComponent *mdns) { this->mdns_ = mdns; }
bool OpenThreadComponent::teardown() {
if (!this->teardown_started_) {
this->teardown_started_ = true;
ESP_LOGD(TAG, "Clear Srp");
auto lock = InstanceLock::try_acquire(100);
if (!lock) {
ESP_LOGW(TAG, "Failed to acquire OpenThread lock during teardown, leaking memory");
return true;
}
otInstance *instance = lock.get_instance();
otSrpClientClearHostAndServices(instance);
otSrpClientBuffersFreeAllServices(instance);
global_openthread_component = nullptr;
ESP_LOGD(TAG, "Exit main loop ");
int error = this->openthread_stop_();
if (error != 0) {
ESP_LOGW(TAG, "Failed attempt to stop main loop %d", error);
this->teardown_complete_ = true;
}
switch (this->teardown_stage_) {
case TeardownStage::TEARDOWN_STAGE_NOT_STARTED: {
auto lock = InstanceLock::try_acquire(100);
if (!lock) {
// Try again on next teardown loop
ESP_LOGV(TAG, "Failed to acquire OpenThread lock during teardown");
return false;
}
// Start tearing down
this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_STOP_IN_PROCESS;
ESP_LOGV(TAG, "Clear SRP");
otInstance *instance = lock.get_instance();
otSrpClientClearHostAndServices(instance);
otSrpClientBuffersFreeAllServices(instance);
if (otThreadSetEnabled(instance, false) != OT_ERROR_NONE) {
ESP_LOGW(TAG, "Failed to disable Thread during teardown");
}
if (otIp6SetEnabled(instance, false) != OT_ERROR_NONE) {
ESP_LOGW(TAG, "Failed to disable IPv6 during teardown");
}
// Stop OpenThread
global_openthread_component = nullptr;
ESP_LOGV(TAG, "Stop OpenThread");
int error = this->openthread_stop_();
if (error != 0) {
ESP_LOGW(TAG, "Failed attempt to stop OpenThread %d", error);
this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED;
}
} break;
case TeardownStage::TEARDOWN_STAGE_STOP_IN_PROCESS:
// Waiting on OpenThread stop
break;
case TeardownStage::TEARDOWN_STAGE_COMPLETED:
ESP_LOGV(TAG, "OpenThreadComponent Teardown Complete");
break;
}
return this->teardown_complete_;
return this->teardown_stage_ == TeardownStage::TEARDOWN_STAGE_COMPLETED;
}
void OpenThreadComponent::on_factory_reset(std::function<void()> callback) {
+8 -3
View File
@@ -19,6 +19,12 @@ namespace esphome::openthread {
class InstanceLock;
enum class TeardownStage : uint8_t {
TEARDOWN_STAGE_NOT_STARTED = 0,
TEARDOWN_STAGE_STOP_IN_PROCESS,
TEARDOWN_STAGE_COMPLETED,
};
template<typename... Ts> class OpenThreadComponentPollPeriodAction;
class OpenThreadComponent final : public Component {
@@ -71,9 +77,8 @@ class OpenThreadComponent final : public Component {
#endif
std::optional<int8_t> output_power_{};
std::atomic<bool> lock_initialized_{false};
bool teardown_started_{false};
bool teardown_complete_{false};
bool connected_{false};
std::atomic<TeardownStage> teardown_stage_{TeardownStage::TEARDOWN_STAGE_NOT_STARTED};
std::atomic<bool> connected_{false};
private:
// Stores a pointer to a string literal (static storage duration).
@@ -168,7 +168,7 @@ void OpenThreadComponent::ot_main() {
esp_netif_destroy(openthread_netif);
esp_vfs_eventfd_unregister();
this->teardown_complete_ = true;
this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED;
vTaskDelete(NULL);
}
@@ -90,10 +90,8 @@ void OpenThreadComponent::ot_main() {}
otInstance *OpenThreadComponent::get_openthread_instance_() { return openthread_get_default_instance(); }
int OpenThreadComponent::openthread_stop_() {
// OT stack is intentionally left running — no Zephyr stop API. The state callback stays
// registered but is safe (null-checks global_openthread_component). nRF52840 never
// re-enters setup() after teardown so this is functionally correct.
this->teardown_complete_ = true;
// Zephyr has no stack-stop API, so stop is synchronous here; mark complete immediately.
this->teardown_stage_ = TeardownStage::TEARDOWN_STAGE_COMPLETED;
return 0;
}
@@ -23,6 +23,8 @@ class ProvisioningData:
sources: set[str] = field(default_factory=set)
# Names of source components that have their credentials set in the config.
hardcoded_credentials: set[str] = field(default_factory=set)
# True when WiFi is configured with an access point but no station credentials.
ap_without_sta: bool = False
def _get_data() -> ProvisioningData:
@@ -56,6 +58,17 @@ def report_hardcoded_credentials(name: str) -> None:
_get_data().hardcoded_credentials.add(name)
def report_ap_without_sta() -> None:
"""Record that WiFi runs an access point with no station credentials.
The access point (and captive portal) shut down when the provisioning window
closes. On a device where that access point is the only network connection,
closing the window makes the device unreachable until it is power-cycled, so
`provisioning:` warns about this combination.
"""
_get_data().ap_without_sta = True
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(ProvisioningManager),
@@ -89,6 +102,13 @@ def _final_validate(config: ConfigType) -> None:
"hardcoding them makes the window pointless.",
", ".join(sorted(data.hardcoded_credentials)),
)
if data.ap_without_sta:
_LOGGER.warning(
"'provisioning' is configured with a WiFi access point and no station "
"credentials. The access point shuts down when the provisioning window "
"closes, so if it is the device's only network connection, the device "
"will be unreachable until it is power-cycled."
)
FINAL_VALIDATE_SCHEMA = _final_validate
@@ -3,6 +3,12 @@ import logging
from esphome import automation, pins
import esphome.codegen as cg
from esphome.components import esp32, esp32_rmt, remote_base
from esphome.components.libretiny import get_libretiny_family
from esphome.components.libretiny.const import (
FAMILY_BK7231N,
FAMILY_BK7238,
FAMILY_RTL8720C,
)
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import (
@@ -43,6 +49,21 @@ DigitalWriteAction = remote_transmitter_ns.class_(
)
_NON_BLOCKING_LIBRETINY_FAMILIES = (FAMILY_RTL8720C, FAMILY_BK7231N, FAMILY_BK7238)
def _validate_non_blocking_platform(value: bool) -> bool:
# non_blocking requires hardware transmission: RMT on ESP32, a hardware timer
# envelope chain on the listed LibreTiny families. Reject elsewhere at config time.
if CORE.is_esp32:
return cv.boolean(value)
if CORE.is_libretiny and get_libretiny_family() in _NON_BLOCKING_LIBRETINY_FAMILIES:
return cv.boolean(value)
raise cv.Invalid(
"non_blocking is only supported on ESP32, RTL8720C, BK7231N and BK7238"
)
MULTI_CONF = True
CONFIG_SCHEMA = (
cv.Schema(
@@ -76,7 +97,7 @@ CONFIG_SCHEMA = (
esp32_s2=64,
esp32_s3=48,
): cv.All(cv.only_on_esp32, cv.int_range(min=2)),
cv.Optional(CONF_NON_BLOCKING): cv.All(cv.only_on_esp32, cv.boolean),
cv.Optional(CONF_NON_BLOCKING): _validate_non_blocking_platform,
cv.Optional(CONF_ON_TRANSMIT): automation.validate_automation(single=True),
cv.Optional(CONF_ON_COMPLETE): automation.validate_automation(single=True),
}
@@ -164,6 +185,8 @@ async def to_code(config: ConfigType) -> None:
)
else:
var = cg.new_Pvariable(config[CONF_ID], pin)
if (non_blocking := config.get(CONF_NON_BLOCKING)) is not None:
cg.add(var.set_non_blocking(non_blocking))
await cg.register_component(var, config)
cg.add(var.set_carrier_duty_percent(config[CONF_CARRIER_DUTY_PERCENT]))
@@ -188,6 +211,13 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
"remote_transmitter_rtl87xx.cpp": {
PlatformFramework.RTL87XX_ARDUINO,
},
"remote_transmitter_bk72xx.cpp": {
PlatformFramework.BK72XX_ARDUINO,
},
"remote_transmitter_libretiny_isr.cpp": {
PlatformFramework.RTL87XX_ARDUINO,
PlatformFramework.BK72XX_ARDUINO,
},
"remote_transmitter.cpp": {
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
@@ -2,8 +2,8 @@
#include "esphome/core/log.h"
#include "esphome/core/application.h"
#if (defined(USE_LIBRETINY) && !defined(USE_RTL87XX)) || defined(USE_ESP8266) || defined(USE_RP2) || \
(defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
#if (defined(USE_LIBRETINY) && !defined(USE_RTL87XX) && !defined(REMOTE_TRANSMITTER_BK_PWM)) || \
defined(USE_ESP8266) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
namespace esphome::remote_transmitter {
@@ -12,6 +12,13 @@
#endif // SOC_RMT_SUPPORTED
#endif // USE_ESP32
// The BK7231N-style PWM block (hardware shadow-load duty updates) enables the ISR-driven
// transmitter on these families; family-level proxy for the SDK's CFG_SOC_NAME gate.
// See remote_transmitter_bk72xx.cpp.
#if defined(USE_LIBRETINY_VARIANT_BK7231N) || defined(USE_LIBRETINY_VARIANT_BK7238)
#define REMOTE_TRANSMITTER_BK_PWM
#endif
namespace esphome::remote_transmitter {
#if defined(USE_ESP32) && SOC_RMT_SUPPORTED
@@ -56,19 +63,32 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa
#if defined(USE_ESP32) && SOC_RMT_SUPPORTED
void set_with_dma(bool with_dma) { this->with_dma_ = with_dma; }
void set_eot_level(bool eot_level) { this->eot_level_ = eot_level; }
#endif
#if (defined(USE_ESP32) && SOC_RMT_SUPPORTED) || defined(USE_LIBRETINY_VARIANT_RTL8720C) || \
defined(REMOTE_TRANSMITTER_BK_PWM)
void set_non_blocking(bool non_blocking) { this->non_blocking_ = non_blocking; }
#endif
#if defined(USE_LIBRETINY_VARIANT_RTL8720C) || defined(REMOTE_TRANSMITTER_BK_PWM)
void loop() override;
// called from the envelope timer ISR trampoline; not part of the public API
void advance_envelope_isr();
// same, for trampolines whose SDK callback carries no user argument
static void advance_active_isr();
#endif
Trigger<> *get_transmit_trigger() { return &this->transmit_trigger_; }
Trigger<> *get_complete_trigger() { return &this->complete_trigger_; }
protected:
void send_internal(uint32_t send_times, uint32_t send_wait) override;
#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
#if defined(USE_ESP8266) || \
(defined(USE_LIBRETINY) && !defined(USE_LIBRETINY_VARIANT_RTL8720C) && !defined(REMOTE_TRANSMITTER_BK_PWM)) || \
defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
void await_target_time_();
uint32_t target_time_{0};
#endif
#if defined(USE_ESP8266) || (defined(USE_LIBRETINY) && !defined(USE_RTL87XX)) || defined(USE_RP2) || \
#if defined(USE_ESP8266) || \
(defined(USE_LIBRETINY) && !defined(USE_RTL87XX) && !defined(REMOTE_TRANSMITTER_BK_PWM)) || defined(USE_RP2) || \
(defined(USE_ESP32) && !SOC_RMT_SUPPORTED)
void calculate_on_off_time_(uint32_t carrier_frequency, uint32_t *on_time_period, uint32_t *off_time_period);
@@ -81,6 +101,43 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa
uint32_t current_carrier_frequency_{0};
void *pwm_{nullptr}; // pwmout_t*, opaque here to keep the SDK header out of this shared header
#endif
#if defined(USE_LIBRETINY_VARIANT_RTL8720C) || defined(REMOTE_TRANSMITTER_BK_PWM)
// Envelope chain, shared by every family that paces transmission from a hardware timer
// (remote_transmitter_libretiny_isr.cpp)
void start_isr_item_(size_t index);
void arm_envelope_timer_(uint32_t duration_us);
void abort_stalled_chain_();
void deliver_completion_();
void wait_until_idle_();
void arm_chain_(uint32_t send_times, uint32_t send_wait);
// Hooks implemented per family: everything the chain needs from the hardware
bool envelope_ready_() const; // PWM claimed successfully in setup()
void prepare_carrier_(uint32_t carrier_frequency); // retune period, stage mark/space levels
void write_envelope_level_(bool mark); // drive carrier (mark) or idle (space)
void arm_one_shot_(uint32_t duration_us); // fire advance_envelope_isr after duration_us
void stop_envelope_timer_();
std::vector<int32_t> isr_data_; // owned copy of the frame; temp_ may be re-encoded mid-flight
volatile size_t isr_index_{0};
volatile uint32_t isr_repeats_left_{0};
uint32_t isr_send_wait_{0};
volatile uint32_t isr_wait_remaining_{0}; // remainder of a duration chained across one-shots
volatile bool isr_in_gap_{false};
volatile bool transmitting_{false};
bool non_blocking_{false};
bool complete_pending_{false};
bool stall_aborted_{false}; // this transmission ended via abort; blocks warning clear
#endif
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
float isr_mark_duty_{0.0f};
float isr_space_duty_{0.0f};
#endif
#ifdef REMOTE_TRANSMITTER_BK_PWM
void write_pwm_t1_(uint32_t t1_counts);
uint32_t isr_mark_t1_{0};
uint32_t isr_space_t1_{0};
uint32_t isr_period_t4_{684}; // 26MHz counts; ~38kHz default until a send sets the real carrier
int8_t pwm_channel_{-1};
#endif
#if defined(USE_ESP32) && SOC_RMT_SUPPORTED
void configure_rmt_();
@@ -0,0 +1,187 @@
#include "remote_transmitter.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
// clang-tidy cannot parse the Beken SDK headers pulled in via ArduinoPrivate.h
#if defined(USE_BK72XX) && !defined(CLANG_TIDY)
// ArduinoPrivate.h = Arduino.h + the BDK SDK headers (pwm_pub.h, bk_timer_pub.h, icu_pub.h)
// with the core's fixes for type-name collisions between the two
#include <ArduinoPrivate.h>
// Only the BK7231N-style PWM block (shadow registers with a hardware CFG_UPDATA load bit)
// supports glitch-free per-edge duty updates; older SoCs compile the generic bit-bang
// implementation (remote_transmitter.cpp) instead, and this file compiles to nothing.
// REMOTE_TRANSMITTER_BK_PWM is set per-family in remote_transmitter.h.
namespace esphome::remote_transmitter {
static const char *const TAG = "remote_transmitter";
#ifdef REMOTE_TRANSMITTER_BK_PWM
// PWM peripheral carrier (26MHz block), envelope paced by a BKTIMER1 interrupt chain: each
// interrupt writes the next duty through the shadow registers (T1..T4 + CFG_UPDATA hardware
// load, glitch-free at the next carrier period). Direct register writes beat the driver's
// pwm_update_param() (~19us vs ~26us edge error) and have no shared state to race against.
// BKTIMER1 is the only free channel: TIMER0 = FreeRTOS tick, TIMER2 = SDK cal, TIMER4 = wdt.
static constexpr uint32_t REG_PWM_BASE = 0x00802B00UL;
static constexpr uint32_t REG_PWM_GROUP_STRIDE = 0x40; // one register group per channel pair
static constexpr uint32_t REG_PWM_T_REGS[2] = {0x04, 0x14}; // T1..T4 offsets within a group
static constexpr uint32_t PWM_INT_STATUS_MASK = 3UL << 30; // write-1-clear -- always write as zero
static constexpr uint8_t ENVELOPE_TIMER = BKTIMER1;
// The bk_timer handler receives only the channel number, so the chain resolves the instance
// that owns the timer. No IRAM_ATTR: hal.h makes it a no-op on BK72xx (the SDK masks IRQs
// around flash writes).
static void envelope_timer_isr(UINT8 channel) { RemoteTransmitterComponent::advance_active_isr(); }
// Channel <-> pin comes from the board variant's own PIN_PWMn defines rather than a
// family-wide assumption, so an unusual pinout maps correctly instead of silently
// driving another pad
struct PwmPinChannel {
uint8_t pin;
int8_t channel;
};
static constexpr PwmPinChannel PWM_PIN_CHANNELS[] = {
#ifdef PIN_PWM0
{PIN_PWM0, 0},
#endif
#ifdef PIN_PWM1
{PIN_PWM1, 1},
#endif
#ifdef PIN_PWM2
{PIN_PWM2, 2},
#endif
#ifdef PIN_PWM3
{PIN_PWM3, 3},
#endif
#ifdef PIN_PWM4
{PIN_PWM4, 4},
#endif
#ifdef PIN_PWM5
{PIN_PWM5, 5},
#endif
};
static int8_t pwm_channel_for_pin(uint8_t pin) {
for (const auto &entry : PWM_PIN_CHANNELS) {
if (entry.pin == pin)
return entry.channel;
}
return -1;
}
void RemoteTransmitterComponent::setup() {
// Deliberately no pin_->setup(): the pin must belong to the PWM function, not GPIO
const int8_t channel = pwm_channel_for_pin(this->pin_->get_pin());
if (channel < 0) {
ESP_LOGE(TAG, "Pin %u is not PWM-capable", this->pin_->get_pin());
this->mark_failed();
return;
}
this->pwm_channel_ = channel;
const uint32_t idle_t1 = this->pin_->is_inverted() ? this->isr_period_t4_ : 0;
pwm_param_st param{};
param.chan = channel;
param.t1 = idle_t1;
param.t4 = this->isr_period_t4_;
param.init_level = idle_t1 ? 1 : 0;
if (pwm_init_param(&param) != 0 || pwm_start(channel) != 0) {
ESP_LOGE(TAG, "PWM init failed on pin %u", this->pin_->get_pin());
this->pwm_channel_ = -1;
this->mark_failed();
return;
}
this->disable_loop(); // loop() is only needed while a non-blocking completion is pending
}
void RemoteTransmitterComponent::dump_config() {
ESP_LOGCONFIG(TAG,
"Remote Transmitter:\n"
" Carrier Duty: %u%%\n"
" Non-blocking: %s",
this->carrier_duty_percent_, YESNO(this->non_blocking_));
LOG_PIN(" Pin: ", this->pin_);
}
// Writes the duty compare registers and sets the hardware CFG_UPDATA shadow-load bit;
// the new duty latches glitch-free at the next carrier period. ISR-safe: registers only.
// The group control word is shared with the paired channel, but every SDK write to it runs
// under GLOBAL_INT_DISABLE (bk_pwm), so it cannot be torn by this interrupt.
void RemoteTransmitterComponent::write_pwm_t1_(uint32_t t1_counts) {
const uint32_t group = this->pwm_channel_ / 2;
const uint32_t post = this->pwm_channel_ % 2;
const uint32_t group_base = REG_PWM_BASE + REG_PWM_GROUP_STRIDE * group;
auto *t_regs = (volatile uint32_t *) (group_base + REG_PWM_T_REGS[post]);
auto *ctrl = (volatile uint32_t *) group_base;
const uint32_t init_level_bit = 1UL << (8 * post + 6); // output level while the counter is stopped
const uint32_t cfg_updata_bit = 1UL << (8 * post + 7); // 0->1 latches T1..T4 at the next period
t_regs[0] = t1_counts; // T1: high time
t_regs[1] = 0; // T2
t_regs[2] = 0; // T3
t_regs[3] = this->isr_period_t4_; // T4: period
uint32_t cfg = *ctrl;
cfg &= ~(PWM_INT_STATUS_MASK | init_level_bit | cfg_updata_bit);
if (t1_counts != 0)
cfg |= init_level_bit;
*ctrl = cfg;
*ctrl = cfg | cfg_updata_bit;
}
// --- envelope chain hooks (see remote_transmitter_libretiny_isr.cpp) ---
bool RemoteTransmitterComponent::envelope_ready_() const { return this->pwm_channel_ >= 0; }
// Recomputes the carrier period in 26MHz counts and stages the per-item duties;
// unmodulated protocols drive the pin constantly during marks
void RemoteTransmitterComponent::prepare_carrier_(uint32_t carrier_frequency) {
if (carrier_frequency > 0) {
this->isr_period_t4_ = std::max(uint32_t(2), (26000000UL + carrier_frequency / 2) / carrier_frequency);
}
uint32_t mark_t1 = (carrier_frequency > 0 && this->carrier_duty_percent_ < 100)
? std::max(uint32_t(1), this->isr_period_t4_ * this->carrier_duty_percent_ / 100)
: this->isr_period_t4_;
uint32_t space_t1 = 0;
if (this->pin_->is_inverted()) {
mark_t1 = this->isr_period_t4_ - mark_t1;
space_t1 = this->isr_period_t4_;
}
this->isr_mark_t1_ = mark_t1;
this->isr_space_t1_ = space_t1;
}
void RemoteTransmitterComponent::write_envelope_level_(bool mark) {
this->write_pwm_t1_(mark ? this->isr_mark_t1_ : this->isr_space_t1_);
}
// The driver's microsecond init path is register writes under a nested interrupt guard,
// so it is safe to call from the chain's own interrupt
void RemoteTransmitterComponent::arm_one_shot_(uint32_t duration_us) {
timer_param_t param{};
param.channel = ENVELOPE_TIMER;
param.div = 1;
param.period = duration_us;
param.t_Int_Handler = envelope_timer_isr;
sddev_control((char *) TIMER_DEV_NAME, CMD_TIMER_INIT_PARAM_US, &param);
}
void RemoteTransmitterComponent::stop_envelope_timer_() {
UINT32 channel = ENVELOPE_TIMER;
sddev_control((char *) TIMER_DEV_NAME, CMD_TIMER_UNIT_DISABLE, &channel);
}
void RemoteTransmitterComponent::digital_write(bool value) {
if (this->pwm_channel_ < 0)
return;
// serialize behind an in-flight chain, matching the ESP32/RMT non-blocking behavior
this->wait_until_idle_();
this->write_pwm_t1_((value != this->pin_->is_inverted()) ? this->isr_period_t4_ : 0);
}
#endif // REMOTE_TRANSMITTER_BK_PWM
} // namespace esphome::remote_transmitter
#endif // USE_BK72XX && !CLANG_TIDY
@@ -0,0 +1,224 @@
#include "remote_transmitter.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
// Envelope chain shared by the LibreTiny families that pace transmission from a hardware
// timer interrupt: RTL8720C (gtimer) and the BK7231N-style PWM block (BKTIMER1). Everything
// platform-specific sits behind five hooks implemented in the per-family files -- carrier
// setup, duty writes, one-shot arming and timer stop. Families without a usable timer keep
// the generic bit-bang implementation and compile none of this.
#if defined(USE_LIBRETINY_VARIANT_RTL8720C) || defined(REMOTE_TRANSMITTER_BK_PWM)
namespace esphome::remote_transmitter {
static const char *const TAG = "remote_transmitter";
// Margin past a transmission's expected duration before the chain is declared stalled
static constexpr uint32_t STALL_MARGIN_MS = 1000;
// Longest single one-shot armed; longer durations are chained. Both families need the cap:
// the Beken driver computes period_us * 26 in 32 bits (overflows past ~165s) and the Realtek
// us->tick conversion lives in mask ROM with unverified headroom.
static constexpr uint32_t MAX_ONE_SHOT_US = 50000;
// One hardware timer is shared by all instances (MULTI_CONF), so they serialize on this
// token; the deadline always describes whichever chain currently owns it.
// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables)
static RemoteTransmitterComponent *volatile s_active_transmitter = nullptr;
static uint32_t s_expected_end_ms = 0;
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables)
// Entry point for trampolines whose SDK callback carries no user argument
void IRAM_ATTR RemoteTransmitterComponent::advance_active_isr() {
auto *transmitter = s_active_transmitter;
if (transmitter != nullptr)
transmitter->advance_envelope_isr();
}
// Arms the envelope timer, chaining durations longer than MAX_ONE_SHOT_US. ISR-safe.
void IRAM_ATTR RemoteTransmitterComponent::arm_envelope_timer_(uint32_t duration_us) {
// clamp to 1us (a zero-length one-shot never fires); the remainder must not underflow
const uint32_t chunk = std::max(uint32_t(1), std::min(duration_us, MAX_ONE_SHOT_US));
this->isr_wait_remaining_ = duration_us > chunk ? duration_us - chunk : 0;
this->arm_one_shot_(chunk);
}
// Writes the level for one envelope item and arms the timer for its duration.
// Runs in ISR context (and once from arm_chain_ to kick the chain): no logging, no allocation.
void IRAM_ATTR RemoteTransmitterComponent::start_isr_item_(size_t index) {
const int32_t item = this->isr_data_[index];
this->write_envelope_level_(item > 0);
this->arm_envelope_timer_(uint32_t(item > 0 ? item : -item));
}
void IRAM_ATTR RemoteTransmitterComponent::advance_envelope_isr() {
if (!this->transmitting_)
return; // chain was aborted; this is a stale one-shot that was already latched
if (this->isr_wait_remaining_ > 0) {
// continue a duration longer than one hardware one-shot
this->arm_envelope_timer_(this->isr_wait_remaining_);
return;
}
if (this->isr_in_gap_) {
// inter-repeat gap elapsed; restart the item chain
this->isr_in_gap_ = false;
this->isr_index_ = 0;
this->start_isr_item_(0);
return;
}
this->isr_index_ = this->isr_index_ + 1;
if (this->isr_index_ < this->isr_data_.size()) {
this->start_isr_item_(this->isr_index_);
return;
}
// end of one repetition
this->write_envelope_level_(false);
if (this->isr_repeats_left_ > 1) {
this->isr_repeats_left_ = this->isr_repeats_left_ - 1;
this->isr_index_ = 0;
if (this->isr_send_wait_ > 0) {
this->isr_in_gap_ = true;
this->arm_envelope_timer_(this->isr_send_wait_);
} else {
this->start_isr_item_(0);
}
return;
}
// required on Beken (its timer reloads); on Realtek this only clears the enable bit of a
// one-shot that has already fired
this->stop_envelope_timer_();
this->transmitting_ = false;
s_active_transmitter = nullptr;
}
// Aborts a chain that stopped advancing: stop the timer, idle the pin, release the token.
// Every step is a no-op if the chain completed meanwhile. Task context only.
void RemoteTransmitterComponent::abort_stalled_chain_() {
// cleared first so a straggler one-shot bails at the ISR entry check
this->transmitting_ = false;
this->stop_envelope_timer_();
this->write_envelope_level_(false);
s_active_transmitter = nullptr;
this->stall_aborted_ = true;
this->status_set_warning("envelope timer stalled");
ESP_LOGE(TAG, "Envelope timer stalled; transmission aborted");
delay(1); // let any already-latched interrupt land while the chain state is safe
}
// Delivers one deferred completion with its status bookkeeping
void RemoteTransmitterComponent::deliver_completion_() {
if (!this->stall_aborted_)
this->status_clear_warning();
this->complete_pending_ = false;
this->complete_trigger_.trigger();
}
// Waits until no chain is in flight, delivering any deferred completions; a completion
// automation may start a new send, so repeat until truly idle. Bounded by the stall deadline.
void RemoteTransmitterComponent::wait_until_idle_() {
while (true) {
while (true) {
// snapshot: the final ISR can clear the volatile pointer between a check and a use
auto *active = s_active_transmitter;
if (active == nullptr)
break;
if ((int32_t) (millis() - s_expected_end_ms) > 0) {
active->abort_stalled_chain_();
break;
}
App.feed_wdt();
delay(1);
}
if (!this->complete_pending_)
break;
this->deliver_completion_();
}
}
// Stages the repeat schedule and stall deadline, then starts the interrupt chain
void RemoteTransmitterComponent::arm_chain_(uint32_t send_times, uint32_t send_wait) {
this->isr_repeats_left_ = send_times;
this->isr_send_wait_ = send_wait;
this->isr_index_ = 0;
this->isr_in_gap_ = false;
this->stall_aborted_ = false;
uint64_t frame_us = 0;
for (int32_t item : this->isr_data_)
frame_us += uint32_t(item > 0 ? item : -item);
const uint64_t total_us = frame_us * send_times + uint64_t(send_wait) * (send_times - 1);
s_expected_end_ms = millis() + uint32_t(total_us / 1000) + STALL_MARGIN_MS;
this->transmitting_ = true;
s_active_transmitter = this;
this->start_isr_item_(0);
}
void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) {
if (!this->envelope_ready_()) {
// both triggers still fire, so an on_complete-sequenced automation does not stall
ESP_LOGW(TAG, "Cannot send: PWM not initialized");
this->transmit_trigger_.trigger();
this->deliver_completion_();
return;
}
this->wait_until_idle_();
if (send_times == 0) {
// parity with the loop-based implementations: transmit nothing, but both triggers
// still fire so an on_complete-sequenced automation does not stall
this->transmit_trigger_.trigger();
this->deliver_completion_();
return;
}
ESP_LOGD(TAG, "Sending remote code");
this->prepare_carrier_(this->temp_.get_carrier_frequency());
// own copy: with non_blocking the caller may re-encode temp_ while this frame is in flight
this->isr_data_.assign(this->temp_.get_data().begin(), this->temp_.get_data().end());
if (this->isr_data_.empty()) {
ESP_LOGW(TAG, "Empty data");
this->transmit_trigger_.trigger();
this->deliver_completion_();
return;
}
// trigger first: the deadline computed in arm_chain_ must not be charged for user code
this->transmit_trigger_.trigger();
// the automation may have started a send on another instance; let it finish before
// claiming the shared timer (a same-instance send remains unsupported here)
this->wait_until_idle_();
this->arm_chain_(send_times, send_wait);
if (this->non_blocking_) {
this->complete_pending_ = true;
this->enable_loop();
return;
}
// blocking mode: wait out the chain, bounded by the stall deadline
while (this->transmitting_) {
if ((int32_t) (millis() - s_expected_end_ms) > 0) {
this->abort_stalled_chain_();
break;
}
App.feed_wdt();
delay(1);
}
this->deliver_completion_();
}
void RemoteTransmitterComponent::loop() {
if (!this->complete_pending_) {
this->disable_loop();
return;
}
if (this->transmitting_) {
// non-blocking stall recovery: without this, a dead chain would leave the carrier
// driven and on_complete unfired until the next send happened to abort it
if ((int32_t) (millis() - s_expected_end_ms) <= 0)
return;
this->abort_stalled_chain_();
}
// release the loop before user code runs: the automation may start a new non-blocking
// send, and its enable_loop() must be the last writer or its completion would strand
this->disable_loop();
this->deliver_completion_();
}
} // namespace esphome::remote_transmitter
#endif // USE_LIBRETINY_VARIANT_RTL8720C || REMOTE_TRANSMITTER_BK_PWM
@@ -5,31 +5,42 @@
// clang-tidy cannot parse the Realtek SDK headers pulled in via ArduinoPrivate.h
#if defined(USE_RTL87XX) && !defined(CLANG_TIDY)
// ArduinoPrivate.h = Arduino.h + the SDK's mbed HAL (pwmout etc.) with the core's fixes for
// ArduinoPrivate.h = Arduino.h + the SDK's mbed HAL (pwmout, gtimer) with the core's fixes for
// type-name collisions between the two (e.g. PinMode)
#include <ArduinoPrivate.h>
#ifndef USE_LIBRETINY_VARIANT_RTL8720C
#include <FreeRTOS.h>
#include <task.h>
#endif
namespace esphome::remote_transmitter {
static const char *const TAG = "remote_transmitter";
// The carrier is generated by the PWM peripheral instead of bit-banging the pin: software carrier
// generation requires disabling interrupts for the whole frame, but this core's micros() is derived
// from the FreeRTOS tick and freezes while interrupts are off, so the timing loop never advances and
// the watchdog resets the chip. With hardware PWM, software only times the mark/space envelope and
// interrupts can stay enabled.
//
// The PWM is driven through the SDK's pwmout HAL directly rather than the Arduino wiring layer:
// changing the carrier frequency via the wiring requires a GPIO/PWM pin mode round-trip, which
// use-after-frees the core's per-pin state (pinRemoveMode() frees without nulling) and corrupts the
// heap. pwmout_period_us() changes the frequency with no mode transitions.
// PWM peripheral carrier, envelope paced by a gtimer interrupt chain. Bit-banging would need
// interrupts disabled for the whole frame, but this core's micros() derives from the FreeRTOS
// tick and freezes then. The SDK pwmout HAL is driven directly: the Arduino wiring layer's
// GPIO/PWM mode round-trip use-after-frees LibreTiny's per-pin state.
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
static constexpr uint32_t ENVELOPE_TIMER_ID = TIMER6; // GTimer7
// One envelope timer for all instances: a second gtimer_init on the same id fails silently,
// so the chain serializes them (remote_transmitter_libretiny_isr.cpp)
// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables)
static uint8_t s_pwm_tick_sources[] = {GTimer1, GTimer2, GTimer3, GTimer4, GTimer5, GTimer6, 0xff};
static gtimer_t s_envelope_timer;
static bool s_envelope_timer_ready = false;
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables)
static void IRAM_ATTR envelope_timer_isr(uint32_t arg) {
reinterpret_cast<RemoteTransmitterComponent *>(arg)->advance_envelope_isr();
}
#endif // USE_LIBRETINY_VARIANT_RTL8720C
void RemoteTransmitterComponent::setup() {
// Deliberately no pin_->setup(): registering the pin as GPIO claims it in the SDK's pin
// management, and the pad is then never handed over to the PWM peripheral -- pwmout_init()
// must own the pin from the start.
// no pin_->setup(): a GPIO claim in the SDK's pin management blocks pwmout_init from
// owning the pad
PinInfo *info = pinInfo(this->pin_->get_pin());
if (info == nullptr || !pinSupported(info, PIN_PWM)) {
// checked here because the AmebaZ (RTL8710B) SDK does not report PWM init failure
@@ -40,7 +51,7 @@ void RemoteTransmitterComponent::setup() {
auto *pwm = new pwmout_t();
this->pwm_ = pwm;
pwmout_init(pwm, static_cast<PinName>(info->gpio));
#if LT_RTL8720C
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
// only the AmebaZ2 SDK's pwmout_s reports init success
if (!pwm->is_init) {
ESP_LOGE(TAG, "PWM init failed on pin %u", this->pin_->get_pin());
@@ -49,9 +60,19 @@ void RemoteTransmitterComponent::setup() {
this->mark_failed();
return;
}
// Shrink the PWM tick-source pool before the period claim below so GTimer7 stays free
// for the envelope; pwmout_init just registered the full pool.
hal_pwm_comm_tick_source_list(s_pwm_tick_sources);
#endif
pwmout_period_us(pwm, 26); // placeholder; the real carrier period is set per transmission
pwmout_write(pwm, this->pin_->is_inverted() ? 1.0f : 0.0f);
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
if (!s_envelope_timer_ready) {
gtimer_init(&s_envelope_timer, ENVELOPE_TIMER_ID);
s_envelope_timer_ready = true;
}
this->disable_loop(); // loop() is only needed while a non-blocking completion is pending
#endif
}
void RemoteTransmitterComponent::dump_config() {
@@ -59,9 +80,59 @@ void RemoteTransmitterComponent::dump_config() {
"Remote Transmitter:\n"
" Carrier Duty: %u%%",
this->carrier_duty_percent_);
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
ESP_LOGCONFIG(TAG, " Non-blocking: %s", YESNO(this->non_blocking_));
#endif
LOG_PIN(" Pin: ", this->pin_);
}
void RemoteTransmitterComponent::digital_write(bool value) {
if (this->pwm_ == nullptr)
return;
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
// serialize behind an in-flight chain, matching the ESP32/RMT non-blocking behavior
this->wait_until_idle_();
#endif
pwmout_write(static_cast<pwmout_t *>(this->pwm_), (value != this->pin_->is_inverted()) ? 1.0f : 0.0f);
}
#ifdef USE_LIBRETINY_VARIANT_RTL8720C
// --- envelope chain hooks (see remote_transmitter_libretiny_isr.cpp) ---
bool RemoteTransmitterComponent::envelope_ready_() const { return this->pwm_ != nullptr; }
// Retunes the PWM period when the carrier changes and stages the per-item duties;
// unmodulated protocols (no carrier or 100% duty) drive the pin constantly during marks
void RemoteTransmitterComponent::prepare_carrier_(uint32_t carrier_frequency) {
float mark_duty =
(carrier_frequency > 0 && this->carrier_duty_percent_ < 100) ? this->carrier_duty_percent_ / 100.0f : 1.0f;
float space_duty = 0.0f;
if (this->pin_->is_inverted()) {
mark_duty = 1.0f - mark_duty;
space_duty = 1.0f;
}
this->isr_mark_duty_ = mark_duty;
this->isr_space_duty_ = space_duty;
if (carrier_frequency == 0 || carrier_frequency == this->current_carrier_frequency_)
return;
// round(1000000/freq), clamped so a bad lambda can't hand the SDK a zero period
const uint32_t period = std::max(uint32_t(1), (1000000UL + carrier_frequency / 2) / carrier_frequency);
pwmout_period_us(static_cast<pwmout_t *>(this->pwm_), period);
this->current_carrier_frequency_ = carrier_frequency;
}
void IRAM_ATTR RemoteTransmitterComponent::write_envelope_level_(bool mark) {
pwmout_write(static_cast<pwmout_t *>(this->pwm_), mark ? this->isr_mark_duty_ : this->isr_space_duty_);
}
void IRAM_ATTR RemoteTransmitterComponent::arm_one_shot_(uint32_t duration_us) {
gtimer_start_one_shout(&s_envelope_timer, duration_us, (void *) envelope_timer_isr, (uint32_t) this);
}
void IRAM_ATTR RemoteTransmitterComponent::stop_envelope_timer_() { gtimer_stop(&s_envelope_timer); }
#else // !USE_LIBRETINY_VARIANT_RTL8720C -- AmebaZ (RTL8710B): spin-based envelope, per-frame priority boost
void RemoteTransmitterComponent::await_target_time_() {
const uint32_t current_time = micros();
if (this->target_time_ == 0) {
@@ -72,15 +143,8 @@ void RemoteTransmitterComponent::await_target_time_() {
}
}
void RemoteTransmitterComponent::digital_write(bool value) {
if (this->pwm_ == nullptr)
return;
pwmout_write(static_cast<pwmout_t *>(this->pwm_), (value != this->pin_->is_inverted()) ? 1.0f : 0.0f);
}
void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) {
auto *pwm = static_cast<pwmout_t *>(this->pwm_);
if (pwm == nullptr) {
if (this->pwm_ == nullptr) {
ESP_LOGW(TAG, "Cannot send: PWM not initialized");
return;
}
@@ -94,6 +158,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen
mark_duty = 1.0f - mark_duty;
space_duty = 1.0f;
}
auto *pwm = static_cast<pwmout_t *>(this->pwm_);
if (carrier_frequency > 0 && carrier_frequency != this->current_carrier_frequency_) {
// round(1000000/freq), clamped like the bit-bang path so a bad lambda can't hand the SDK a zero period
const uint32_t period = std::max(uint32_t(1), (1000000UL + carrier_frequency / 2) / carrier_frequency);
@@ -132,6 +197,8 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen
this->complete_trigger_.trigger();
}
#endif // USE_LIBRETINY_VARIANT_RTL8720C
} // namespace esphome::remote_transmitter
#endif // USE_RTL87XX && !CLANG_TIDY
+1
View File
@@ -312,6 +312,7 @@ CONFIG_SCHEMA = cv.All(
),
cv.has_at_least_one_key(CONF_BOARD, CONF_VARIANT),
_detect_variant,
cv.require_platformio_toolchain("RP2"),
set_core_data,
)

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