Compare commits

..
Author SHA1 Message Date
J. Nick Koston 21e9957fa4 Add tests for encrypted OTA 2026-08-23 17:25:40 -05:00
J. Nick Koston d7b1656464 Support encrypted uploads in the OTA client 2026-08-23 17:25:40 -05:00
J. Nick Koston 51dc9b31f4 Add noise encryption to the esphome OTA platform 2026-08-23 17:25:40 -05:00
J. Nick Koston 5d0522b88c Add host unit tests for the noise component 2026-08-23 17:25:40 -05:00
J. Nick Koston f0f9a1c57a Refactor the api noise handshake onto the shared responder 2026-08-23 17:25:40 -05:00
J. Nick Koston 44f80efcca Share the noise wire constants and reject formatter 2026-08-23 17:25:40 -05:00
J. Nick Koston a9b8719ef9 Move encryption key validation into the noise component 2026-08-23 17:25:39 -05:00
J. Nick Koston c1ef59c4b6 Add shared noise component and move noise-c primitives out of api 2026-08-23 17:25:39 -05:00
J. Nick KostonandGitHub 98f138f6b2 [esp32_ble_tracker] Scan at the default window while a GATT connection is active (#18609) 2026-08-24 09:16:01 +12:00
J. Nick KostonandGitHub 9179fe26ce [api] Fix loop stall that triggers the task watchdog when entity list sends block (#18577) 2026-08-24 08:38:19 +12:00
J. Nick KostonandGitHub e6c49e2bd9 [core] Retry transient network errors when downloading external files (#18538) 2026-08-24 08:25:39 +12:00
guillempagesGitHubJ. Nick Kostonpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>J. Nick Koston
d84293931b [online_image] Support image format auto-detection (#16337)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-08-23 19:58:01 +00:00
Boris KrivonogandGitHub 2c32ac2221 [mitsubishi_cn105] Mark configurable classes as final (#18272) 2026-08-23 19:40:08 +00:00
dde6906f98 [modbus_client] Add continuous option to the read and send actions (#18542)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-23 13:01:34 -05:00
J. Nick KostonandGitHub 4efd308345 [tests] Fix flaky pty log probe test on macOS (#18681) 2026-08-23 11:26:55 -05:00
esphome[bot]andGitHub f741c274d5 Bump aioesphomeapi from 45.13.1 to 46.0.0 (#18683) 2026-08-23 15:55:42 +00:00
J. Nick KostonandGitHub cf31c08a5c [core] Skip copying entity automation and filter sources when unused (#18602) 2026-08-23 09:05:04 -05:00
J. Nick KostonandGitHub e7574a574b [ota] Restore lazy flash erase for ESP32 OTA with 64 KiB block erase (#18580) 2026-08-23 09:04:48 -05:00
J. Nick KostonandGitHub 33484108a9 [core] Replace a damaged existing file in write_file_if_changed (#18665) 2026-08-23 09:04:24 -05:00
J. Nick KostonandGitHub e697a40fda [core] Register the OTA component in dummy_main like its siblings (#18666) 2026-08-23 09:04:06 -05:00
J. Nick KostonandGitHub d1f065671e [http_request] Abort OTA backend when update fails before first write (#18581) 2026-08-22 22:21:04 -05:00
J. Nick KostonandGitHub 02da5c6484 [ethernet] Remove deprecated get_eth_mac_address_pretty() (#18379) 2026-08-22 22:02:41 -05:00
J. Nick KostonandGitHub b2440cb655 [modbus] Remove deprecated waiting_for_response() (#18381) 2026-08-22 22:02:22 -05:00
J. Nick KostonandGitHub 160d8b8f0c [web_server_idf] Remove deprecated AsyncWebServerRequest::url() (#18382) 2026-08-22 22:02:05 -05:00
J. Nick KostonandGitHub 8899713ef9 [core] Remove deprecated gamma_correct and gamma_uncorrect (#18376) 2026-08-22 22:00:53 -05:00
J. Nick KostonandGitHub f3cdefce21 [wifi] Remove deprecated wifi_ssid() (#18378) 2026-08-22 22:00:38 -05:00
J. Nick KostonandGitHub b115813fbe [esp32] Report abort and task watchdog panics correctly in crash handler (#18575) 2026-08-22 22:00:17 -05:00
J. Nick KostonandGitHub ab45ab316a [core] Remove deprecated entity_base getters (#18375) 2026-08-22 22:00:02 -05:00
J. Nick KostonandGitHub 5b3a6c05bf [core] Remove deprecated esp_log_vprintf_ flash-string overload (#18377) 2026-08-22 21:59:47 -05:00
J. Nick KostonandGitHub cd53681787 [wifi] Inline the remaining trivial WiFiAP and WiFiComponent accessors (#18617) 2026-08-23 02:51:39 +00:00
J. Nick KostonandGitHub f0651e5c9b [radio_frequency] Inline the trivial make_call helper (#18645) 2026-08-22 21:07:34 -05:00
J. Nick KostonandGitHub f24b731f95 [infrared] Inline the trivial make_call helper (#18644) 2026-08-22 21:07:14 -05:00
J. Nick KostonandGitHub 01ad424d12 [datetime] Inline the trivial make_call helpers (#18643) 2026-08-22 21:07:03 -05:00
J. Nick KostonandGitHub 435d522683 [text] Inline the trivial Text publish_state forwarding overloads (#18642) 2026-08-22 21:06:47 -05:00
J. Nick KostonandGitHub a63c3bc0c7 [valve] Inline the trivial Valve and ValveCall accessors (#18641) 2026-08-22 21:06:31 -05:00
J. Nick KostonandGitHub b83ce91528 [display] Inline the trivial DisplayPage setters and page navigation helpers (#18639) 2026-08-22 21:06:03 -05:00
J. Nick KostonandGitHub a30238aab6 [wireguard] Inline the trivial Wireguard setters (#18638) 2026-08-22 21:05:51 -05:00
J. Nick KostonandGitHub 47156c9a5b [mqtt] Inline the trivial MQTT client, component and sensor accessors (#18637) 2026-08-22 21:05:39 -05:00
J. Nick KostonandGitHub 78240c9a46 [sprinkler] Inline the trivial Sprinkler accessors (#18636) 2026-08-22 21:04:57 -05:00
J. Nick KostonandGitHub 5a9f06e584 [thermostat] Inline the trivial ThermostatClimate setters and getters (#18635) 2026-08-22 21:04:41 -05:00
J. Nick KostonandGitHub ecb007da70 [deep_sleep] Inline the trivial DeepSleepComponent setters (#18634) 2026-08-22 21:04:23 -05:00
J. Nick KostonandGitHub ad1a4fca36 [version] Inline the trivial VersionTextSensor setters (#18633) 2026-08-22 21:04:08 -05:00
J. Nick KostonandGitHub 832a738588 [switch] Inline the trivial inverted accessors (#18632) 2026-08-22 21:03:51 -05:00
J. Nick KostonandGitHub fedb3ac5c1 [fan] Inline the trivial Fan call helpers (#18631) 2026-08-22 21:03:38 -05:00
J. Nick KostonandGitHub cb4e55e444 [cover] Inline the trivial Cover and CoverCall accessors (#18630) 2026-08-22 21:03:15 -05:00
J. Nick KostonandGitHub 3ef5a8e6a4 [water_heater] Inline the trivial visual override setters (#18629) 2026-08-22 21:03:07 -05:00
J. Nick KostonandGitHub 0e915e9b8b [core] Inline the ESPTime::strftime std::string overload (#18628) 2026-08-22 21:02:58 -05:00
J. Nick KostonandGitHub dba3b287dd [api] Inline the trivial APIServer accessors (#18627) 2026-08-22 21:02:14 -05:00
J. Nick KostonandGitHub ce019f508d [safe_mode] Inline the trivial set_safe_mode setters (#18626) 2026-08-22 21:02:04 -05:00
J. Nick KostonandGitHub 5c2286cc4a [climate] Inline the trivial visual override setters (#18624) 2026-08-22 21:01:55 -05:00
J. Nick KostonandGitHub efc0a94112 [text_sensor] Inline the trivial TextSensor forwarding overloads (#18623) 2026-08-22 21:01:42 -05:00
J. Nick KostonandGitHub c60062c418 [sensor] Inline the trivial ExponentialMovingAverageFilter setters (#18622) 2026-08-22 21:01:28 -05:00
J. Nick KostonandGitHub 763a1d9371 [select] Inline the trivial Select accessors (#18621) 2026-08-22 21:01:16 -05:00
J. Nick KostonandGitHub 4db1666024 [light] Inline the trivial LightState accessors (#18620) 2026-08-22 21:00:59 -05:00
J. Nick KostonandGitHub 0dc69aab1e [logger] Inline the trivial Logger accessors (#18619) 2026-08-22 21:00:49 -05:00
J. Nick KostonandGitHub a282cb095e [ethernet] Inline the trivial EthernetComponent setters (#18618) 2026-08-22 21:00:39 -05:00
J. Nick KostonandGitHub 259e7182a3 [esp32] Exclude esp_gdbstub from the build by default (#18604) 2026-08-22 20:59:55 -05:00
J. Nick KostonandGitHub 74bdf275d2 [core] Dump the main.cpp config comment with sorted keys (#18653) 2026-08-22 20:58:28 -05:00
J. Nick KostonandGitHub 14499223fd [esp8266] Don't report stale crash state after hardware WDT resets (#18597) 2026-08-22 20:58:13 -05:00
J. Nick KostonandGitHub 6aab523dd9 [esp32_ble] Log connection parameter update results (#18607) 2026-08-22 20:57:58 -05:00
J. Nick KostonandGitHub 1f31e51446 [esphome] Inline the trivial OTA port accessors (#18625) 2026-08-22 20:57:37 -05:00
J. Nick KostonandGitHub c062d0c717 [ota] Log prepare, upload, and total OTA timing in espota2 (#18582) 2026-08-22 20:57:13 -05:00
J. Nick KostonandGitHub dcabaedff1 [bk72xx_ble] Block BK7238 until the LibreTiny bonding partition fix lands (#18649) 2026-08-23 11:59:24 +12:00
d119ad6c60 [usb_uart] Extract non-final USBUartChannelBase from USBUartChannel (#17472)
Co-authored-by: p1ngb4ck <p@local>
2026-08-22 10:47:46 -05:00
esphome[bot]andGitHub 74fc2e367a Bump bundled esphome-device-builder to 1.12.4 (#18651) 2026-08-22 15:42:53 +00:00
ea10f94376 [core] Add type annotations to component Python (10/11) (#18347)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-08-22 10:31:47 -05:00
Brandon HarveyGitHubpre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>J. Nick KostonBluetooth Devices Bot
ef1d77885d [captive_portal] Show each network once in the scan list (#17847)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Bluetooth Devices Bot <bluetooth@koston.org>
2026-08-22 05:04:08 +00:00
Keith BurzinskiandGitHub dccf55eadc [remote_transmitter] Use hardware PWM on rtl87xx to fix watchdog crash (#18579) 2026-08-21 23:53:16 -05:00
65704e881f [mitsubishi_cn105] Add Fahrenheit support (#15488)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-21 23:52:18 -05:00
Brandon HarveyandGitHub a30e82459f [deep_sleep] Reject wakeup_pin_mode at both levels on BK72xx (#18615) 2026-08-21 23:51:19 -05:00
Brandon HarveyandGitHub 5a300e92f1 [wifi] Inline the trivial WiFiScanResult accessors (#18613) 2026-08-22 04:14:56 +00:00
8e9fb0f93c [remote_transmitter] Fix repeat gap timing on LibreTiny Beken (#18585)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-08-21 23:10:47 -05:00
286 changed files with 6796 additions and 2159 deletions
+1
View File
@@ -381,6 +381,7 @@ esphome/components/nextion/switch/* @senexcrenshaw
esphome/components/nextion/text_sensor/* @senexcrenshaw
esphome/components/nfc/* @jesserockz @kbx81
esphome/components/noblex/* @AGalfra
esphome/components/noise/* @esphome/core
esphome/components/npi19/* @bakerkj
esphome/components/nrf52/* @tomaszduda23
esphome/components/number/* @esphome/core
+36 -3
View File
@@ -23,7 +23,8 @@ For this repository there are two trusted inputs by design:
1. **The configuration.** Anyone who can supply or edit a YAML config is trusted
(see below).
2. **Authenticated peers of a running device** — clients holding the device's
API encryption key / password, OTA password, or web server credentials.
API/OTA encryption key, API password, OTA password, or web server
credentials.
The security boundary is therefore **unauthenticated network traffic vs. those
trusted inputs.** A bug that lets an unauthenticated attacker cross it is a
@@ -76,8 +77,8 @@ These *are* security bugs in this repo, and we want to hear about them privately
captive portal, etc.) **without** valid credentials.
- Authentication or encryption bypass on the device — reaching API calls, OTA
updates, or the web server without the configured key/password.
- Flaws that weaken the device's API encryption (Noise), OTA, or web server auth
below their documented guarantees.
- Flaws that weaken the device's API or OTA encryption (Noise), OTA auth, or
web server auth below their documented guarantees.
## The web server is an open HTTP API by design
@@ -121,6 +122,38 @@ and any memory-safety or protocol bug in the server reachable without credential
This section documents the current design and scope; it is not a judgment that the
design is optimal or that it will not change.
## OTA update encryption
The `esphome` OTA platform optionally encrypts updates with the same Noise
`NNpsk0` pattern the native API uses; one key protects the device. With an
`encryption:` block configured the guarantees are: the firmware image is
confidential in transit, the uploader is authenticated by the pre-shared key,
and the plaintext negotiation preceding the handshake is bound into the
handshake prologue, so stripping or tampering with it fails the first MAC.
Both ends fail closed with no override: a device built with a key refuses
plaintext uploads, and the CLI refuses to send plaintext when a key is
configured.
Defeating any of that without the key is in scope: a keyed device accepting a
plaintext or downgraded upload, getting past the MAC, or recovering image
contents from captured traffic.
The following are **not** vulnerabilities, by design:
- Plaintext OTA on a device with no `encryption:` block. That is the
documented default, authenticated (if at all) by the OTA password.
- The enablement window: turning encryption on takes one last upload of the
encryption-enabled firmware over the existing plaintext channel, with the
pre-existing plaintext exposure.
- The captive portal's update endpoint. `captive_portal:` auto-loads the web
OTA platform, and while the fallback AP is active its plaintext `/update`
endpoint is reachable on that AP; validation warns about the combination.
Combining `encryption:` with the always-on `web_server` component is a
config error instead.
- CLI retry behavior on transport or MAC failures; every attempt renegotiates
a fresh handshake with fresh ephemerals, so retrying does not weaken
authentication.
## Explicitly out of scope
- Local attackers who already have shell access on the host that runs `esphome`.
+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.12.3
RUN uv pip install --no-cache-dir esphome-device-builder==1.12.4
RUN \
platformio settings set enable_telemetry No \
+30 -3
View File
@@ -26,7 +26,9 @@ from esphome.const import (
CONF_DEASSERT_RTS_DTR,
CONF_DISABLED,
CONF_DISCOVER_IP,
CONF_ENCRYPTION,
CONF_ESPHOME,
CONF_KEY,
CONF_LEVEL,
CONF_LOG,
CONF_LOG_TOPIC,
@@ -762,9 +764,11 @@ def _wrap_to_code(name, comp, yaml_util):
async def wrapped(conf):
cg.add(cg.LineComment(f"{name}:"))
if comp.config_schema is not None:
conf_str = yaml_util.dump(conf)
# sort_keys: voluptuous fills defaults in set order, so an
# unsorted dump would churn main.cpp and relink every run
conf_str = yaml_util.dump(conf, sort_keys=True)
conf_str = conf_str.replace("//", "")
# remove tailing \ to avoid multi-line comment warning
# remove trailing \ to avoid multi-line comment warning
conf_str = conf_str.replace("\\\n", "\n")
cg.add(cg.LineComment(indent(conf_str)))
await coro(conf)
@@ -1321,6 +1325,17 @@ def _upload_via_native_api(
remote_port = int(ota_conf[CONF_PORT])
password = ota_conf.get(CONF_PASSWORD)
# Final validate resolved a bare `encryption:` block to the api key.
# Fail closed: if the block is present but no key was resolved, never
# fall back to a plaintext upload of an image that carries credentials.
noise_psk = None
if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is not None:
noise_psk = encryption_conf.get(CONF_KEY)
if not noise_psk:
raise EsphomeError(
"OTA encryption is configured but no key was resolved; "
"set the key under 'ota: encryption:' or 'api: encryption:'"
)
def check_partition_access(option_string: str) -> None:
if not ota_conf.get("allow_partition_access"):
@@ -1351,7 +1366,9 @@ def _upload_via_native_api(
if ota_type == espota2.OTA_TYPE_UPDATE_BOOTLOADER:
_validate_bootloader_binary(binary)
return espota2.run_ota(network_devices, remote_port, password, binary, ota_type)
return espota2.run_ota(
network_devices, remote_port, password, binary, ota_type, noise_psk
)
def _upload_via_web_server(
@@ -1360,6 +1377,16 @@ def _upload_via_web_server(
from esphome import web_server_ota
from esphome.web_server_helpers import get_web_server_connection
if any(
ota_item.get(CONF_PLATFORM) == CONF_ESPHOME
and ota_item.get(CONF_ENCRYPTION) is not None
for ota_item in config.get(CONF_OTA, [])
):
_LOGGER.warning(
"This config has OTA encryption, but the web_server OTA path sends "
"the image over plaintext HTTP; use the esphome OTA platform to "
"keep it confidential"
)
remote_port, username, password = get_web_server_connection(config)
return web_server_ota.run_ota(
network_devices, remote_port, username, password, binary
+4 -1
View File
@@ -1,3 +1,5 @@
from typing import Any
from esphome import pins
import esphome.codegen as cg
from esphome.components.esp32 import (
@@ -16,6 +18,7 @@ from esphome.components.esp32 import (
import esphome.config_validation as cv
from esphome.const import CONF_ANALOG, CONF_INPUT, CONF_NUMBER, PLATFORM_ESP8266
from esphome.core import CORE
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
@@ -225,7 +228,7 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = {
}
def validate_adc_pin(value):
def validate_adc_pin(value: Any) -> ConfigType | str:
if str(value).upper() == "VCC":
if CORE.is_rp2:
return pins.internal_gpio_input_pin_schema(29)
+3 -3
View File
@@ -52,7 +52,7 @@ _attenuation = cv.enum(ATTENUATION_MODES, lower=True)
_sampling_mode = cv.enum(SAMPLING_MODES, lower=True)
def validate_config(config):
def validate_config(config: ConfigType) -> ConfigType:
if config[CONF_RAW] and config.get(CONF_ATTENUATION, None) == "auto":
raise cv.Invalid("Automatic attenuation cannot be used when raw output is set")
@@ -120,7 +120,7 @@ CONFIG_SCHEMA = cv.All(
CONF_ADC_CHANNEL_ID = "adc_channel_id"
def _overlay_io_channels():
def _overlay_io_channels() -> str:
channel_count = CORE.data[CONF_ADC_CHANNEL_ID]
entries = ", ".join(f"<&adc {channel_id}>" for channel_id in range(channel_count))
return f"""
@@ -132,7 +132,7 @@ def _overlay_io_channels():
"""
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await sensor.register_sensor(var, config)
+46 -42
View File
@@ -1,10 +1,19 @@
import base64
import logging
from typing import Any
from esphome import automation
from esphome.automation import Condition
import esphome.codegen as cg
from esphome.components.logger import request_log_listener
# ENCRYPTION_SCHEMA and validate_encryption_key are re-exported for external
# components and downstream consumers that import them from api
from esphome.components.noise import ( # noqa: F401
ENCRYPTION_SCHEMA,
decode_encryption_key,
encryption_schema,
validate_encryption_key,
)
from esphome.config_helpers import get_logger_level
import esphome.config_validation as cv
from esphome.const import (
@@ -37,6 +46,10 @@ from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_pr
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigFragmentType, ConfigType
# Compat alias: downstream consumers (e.g. device-builder) referenced the
# schema by its old private name before it moved to the noise component
_encryption_schema = encryption_schema
_LOGGER = logging.getLogger(__name__)
DOMAIN = "api"
@@ -45,9 +58,15 @@ CODEOWNERS = ["@esphome/core"]
def AUTO_LOAD(config: ConfigType) -> list[str]:
"""Conditionally auto-load json only when capture_response is used."""
"""Conditionally auto-load noise (encryption) and json (capture_response)."""
base = ["socket"]
# A falsy config is a tooling probe for the maximal set (None from
# dependency resolution, {} from the components-graph platform probe);
# a validated config always carries defaults, never empty
if not config or CONF_ENCRYPTION in config:
base = base + ["noise"]
# Check if any homeassistant.action/homeassistant.service has capture_response: true
# This flag is set during config validation in _validate_response_config
if not config or CORE.data.get(DOMAIN, {}).get(CONF_CAPTURE_RESPONSE, False):
@@ -129,20 +148,6 @@ def _register_provisioning_source(config: ConfigType) -> ConfigType:
return config
def validate_encryption_key(value):
value = cv.string_strict(value)
try:
decoded = base64.b64decode(value, validate=True)
except ValueError as err:
raise cv.Invalid("Invalid key format, please check it's using base64") from err
if len(decoded) != 32:
raise cv.Invalid("Encryption key must be base64 and 32 bytes long")
# Return original data for roundtrip conversion
return value
CONF_SUPPORTS_RESPONSE = "supports_response"
# Enum values in api::enums namespace
@@ -217,7 +222,7 @@ def _auto_detect_supports_response(config: ConfigType) -> ConfigType:
return config
def _validate_supports_response(value):
def _validate_supports_response(value: Any) -> str:
"""Validate supports_response after auto-detection has set the value."""
return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value)
@@ -249,18 +254,6 @@ ACTIONS_SCHEMA = automation.validate_automation(
),
)
ENCRYPTION_SCHEMA = cv.Schema(
{
cv.Optional(CONF_KEY): cv.sensitive(validate_encryption_key),
}
)
def _encryption_schema(config):
if config is None:
config = {}
return ENCRYPTION_SCHEMA(config)
def _consume_api_sockets(config: ConfigType) -> ConfigType:
"""Register socket needs for API component."""
@@ -296,7 +289,7 @@ CONFIG_SCHEMA = cv.All(
CONF_SERVICES, group_of_exclusion=CONF_ACTIONS
): ACTIONS_SCHEMA,
cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA,
cv.Optional(CONF_ENCRYPTION): _encryption_schema,
cv.Optional(CONF_ENCRYPTION): encryption_schema,
cv.Optional(CONF_BATCH_DELAY, default="100ms"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(max=cv.TimePeriod(milliseconds=65535)),
@@ -393,7 +386,7 @@ async def to_code(config: ConfigType) -> None:
if actions := config.get(CONF_ACTIONS, []):
# Collect all triggers first, then register all at once with initializer_list
triggers: list[cg.Pvariable] = []
triggers: list[cg.MockObj] = []
for conf in actions:
func_args: list[tuple[MockObj, str]] = []
service_template_args: list[MockObj] = [] # User service argument types
@@ -483,7 +476,7 @@ async def to_code(config: ConfigType) -> None:
if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None:
if key := encryption_config.get(CONF_KEY):
decoded = base64.b64decode(key)
decoded = decode_encryption_key(key)
cg.add(var.set_noise_psk(list(decoded)))
cg.add_define("USE_API_NOISE_PSK_FROM_YAML")
else:
@@ -497,10 +490,6 @@ async def to_code(config: ConfigType) -> None:
# and plaintext disabled. Only a factory reset can remove it.
cg.add_define("USE_API_PLAINTEXT")
cg.add_define("USE_API_NOISE")
cg.add_library("esphome/noise-c", "0.1.21")
# Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
cg.add_build_flag("-DHAVE_INLINE_ASM=1")
else:
cg.add_define("USE_API_PLAINTEXT")
@@ -581,7 +570,7 @@ async def homeassistant_service_to_code(
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
):
) -> MockObj:
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
serv = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, serv, False)
@@ -647,7 +636,7 @@ async def homeassistant_service_to_code(
return var
def validate_homeassistant_event(value):
def validate_homeassistant_event(value: Any) -> str:
value = cv.string(value)
if not value.startswith("esphome."):
raise cv.Invalid(
@@ -676,7 +665,12 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema(
HOMEASSISTANT_EVENT_ACTION_SCHEMA,
synchronous=True,
)
async def homeassistant_event_to_code(config, action_id, template_arg, args):
async def homeassistant_event_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
serv = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, serv, True)
@@ -724,7 +718,12 @@ HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA = cv.maybe_simple_value(
HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA,
synchronous=True,
)
async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, args):
async def homeassistant_tag_scanned_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
serv = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, serv, True)
@@ -740,7 +739,7 @@ CONF_SUCCESS = "success"
CONF_ERROR_MESSAGE = "error_message"
def _validate_api_respond_data(config):
def _validate_api_respond_data(config: ConfigType) -> ConfigType:
"""Set flag during validation so AUTO_LOAD can include json component."""
if CONF_DATA in config:
CORE.data.setdefault(DOMAIN, {})[CONF_CAPTURE_RESPONSE] = True
@@ -824,7 +823,12 @@ API_CONNECTED_CONDITION_SCHEMA = cv.Schema(
@automation.register_condition(
"api.connected", APIConnectedCondition, API_CONNECTED_CONDITION_SCHEMA
)
async def api_connected_to_code(config, condition_id, template_arg, args):
async def api_connected_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(condition_id, template_arg)
templ = await cg.templatable(config[CONF_STATE_SUBSCRIPTION_ONLY], args, cg.bool_)
cg.add(var.set_state_subscription_only(templ))
+10 -10
View File
@@ -412,15 +412,15 @@ void APIConnection::finalize_iterator_sync_() {
}
void APIConnection::process_iterator_batch_(ComponentIterator &iterator) {
size_t initial_size = this->deferred_batch_.size();
size_t max_batch = MAX_INITIAL_PER_BATCH;
while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < max_batch) {
iterator.advance();
}
// Budget by remaining batch capacity so a pass cannot overfill the batch;
// stops early on a refused send and resumes next loop pass
size_t batch_size = this->deferred_batch_.size();
if (batch_size < MAX_INITIAL_BATCH_SIZE)
iterator.try_advance(MAX_INITIAL_BATCH_SIZE - batch_size);
// If the batch is full, process it immediately
// Note: iterator.advance() already calls schedule_batch_() via schedule_message_()
if (this->deferred_batch_.size() >= max_batch) {
// Flush immediately once enough is queued (not guaranteed every pass);
// partial batches go out via the batch timer or finalize_iterator_sync_()
if (this->deferred_batch_.size() >= MAX_INITIAL_BATCH_SIZE) {
this->process_batch_();
}
}
@@ -2130,7 +2130,7 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
}
#endif
psk_t psk{};
noise::psk_t psk{};
if (msg.key_len == 0) {
if (this->parent_->clear_noise_psk(true)) {
resp.success = true;
@@ -2139,7 +2139,7 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
}
} else if (base64_decode(msg.key, msg.key_len, psk.data(), psk.size()) != psk.size()) {
ESP_LOGW(TAG, "Invalid encryption key length");
} else if (APINoiseContext::is_all_zeros(psk)) {
} else if (noise::NoiseContext::is_all_zeros(psk)) {
// Accepting the reserved provisioning PSK would report success without
// enabling encryption (or silently clear an existing key)
ESP_LOGW(TAG, "Rejecting all-zero encryption key");
+4 -4
View File
@@ -53,11 +53,11 @@ void log_dropped_message(const char *tag, int line, const LogString *what);
// Keepalive timeout in milliseconds
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
// Maximum number of entities to process in a single batch during initial state/info sending
static constexpr size_t MAX_INITIAL_PER_BATCH = 34;
// Deferred batch size cap during initial state/info sync
static constexpr size_t MAX_INITIAL_BATCH_SIZE = 34;
// Verify MAX_MESSAGES_PER_BATCH (defined in api_frame_helper.h) can hold the initial batch
static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH,
"MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_PER_BATCH");
static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_BATCH_SIZE,
"MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_BATCH_SIZE");
#ifdef USE_BENCHMARK
class APIConnection;
+1 -1
View File
@@ -36,7 +36,7 @@ static constexpr uint16_t MAX_MESSAGE_SIZE = 32768; // 32 KiB for ESP32 and oth
static constexpr uint16_t RX_BUF_NULL_TERMINATOR = 1;
// Maximum number of messages to batch in a single write operation
// Must be >= MAX_INITIAL_PER_BATCH in api_connection.h (enforced by static_assert there)
// Must be >= MAX_INITIAL_BATCH_SIZE in api_connection.h (enforced by static_assert there)
static constexpr size_t MAX_MESSAGES_PER_BATCH = 34;
// Max client name length (e.g., "Home Assistant 2026.1.0.dev0" = 28 chars)
+55 -155
View File
@@ -2,9 +2,9 @@
#ifdef USE_API
#ifdef USE_API_NOISE
#include "api_connection.h" // For ClientInfo struct
#include "esphome/components/noise/noise.h"
#include "esphome/core/application.h"
#include "esphome/core/entity_base.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "proto.h"
@@ -17,6 +17,14 @@
namespace esphome::api {
using noise::noise_err_to_logstr;
// api_frame_helper.h keeps its own MAX_HANDSHAKE_SIZE because that header is
// also compiled in plaintext-only builds without the noise component; keep
// the two definitions from drifting apart.
static_assert(MAX_HANDSHAKE_SIZE == noise::MAX_HANDSHAKE_SIZE,
"api and noise component handshake size limits must match");
static const char *const TAG = "api.noise";
#ifdef USE_ESP8266
static constexpr char PROLOGUE_INIT[] PROGMEM = "NoiseAPIInit";
@@ -51,45 +59,6 @@ static constexpr size_t API_MAX_LOG_BYTES = 168;
#define LOG_PACKET_RECEIVED(buffer) ((void) 0)
#endif
/// Convert a noise error code to a readable error
const LogString *noise_err_to_logstr(int err) {
if (err == NOISE_ERROR_NO_MEMORY)
return LOG_STR("NO_MEMORY");
if (err == NOISE_ERROR_UNKNOWN_ID)
return LOG_STR("UNKNOWN_ID");
if (err == NOISE_ERROR_UNKNOWN_NAME)
return LOG_STR("UNKNOWN_NAME");
if (err == NOISE_ERROR_MAC_FAILURE)
return LOG_STR("MAC_FAILURE");
if (err == NOISE_ERROR_NOT_APPLICABLE)
return LOG_STR("NOT_APPLICABLE");
if (err == NOISE_ERROR_SYSTEM)
return LOG_STR("SYSTEM");
if (err == NOISE_ERROR_REMOTE_KEY_REQUIRED)
return LOG_STR("REMOTE_KEY_REQUIRED");
if (err == NOISE_ERROR_LOCAL_KEY_REQUIRED)
return LOG_STR("LOCAL_KEY_REQUIRED");
if (err == NOISE_ERROR_PSK_REQUIRED)
return LOG_STR("PSK_REQUIRED");
if (err == NOISE_ERROR_INVALID_LENGTH)
return LOG_STR("INVALID_LENGTH");
if (err == NOISE_ERROR_INVALID_PARAM)
return LOG_STR("INVALID_PARAM");
if (err == NOISE_ERROR_INVALID_STATE)
return LOG_STR("INVALID_STATE");
if (err == NOISE_ERROR_INVALID_NONCE)
return LOG_STR("INVALID_NONCE");
if (err == NOISE_ERROR_INVALID_PRIVATE_KEY)
return LOG_STR("INVALID_PRIVATE_KEY");
if (err == NOISE_ERROR_INVALID_PUBLIC_KEY)
return LOG_STR("INVALID_PUBLIC_KEY");
if (err == NOISE_ERROR_INVALID_FORMAT)
return LOG_STR("INVALID_FORMAT");
if (err == NOISE_ERROR_INVALID_SIGNATURE)
return LOG_STR("INVALID_SIGNATURE");
return LOG_STR("UNKNOWN");
}
/// Initialize the frame helper, returns OK if successful.
APIError APINoiseFrameHelper::init() {
APIError err = init_common_();
@@ -194,9 +163,9 @@ APIError APINoiseFrameHelper::loop() {
*/
APIError APINoiseFrameHelper::try_read_frame_() {
// read header
if (rx_header_buf_len_ < 3) {
if (rx_header_buf_len_ < noise::FRAME_HEADER_SIZE) {
// no header information yet
uint8_t to_read = 3 - rx_header_buf_len_;
uint8_t to_read = static_cast<uint8_t>(noise::FRAME_HEADER_SIZE) - rx_header_buf_len_;
ssize_t received = this->socket_->read(&rx_header_buf_[rx_header_buf_len_], to_read);
APIError err = handle_socket_read_result_(received);
if (err != APIError::OK) {
@@ -208,7 +177,7 @@ APIError APINoiseFrameHelper::try_read_frame_() {
return APIError::WOULD_BLOCK;
}
if (rx_header_buf_[0] != 0x01) {
if (rx_header_buf_[0] != noise::FRAME_INDICATOR) {
state_ = State::FAILED;
HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]);
return APIError::BAD_INDICATOR;
@@ -348,15 +317,15 @@ APIError APINoiseFrameHelper::state_action_server_hello_() {
return APIError::OK;
}
APIError APINoiseFrameHelper::state_action_handshake_() {
int action = noise_handshakestate_get_action(this->handshake_);
if (action == NOISE_ACTION_READ_MESSAGE) {
noise::NoiseResponderHandshake::Action action = this->handshake_.action();
if (action == noise::NoiseResponderHandshake::Action::ACTION_READ) {
return this->state_action_handshake_read_();
} else if (action == NOISE_ACTION_WRITE_MESSAGE) {
} else if (action == noise::NoiseResponderHandshake::Action::ACTION_WRITE) {
return this->state_action_handshake_write_();
}
// bad state for action
this->state_ = State::FAILED;
HELPER_LOG("Bad action for handshake: %d", action);
HELPER_LOG("Bad action for handshake: %d", (int) action);
return APIError::HANDSHAKESTATE_BAD_STATE;
}
APIError APINoiseFrameHelper::state_action_handshake_read_() {
@@ -368,20 +337,16 @@ APIError APINoiseFrameHelper::state_action_handshake_read_() {
if (this->rx_buf_.empty()) {
this->send_explicit_handshake_reject_(LOG_STR("Empty handshake message"));
return APIError::BAD_HANDSHAKE_ERROR_BYTE;
} else if (this->rx_buf_[0] != 0x00) {
} else if (this->rx_buf_[0] != noise::HANDSHAKE_STATUS_OK) {
HELPER_LOG("Bad handshake error byte: %u", this->rx_buf_[0]);
this->send_explicit_handshake_reject_(LOG_STR("Bad handshake error byte"));
return APIError::BAD_HANDSHAKE_ERROR_BYTE;
}
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_input(mbuf, this->rx_buf_.data() + 1, this->rx_buf_.size() - 1);
int err = noise_handshakestate_read_message(this->handshake_, &mbuf, nullptr);
int err = this->handshake_.read_message(this->rx_buf_.data() + 1, this->rx_buf_.size() - 1);
if (err != 0) {
// Special handling for MAC failure
this->send_explicit_handshake_reject_(err == NOISE_ERROR_MAC_FAILURE ? LOG_STR("Handshake MAC failure")
: LOG_STR("Handshake error"));
this->send_explicit_handshake_reject_(noise::reject_reason_for(err));
return this->handle_noise_error_(err, LOG_STR("noise_handshakestate_read_message"),
APIError::HANDSHAKESTATE_READ_FAILED);
}
@@ -390,18 +355,16 @@ APIError APINoiseFrameHelper::state_action_handshake_read_() {
}
APIError APINoiseFrameHelper::state_action_handshake_write_() {
uint8_t buffer[65];
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_output(mbuf, buffer + 1, sizeof(buffer) - 1);
size_t msg_len = 0;
int err = noise_handshakestate_write_message(this->handshake_, &mbuf, nullptr);
int err = this->handshake_.write_message(buffer + 1, sizeof(buffer) - 1, msg_len);
APIError aerr = this->handle_noise_error_(err, LOG_STR("noise_handshakestate_write_message"),
APIError::HANDSHAKESTATE_WRITE_FAILED);
if (aerr != APIError::OK)
return aerr;
buffer[0] = 0x00; // success
buffer[0] = noise::HANDSHAKE_STATUS_OK;
aerr = this->write_frame_(buffer, mbuf.size + 1);
aerr = this->write_frame_(buffer, msg_len + 1);
if (aerr != APIError::OK)
return aerr;
return this->check_handshake_finished_();
@@ -409,33 +372,22 @@ APIError APINoiseFrameHelper::state_action_handshake_write_() {
void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reason) {
// Max reject message: "Bad handshake packet len" (24) + 1 (failure byte) = 25 bytes
uint8_t data[32];
data[0] = 0x01; // failure
#ifdef USE_STORE_LOG_STR_IN_FLASH
// On ESP8266 with flash strings, we need to use PROGMEM-aware functions
size_t reason_len = strlen_P(reinterpret_cast<PGM_P>(reason));
reason_len = std::min(reason_len, sizeof(data) - 1);
if (reason_len > 0) {
memcpy_P(data + 1, reinterpret_cast<PGM_P>(reason), reason_len);
}
#else
// Normal memory access
const char *reason_str = LOG_STR_ARG(reason);
size_t reason_len = strlen(reason_str);
reason_len = std::min(reason_len, sizeof(data) - 1);
if (reason_len > 0) {
// NOLINTNEXTLINE(bugprone-not-null-terminated-result) - binary protocol, not a C string
std::memcpy(data + 1, reason_str, reason_len);
}
#endif
size_t data_size = reason_len + 1;
static_assert(sizeof(data) >= noise::MAC_FAILURE_PAYLOAD_SIZE,
"reject buffer must fit the MAC failure wire contract");
size_t data_size = noise::format_reject_payload(data, sizeof(data), reason);
// temporarily remove failed state
auto orig_state = state_;
state_ = State::EXPLICIT_REJECT;
write_frame_(data, data_size);
state_ = orig_state;
APIError aerr = write_frame_(data, data_size);
if (aerr != APIError::OK) {
// Best effort; the reject reason is a diagnosis aid, not a protocol step
ESP_LOGW(TAG, "Sending handshake reject failed: %d", (int) aerr);
}
if (state_ == State::EXPLICIT_REJECT) {
// write_frame_ may have moved the state to FAILED; keep that decision
state_ = orig_state;
}
}
APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) {
APIError aerr = this->check_data_state_();
@@ -492,12 +444,10 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) {
// Returns APIError::OK on success.
APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type,
uint16_t &encrypted_len_out) {
// Write noise header
buf_start[0] = 0x01; // indicator
// buf_start[1], buf_start[2] to be set after encryption
// The noise frame header is written after encryption, when the size is known
// Write message header (to be encrypted)
constexpr uint8_t msg_offset = 3;
constexpr uint8_t msg_offset = noise::FRAME_HEADER_SIZE;
buf_start[msg_offset] = static_cast<uint8_t>(message_type >> 8); // type high byte
buf_start[msg_offset + 1] = static_cast<uint8_t>(message_type); // type low byte
buf_start[msg_offset + 2] = static_cast<uint8_t>(payload_size >> 8); // data_len high byte
@@ -515,11 +465,10 @@ APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_
if (aerr != APIError::OK)
return aerr;
// Fill in the encrypted size
buf_start[1] = static_cast<uint8_t>(mbuf.size >> 8);
buf_start[2] = static_cast<uint8_t>(mbuf.size);
// Fill in the frame header now that the encrypted size is known
noise::write_frame_header(buf_start, static_cast<uint16_t>(mbuf.size));
encrypted_len_out = static_cast<uint16_t>(3 + mbuf.size); // indicator + size + encrypted data
encrypted_len_out = static_cast<uint16_t>(noise::FRAME_HEADER_SIZE + mbuf.size);
return APIError::OK;
}
@@ -568,21 +517,19 @@ APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, s
}
APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
uint8_t header[3];
header[0] = 0x01; // indicator
header[1] = (uint8_t) (len >> 8);
header[2] = (uint8_t) len;
uint8_t header[noise::FRAME_HEADER_SIZE];
noise::write_frame_header(header, len);
if (len == 0) {
return this->write_raw_buf_(header, 3);
return this->write_raw_buf_(header, noise::FRAME_HEADER_SIZE);
}
struct iovec iov[2];
iov[0].iov_base = header;
iov[0].iov_len = 3;
iov[0].iov_len = noise::FRAME_HEADER_SIZE;
iov[1].iov_base = const_cast<uint8_t *>(data);
iov[1].iov_len = len;
return this->write_raw_iov_(iov, 2, 3 + len);
return this->write_raw_iov_(iov, 2, noise::FRAME_HEADER_SIZE + len);
}
/** Initiate the data structures for the handshake.
@@ -590,45 +537,12 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
* @return 0 on success, -1 on error (check errno)
*/
APIError APINoiseFrameHelper::init_handshake_() {
int err;
// Noise_NNpsk0_25519_ChaChaPoly_SHA256, built on the stack:
// noise_handshakestate_new_by_id copies it, so a member would waste
// 104 bytes per connection, and a static const would sit in RAM on
// ESP8266 (.rodata is DRAM there).
const NoiseProtocolId nid = {
.prefix_id = NOISE_PREFIX_STANDARD,
.pattern_id = NOISE_PATTERN_NN,
.modifier_ids = {NOISE_MODIFIER_PSK0},
.dh_id = NOISE_DH_CURVE25519,
.cipher_id = NOISE_CIPHER_CHACHAPOLY,
.hash_id = NOISE_HASH_SHA256,
.hybrid_id = NOISE_DH_NONE,
};
err = noise_handshakestate_new_by_id(&handshake_, &nid, NOISE_ROLE_RESPONDER);
APIError aerr =
handle_noise_error_(err, LOG_STR("noise_handshakestate_new_by_id"), APIError::HANDSHAKESTATE_SETUP_FAILED);
int err = this->handshake_.init(this->ctx_.get_psk(), prologue_.data(), prologue_.size());
APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshake_init"), APIError::HANDSHAKESTATE_SETUP_FAILED);
if (aerr != APIError::OK)
return aerr;
const auto &psk = this->ctx_.get_psk();
err = noise_handshakestate_set_pre_shared_key(handshake_, psk.data(), psk.size());
aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_set_pre_shared_key"),
APIError::HANDSHAKESTATE_SETUP_FAILED);
if (aerr != APIError::OK)
return aerr;
err = noise_handshakestate_set_prologue(handshake_, prologue_.data(), prologue_.size());
aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_set_prologue"), APIError::HANDSHAKESTATE_SETUP_FAILED);
if (aerr != APIError::OK)
return aerr;
// set_prologue copies it into handshakestate, so we can get rid of it now
// init copies the prologue into the handshakestate, so we can get rid of it now
prologue_.release();
err = noise_handshakestate_start(handshake_);
aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_start"), APIError::HANDSHAKESTATE_SETUP_FAILED);
if (aerr != APIError::OK)
return aerr;
return APIError::OK;
}
@@ -637,15 +551,17 @@ APIError APINoiseFrameHelper::check_handshake_finished_() {
assert(state_ == State::HANDSHAKE);
#endif
int action = noise_handshakestate_get_action(handshake_);
if (action == NOISE_ACTION_READ_MESSAGE || action == NOISE_ACTION_WRITE_MESSAGE)
noise::NoiseResponderHandshake::Action action = this->handshake_.action();
if (action == noise::NoiseResponderHandshake::Action::ACTION_READ ||
action == noise::NoiseResponderHandshake::Action::ACTION_WRITE)
return APIError::OK;
if (action != NOISE_ACTION_SPLIT) {
if (action != noise::NoiseResponderHandshake::Action::ACTION_SPLIT) {
state_ = State::FAILED;
HELPER_LOG("Bad action for handshake: %d", action);
HELPER_LOG("Bad action for handshake: %d", (int) action);
return APIError::HANDSHAKESTATE_BAD_STATE;
}
int err = noise_handshakestate_split(handshake_, &send_cipher_, &recv_cipher_);
// split() also frees the handshake state
int err = this->handshake_.split(send_cipher_, recv_cipher_);
APIError aerr =
handle_noise_error_(err, LOG_STR("noise_handshakestate_split"), APIError::HANDSHAKESTATE_SPLIT_FAILED);
if (aerr != APIError::OK)
@@ -654,17 +570,11 @@ APIError APINoiseFrameHelper::check_handshake_finished_() {
this->frame_footer_size_ = noise_cipherstate_get_mac_length(send_cipher_);
HELPER_LOG("Handshake complete!");
noise_handshakestate_free(handshake_);
handshake_ = nullptr;
state_ = State::DATA;
return APIError::OK;
}
APINoiseFrameHelper::~APINoiseFrameHelper() {
if (handshake_ != nullptr) {
noise_handshakestate_free(handshake_);
handshake_ = nullptr;
}
if (send_cipher_ != nullptr) {
noise_cipherstate_free(send_cipher_);
send_cipher_ = nullptr;
@@ -675,16 +585,6 @@ APINoiseFrameHelper::~APINoiseFrameHelper() {
}
}
extern "C" {
// declare how noise generates random bytes (here with a good HWRNG based on the RF system)
void noise_rand_bytes(void *output, size_t len) {
if (!esphome::random_bytes(reinterpret_cast<uint8_t *>(output), len)) {
ESP_LOGE(TAG, "Acquiring random bytes failed; rebooting");
arch_restart();
}
}
}
} // namespace esphome::api
#endif // USE_API_NOISE
#endif // USE_API
@@ -3,7 +3,7 @@
#ifdef USE_API
#ifdef USE_API_NOISE
#include "noise/protocol.h"
#include "api_noise_context.h"
#include "esphome/components/noise/noise_handshake.h"
namespace esphome::api {
@@ -14,9 +14,9 @@ class APINoiseFrameHelper final : public APIFrameHelper {
// Pos 1-2: encrypted payload size (16-bit big-endian)
// Pos 3-6: encrypted type (16-bit) + data_len (16-bit)
// Pos 7+: actual payload data
static constexpr uint8_t HEADER_PADDING = 1 + 2 + 2 + 2; // indicator + size + type + data_len
static constexpr uint8_t HEADER_PADDING = noise::FRAME_HEADER_SIZE + 2 + 2; // frame header + type + data_len
APINoiseFrameHelper(std::unique_ptr<socket::Socket> socket, APINoiseContext &ctx)
APINoiseFrameHelper(std::unique_ptr<socket::Socket> socket, noise::NoiseContext &ctx)
: APIFrameHelper(std::move(socket)), ctx_(ctx) {
frame_header_padding_ = HEADER_PADDING;
}
@@ -52,13 +52,13 @@ class APINoiseFrameHelper final : public APIFrameHelper {
APIError handle_handshake_frame_error_(APIError aerr);
APIError handle_noise_error_(int err, const LogString *func_name, APIError api_err);
// Pointers first (4 bytes each)
NoiseHandshakeState *handshake_{nullptr};
// Pointers first (4 bytes each; the handshake wrapper holds one pointer)
noise::NoiseResponderHandshake handshake_;
NoiseCipherState *send_cipher_{nullptr};
NoiseCipherState *recv_cipher_{nullptr};
// Reference to noise context (4 bytes on 32-bit)
APINoiseContext &ctx_;
noise::NoiseContext &ctx_;
// Buffer for noise handshake prologue (released after handshake)
APIBuffer prologue_;
@@ -67,7 +67,7 @@ class APINoiseFrameHelper final : public APIFrameHelper {
// Fixed-size header buffer for noise protocol:
// 1 byte for indicator + 2 bytes for message size (16-bit value, not varint)
// Note: Maximum message size is UINT16_MAX (65535), with a limit of 128 bytes during handshake phase
uint8_t rx_header_buf_[3];
uint8_t rx_header_buf_[noise::FRAME_HEADER_SIZE];
uint8_t rx_header_buf_len_ = 0;
// 4 bytes total, no padding
};
@@ -1,37 +0,0 @@
#pragma once
#include <array>
#include <cstdint>
#include "esphome/core/defines.h"
namespace esphome::api {
#ifdef USE_API_NOISE
using psk_t = std::array<uint8_t, 32>;
class APINoiseContext {
public:
// The all-zeros PSK is reserved: it marks the device as unprovisioned and
// doubles as the well-known provisioning PSK that unprovisioned devices
// accept for Noise handshakes (passive-sniffing protection only, no
// authentication). It is never a valid real key.
static bool is_all_zeros(const psk_t &psk) {
uint8_t acc = 0;
for (uint8_t b : psk) {
acc |= b;
}
return acc == 0;
}
void set_psk(psk_t psk) {
this->psk_ = psk;
this->has_psk_ = !is_all_zeros(psk);
}
const psk_t &get_psk() const { return this->psk_; }
bool has_psk() const { return this->has_psk_; }
protected:
psk_t psk_{};
bool has_psk_{false};
};
#endif // USE_API_NOISE
} // namespace esphome::api
+1 -11
View File
@@ -423,12 +423,6 @@ void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_
API_DISPATCH_UPDATE(alarm_control_panel::AlarmControlPanel, alarm_control_panel)
#endif
float APIServer::get_setup_priority() const { return setup_priority::AFTER_WIFI; }
void APIServer::set_port(uint16_t port) { this->port_ = port; }
void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = batch_delay; }
#ifdef USE_API_HOMEASSISTANT_SERVICES
void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) {
bool has_subscriber = false;
@@ -553,10 +547,6 @@ const std::vector<APIServer::HomeAssistantStateSubscription> &APIServer::get_sta
}
#endif
uint16_t APIServer::get_port() const { return this->port_; }
void APIServer::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; }
#ifdef USE_API_NOISE
bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg,
const LogString *fail_log_msg, bool make_active) {
@@ -598,7 +588,7 @@ bool APIServer::load_and_apply_noise_psk_() {
return true;
}
bool APIServer::save_noise_psk(psk_t psk, bool make_active) {
bool APIServer::save_noise_psk(noise::psk_t psk, bool make_active) {
#ifdef USE_API_NOISE_PSK_FROM_YAML
// When PSK is set from YAML, this function should never be called
// but if it is, reject the change
+14 -11
View File
@@ -5,7 +5,10 @@
#include "api_buffer.h"
// Must precede clients_ so APIConnection is complete for default_delete (libc++).
#include "api_connection.h"
#include "api_noise_context.h"
#ifdef USE_API_NOISE
// Only present in the build when the noise component is loaded
#include "esphome/components/noise/noise.h"
#endif
#include "api_pb2.h"
#include "api_pb2_service.h"
#include "esphome/components/socket/socket.h"
@@ -37,7 +40,7 @@ class UserServiceDescriptor;
#ifdef USE_API_NOISE
struct SavedNoisePsk {
psk_t psk;
noise::psk_t psk;
} PACKED; // NOLINT
#endif
@@ -51,8 +54,8 @@ class APIServer final : public Component,
public:
APIServer();
void setup() override;
uint16_t get_port() const;
float get_setup_priority() const override;
uint16_t get_port() const { return this->port_; }
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
void loop() override;
void dump_config() override;
void on_shutdown() override;
@@ -63,9 +66,9 @@ class APIServer final : public Component,
#ifdef USE_CAMERA
void on_camera_image(const std::shared_ptr<camera::CameraImage> &image) override;
#endif
void set_port(uint16_t port);
void set_reboot_timeout(uint32_t reboot_timeout);
void set_batch_delay(uint16_t batch_delay);
void set_port(uint16_t port) { this->port_ = port; }
void set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; }
void set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = batch_delay; }
uint16_t get_batch_delay() const { return batch_delay_; }
void set_listen_backlog(uint8_t listen_backlog) { this->listen_backlog_ = listen_backlog; }
@@ -73,10 +76,10 @@ class APIServer final : public Component,
APIBuffer &get_shared_buffer_ref() { return shared_write_buffer_; }
#ifdef USE_API_NOISE
bool save_noise_psk(psk_t psk, bool make_active = true);
bool save_noise_psk(noise::psk_t psk, bool make_active = true);
bool clear_noise_psk(bool make_active = true);
void set_noise_psk(psk_t psk) { this->noise_ctx_.set_psk(psk); }
APINoiseContext &get_noise_ctx() { return this->noise_ctx_; }
void set_noise_psk(noise::psk_t psk) { this->noise_ctx_.set_psk(psk); }
noise::NoiseContext &get_noise_ctx() { return this->noise_ctx_; }
#endif // USE_API_NOISE
void handle_disconnect(APIConnection *conn);
@@ -354,7 +357,7 @@ class APIServer final : public Component,
#endif
#ifdef USE_API_NOISE
APINoiseContext noise_ctx_;
noise::NoiseContext noise_ctx_;
ESPPreferenceObject noise_pref_;
#endif // USE_API_NOISE
};
+9 -1
View File
@@ -95,9 +95,17 @@ bool ListEntitiesIterator::on_end() { return this->client_->send_list_info_done(
ListEntitiesIterator::ListEntitiesIterator(APIConnection *client) : client_(client) {}
#ifdef USE_API_USER_DEFINED_ACTIONS
// Yield after every Nth service; bounds direct (non-batched) writes per loop pass
static constexpr uint8_t SERVICE_YIELD_INTERVAL = 3;
bool ListEntitiesIterator::on_service(UserServiceDescriptor *service) {
auto resp = service->encode_list_service_response();
return this->client_->send_message(resp);
if (!this->client_->send_message(resp))
return false;
// at_ is this service's index
if ((this->at_ + 1) % SERVICE_YIELD_INTERVAL == 0)
this->yield_after_step_();
return true;
}
#endif
@@ -5,6 +5,7 @@ from esphome.automation import Condition, maybe_simple_id
import esphome.codegen as cg
from esphome.components import mqtt, web_server, zigbee
from esphome.components.const import CONF_ON_STATE_CHANGE
from esphome.config_helpers import filter_source_files_from_defines
import esphome.config_validation as cv
from esphome.const import (
CONF_DELAY,
@@ -560,6 +561,11 @@ _CALLBACK_AUTOMATIONS = (
async def _build_binary_sensor_automations(var, config):
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
if config.get(CONF_ON_CLICK) or config.get(CONF_ON_DOUBLE_CLICK):
cg.add_define("USE_BINARY_SENSOR_CLICK_TRIGGER")
if config.get(CONF_ON_MULTI_CLICK):
cg.add_define("USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER")
for conf in config.get(CONF_ON_CLICK, []):
trigger = cg.new_Pvariable(
conf[CONF_TRIGGER_ID], var, conf[CONF_MIN_LENGTH], conf[CONF_MAX_LENGTH]
@@ -673,3 +679,15 @@ async def to_code(config):
async def binary_sensor_invalidate_state_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
return cg.new_Pvariable(action_id, template_arg, paren)
# automation.cpp only implements the click/double_click/multi_click triggers
FILTER_SOURCE_FILES = filter_source_files_from_defines(
{
"automation.cpp": (
"USE_BINARY_SENSOR_CLICK_TRIGGER",
"USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER",
),
"filter.cpp": "USE_BINARY_SENSOR_FILTER",
}
)
@@ -1,8 +1,13 @@
#include "esphome/core/defines.h"
#if defined(USE_BINARY_SENSOR_CLICK_TRIGGER) || defined(USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER)
#include "automation.h"
#include "esphome/core/log.h"
namespace esphome::binary_sensor {
#ifdef USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER
static const char *const TAG = "binary_sensor.automation";
// MultiClickTrigger timeout IDs.
@@ -120,6 +125,9 @@ void MultiClickTriggerBase::trigger_() {
this->trigger();
}
#endif // USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER
#ifdef USE_BINARY_SENSOR_CLICK_TRIGGER
bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length) {
if (max_length == 0) {
return length >= min_length;
@@ -127,4 +135,8 @@ bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length) {
return length >= min_length && length <= max_length;
}
}
#endif // USE_BINARY_SENSOR_CLICK_TRIGGER
} // namespace esphome::binary_sensor
#endif // USE_BINARY_SENSOR_CLICK_TRIGGER || USE_BINARY_SENSOR_MULTI_CLICK_TRIGGER
+15 -15
View File
@@ -4,9 +4,12 @@ The platform analog of esp32_ble / rp2040_ble: owns the Beken BDK BLE stack
bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build
on this component and contain no SDK calls of their own.
Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253
(BLE 5.2), and any future BLE-5.x SoC. Known non-5.x families are rejected in
to_code; unknown families are capability-checked at compile time via
Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7252N/BK7253 (BLE 5.2),
and any future BLE-5.x SoC. BK7238 (BLE 5.2) is blocked for now: with BLE
compiled in, the Beken SDK erases the bootloader flash sector at boot because
LibreTiny's partition table has no BLE bonding entry (esphome#18646,
libretiny-eu/libretiny#408). Known non-5.x families and BK7238 are rejected in
to_code. Unknown families are capability-checked at compile time via
`__has_include("app_ble.h")`, a header only on the BLE 5.x include path
(ble_api.h ships for every SoC, so it cannot be the probe). A non-5.x build
fails with a clear #error.
@@ -65,6 +68,14 @@ def _unsupported_family_message(family: str) -> str | None:
)
if family == FAMILY_BK7231Q:
return "bk72xx_ble does not support BK7231Q: this SoC has no BLE"
if family == FAMILY_BK7238:
return (
"bk72xx_ble is disabled on BK7238: with BLE compiled in, the Beken SDK "
"erases the bootloader flash sector at boot and the device can no longer "
"start (see https://github.com/esphome/esphome/issues/18646); support "
"returns once the LibreTiny partition table fix "
"(libretiny-eu/libretiny#408) is released"
)
return None
@@ -113,18 +124,7 @@ async def to_code(config: ConfigType) -> None:
# BK7231N, but NOT on BK7238 (its BLE stack has no such symbol; the address is
# derived from the WiFi MAC instead — the BDK's own fallback). Tell the C++
# which path is available so it doesn't reference a missing symbol.
family = libretiny.get_libretiny_family()
if family == FAMILY_BK7231N:
if libretiny.get_libretiny_family() == FAMILY_BK7231N:
cg.add_define("BK72XX_BLE_HAS_COMMON_BDADDR")
elif family == FAMILY_BK7238:
# ESPHome's LibreTiny disables BLE on BK7238 because the SDK can hang at
# WiFi STA startup when BLE init runs. This component re-enables BLE, so
# warn loudly: BK7238 is accepted but not hardware-verified and may be
# WiFi-unstable with BLE on.
_LOGGER.warning(
"bk72xx_ble on BK7238: enabling BLE is known to risk a WiFi STA startup "
"hang on this family and is not yet hardware-verified. Expect possible "
"instability."
)
cg.add_define("USE_BK72XX_BLE")
+27 -16
View File
@@ -206,32 +206,36 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType:
interval = config[CONF_INTERVAL]
window = config[CONF_WINDOW]
if window > interval:
raise cv.Invalid(
f"Scan window ({window}) needs to be smaller than scan interval ({interval})"
)
# Labels are reused in every error below; the optional one names its key.
windows = [("Scan window", window)]
if (connection_window := config.get(CONF_CONNECTION_SCAN_WINDOW)) is not None:
windows.append((CONF_CONNECTION_SCAN_WINDOW, connection_window))
for name, value in windows:
if value > interval:
raise cv.Invalid(
f"{name} ({value}) needs to be smaller than scan interval ({interval})"
)
# BLE scan interval/window are programmed in 0.625 ms units as a 16-bit value; the
# controller only accepts 2.5 ms .. 10240 ms (0x0004 .. 0x4000). Reject out-of-range
# values here instead of letting the unit conversion silently overflow.
for name, value in (("interval", interval), ("window", window)):
for name, value in (("Scan interval", interval), *windows):
if value.total_microseconds < 2500 or value.total_microseconds > 10_240_000:
raise cv.Invalid(
f"Scan {name} ({value}) must be between 2.5 ms and 10240 ms"
)
raise cv.Invalid(f"{name} ({value}) must be between 2.5 ms and 10240 ms")
# Validate what actually reaches the controller: both values are truncated to
# whole 0.625 ms units, so a window/interval pair that differs by less than one
# unit collapses to the same value — silently programming a 100 % duty cycle
# (radio permanently on) from a config that asked for less.
interval_units = to_ble_units(interval)
window_units = to_ble_units(window)
if window_units == interval_units and window < interval:
raise cv.Invalid(
f"Scan window ({window}) and interval ({interval}) both truncate to "
f"{interval_units} x 0.625 ms, which the controller scans at a 100 % duty "
f"cycle. Separate them by at least 0.625 ms."
)
for name, value in windows:
if to_ble_units(value) == interval_units and value < interval:
raise cv.Invalid(
f"{name} ({value}) and interval ({interval}) both truncate to "
f"{interval_units} x 0.625 ms, which the controller scans at a 100 % duty "
f"cycle. Separate them by at least 0.625 ms."
)
if interval.total_microseconds * 3 > duration.total_microseconds:
raise cv.Invalid(
@@ -247,11 +251,14 @@ def validate_scan_parameters(config: ConfigType) -> ConfigType:
# their own; also the fallback for esp32's conditional default.
DEFAULT_SCAN_WINDOW = "30ms"
CONF_CONNECTION_SCAN_WINDOW = "connection_scan_window"
def scan_parameters_schema(
interval_default: str,
*,
window_default: str | Callable[[], TimePeriod] = DEFAULT_SCAN_WINDOW,
connection_window: bool = False,
) -> cv.All:
"""Build the scan_parameters value schema shared by all BLE trackers.
@@ -263,7 +270,9 @@ def scan_parameters_schema(
can adjust it once sibling keys are resolved). The `active` option
(default on) is unconditional: active scanning is part of the tracker
contract every current proxy client assumes it, so a passive-only
tracker must not share this schema.
tracker must not share this schema. connection_window opts in to the
`connection_scan_window` option for trackers that can fall back to a
smaller window while a GATT connection is active.
"""
schema = {
cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds,
@@ -272,6 +281,8 @@ def scan_parameters_schema(
cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean,
cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
}
if connection_window:
schema[cv.Optional(CONF_CONNECTION_SCAN_WINDOW)] = cv.positive_time_period
return cv.All(cv.Schema(schema), validate_scan_parameters)
+13 -7
View File
@@ -16,14 +16,15 @@ from esphome.const import (
DEVICE_CLASS_RESTART,
DEVICE_CLASS_UPDATE,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_device_class,
setup_entity,
)
from esphome.cpp_generator import MockObjClass
from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType
from esphome.types import ConfigType, SafeExpType
CODEOWNERS = ["@esphome/core"]
IS_PLATFORM_COMPONENT = True
@@ -88,7 +89,7 @@ _CALLBACK_AUTOMATIONS = (
@setup_entity("button")
async def setup_button_core_(var, config):
async def setup_button_core_(var: MockObj, config: ConfigType) -> None:
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
setup_device_class(config)
@@ -101,7 +102,7 @@ async def setup_button_core_(var, config):
await web_server.add_entity_config(var, web_server_config)
async def register_button(var, config):
async def register_button(var: MockObj, config: ConfigType) -> None:
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
queue_entity_register("button", config)
@@ -109,7 +110,7 @@ async def register_button(var, config):
await setup_button_core_(var, config)
async def new_button(config, *args):
async def new_button(config: ConfigType, *args: SafeExpType) -> MockObj:
var = cg.new_Pvariable(config[CONF_ID], *args)
await register_button(var, config)
return var
@@ -125,11 +126,16 @@ BUTTON_PRESS_SCHEMA = maybe_simple_id(
@automation.register_action(
"button.press", PressAction, BUTTON_PRESS_SCHEMA, synchronous=True
)
async def button_press_to_code(config, action_id, template_arg, args):
async def button_press_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
paren = await cg.get_variable(config[CONF_ID])
return cg.new_Pvariable(action_id, template_arg, paren)
@coroutine_with_priority(CoroPriority.CORE)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
cg.add_global(button_ns.using)
@@ -6,6 +6,7 @@
#include "esphome/core/string_ref.h"
#include "esphome/components/wifi/wifi_component.h"
#include "captive_index.h"
#include "scan_list.h"
namespace esphome::captive_portal {
@@ -33,8 +34,10 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) {
// Invariant: only bounded in-memory work under the lock; the network send
// happens later in request->send()
wifi::ScanResultsLock lock(wifi::global_wifi_component);
for (const auto &scan : wifi::global_wifi_component->get_scan_result()) {
if (scan.get_is_hidden())
const auto &results = wifi::global_wifi_component->get_scan_result();
for (const auto &scan : results) {
bool with_auth = false;
if (!should_show_scan_entry(results, scan, with_auth))
continue;
json_escape_into_buffer(escaped_ssid, scan.get_ssid());
@@ -44,10 +47,10 @@ void CaptivePortal::handle_config(AsyncWebServerRequest *request) {
stream->print(ESPHOME_F("\",\"rssi\":"));
stream->print(scan.get_rssi());
stream->print(ESPHOME_F(",\"lock\":"));
stream->print(scan.get_with_auth());
stream->print(with_auth);
stream->print(ESPHOME_F("}"));
#else
stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", escaped_ssid, scan.get_rssi(), scan.get_with_auth());
stream->printf(R"(,{"ssid":"%s","rssi":%d,"lock":%d})", escaped_ssid, scan.get_rssi(), with_auth);
#endif
}
}
@@ -0,0 +1,28 @@
#pragma once
#include <cstdint>
namespace esphome::captive_portal {
// A scan lists every BSSID, so one SSID can appear several times. Returns true for
// the strongest entry per SSID (earliest on ties), never for hidden entries. scan
// must be an element of results. with_auth is written only when returning true and
// is set if any entry with that SSID needs a key. Templated for host tests.
template<typename Results, typename Entry>
bool should_show_scan_entry(const Results &results, const Entry &scan, bool &with_auth) {
if (scan.get_is_hidden())
return false;
const int8_t rssi = scan.get_rssi();
bool any_auth = false;
for (const auto &other : results) {
if (other.get_is_hidden() || !other.ssid_equals(scan))
continue;
// Same array, so address order is index order. scan fails both checks against itself.
if (other.get_rssi() > rssi || (other.get_rssi() == rssi && &other < &scan))
return false;
any_auth |= other.get_with_auth();
}
with_auth = any_auth;
return true;
}
} // namespace esphome::captive_portal
+21 -8
View File
@@ -1,3 +1,5 @@
from typing import Any
from esphome import automation
import esphome.codegen as cg
from esphome.components import mqtt, web_server
@@ -48,13 +50,19 @@ from esphome.const import (
CONF_VISUAL,
CONF_WEB_SERVER,
)
from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority
from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_entity,
)
from esphome.cpp_generator import LambdaExpression, MockObjClass
from esphome.cpp_generator import (
LambdaExpression,
MockObj,
MockObjClass,
TemplateArgsType,
)
from esphome.types import ConfigType, SafeExpType
IS_PLATFORM_COMPONENT = True
@@ -132,7 +140,7 @@ VISUAL_TEMPERATURE_STEP_SCHEMA = cv.Schema(
)
def visual_temperature_step(value):
def visual_temperature_step(value: Any) -> ConfigType:
# Allow defining target/current temperature steps separately
if isinstance(value, dict):
return VISUAL_TEMPERATURE_STEP_SCHEMA(value)
@@ -273,7 +281,7 @@ def climate_schema(
@setup_entity("climate")
async def setup_climate_core_(var, config):
async def setup_climate_core_(var: MockObj, config: ConfigType) -> None:
visual = config.get(CONF_VISUAL, {})
if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None:
cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES")
@@ -443,7 +451,7 @@ async def setup_climate_core_(var, config):
await web_server.add_entity_config(var, web_server_config)
async def register_climate(var, config):
async def register_climate(var: MockObj, config: ConfigType) -> None:
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
queue_entity_register("climate", config)
@@ -451,7 +459,7 @@ async def register_climate(var, config):
await setup_climate_core_(var, config)
async def new_climate(config, *args):
async def new_climate(config: ConfigType, *args: SafeExpType) -> MockObj:
var = cg.new_Pvariable(config[CONF_ID], *args)
await register_climate(var, config)
return var
@@ -485,7 +493,12 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema(
CLIMATE_CONTROL_ACTION_SCHEMA,
synchronous=True,
)
async def climate_control_to_code(config, action_id, template_arg, args):
async def climate_control_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
paren = await cg.get_variable(config[CONF_ID])
# All configured fields are folded into a single stateless lambda whose
@@ -549,5 +562,5 @@ async def climate_control_to_code(config, action_id, template_arg, args):
@coroutine_with_priority(CoroPriority.CORE)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
cg.add_global(climate_ns.using)
-23
View File
@@ -511,29 +511,6 @@ ClimateTraits Climate::get_traits() {
return traits;
}
#ifdef USE_CLIMATE_VISUAL_OVERRIDES
void Climate::set_visual_min_temperature_override(float visual_min_temperature_override) {
this->visual_min_temperature_override_ = visual_min_temperature_override;
}
void Climate::set_visual_max_temperature_override(float visual_max_temperature_override) {
this->visual_max_temperature_override_ = visual_max_temperature_override;
}
void Climate::set_visual_temperature_step_override(float target, float current) {
this->visual_target_temperature_step_override_ = target;
this->visual_current_temperature_step_override_ = current;
}
void Climate::set_visual_min_humidity_override(float visual_min_humidity_override) {
this->visual_min_humidity_override_ = visual_min_humidity_override;
}
void Climate::set_visual_max_humidity_override(float visual_max_humidity_override) {
this->visual_max_humidity_override_ = visual_max_humidity_override;
}
#endif
ClimateCall Climate::make_call() { return ClimateCall(this); }
ClimateCall ClimateDeviceRestoreState::to_call(Climate *climate) {
+16 -5
View File
@@ -228,11 +228,22 @@ class Climate : public EntityBase {
ClimateTraits get_traits();
#ifdef USE_CLIMATE_VISUAL_OVERRIDES
void set_visual_min_temperature_override(float visual_min_temperature_override);
void set_visual_max_temperature_override(float visual_max_temperature_override);
void set_visual_temperature_step_override(float target, float current);
void set_visual_min_humidity_override(float visual_min_humidity_override);
void set_visual_max_humidity_override(float visual_max_humidity_override);
void set_visual_min_temperature_override(float visual_min_temperature_override) {
this->visual_min_temperature_override_ = visual_min_temperature_override;
}
void set_visual_max_temperature_override(float visual_max_temperature_override) {
this->visual_max_temperature_override_ = visual_max_temperature_override;
}
void set_visual_temperature_step_override(float target, float current) {
this->visual_target_temperature_step_override_ = target;
this->visual_current_temperature_step_override_ = current;
}
void set_visual_min_humidity_override(float visual_min_humidity_override) {
this->visual_min_humidity_override_ = visual_min_humidity_override;
}
void set_visual_max_humidity_override(float visual_max_humidity_override) {
this->visual_max_humidity_override_ = visual_max_humidity_override;
}
#endif
/// Set the supported custom fan modes (stored on Climate, referenced by ClimateTraits).
+30 -10
View File
@@ -46,7 +46,7 @@ from esphome.core.entity_helpers import (
setup_entity,
)
from esphome.cpp_generator import LambdaExpression, MockObj, MockObjClass
from esphome.types import ConfigType, TemplateArgsType
from esphome.types import ConfigType, SafeExpType, TemplateArgsType
IS_PLATFORM_COMPONENT = True
@@ -162,7 +162,7 @@ _COVER_SCHEMA = (
_COVER_SCHEMA.add_extra(entity_duplicate_validator("cover"))
def _validate_mqtt_state_topics(config):
def _validate_mqtt_state_topics(config: ConfigType) -> ConfigType:
if config.get(CONF_MQTT_JSON_STATE_PAYLOAD):
if CONF_POSITION_STATE_TOPIC in config:
raise cv.Invalid(
@@ -201,7 +201,7 @@ def cover_schema(
@setup_entity("cover")
async def setup_cover_core_(var, config):
async def setup_cover_core_(var: MockObj, config: ConfigType) -> None:
setup_device_class(config)
if CONF_ON_OPEN in config:
@@ -235,7 +235,7 @@ async def setup_cover_core_(var, config):
await web_server.add_entity_config(var, web_server_config)
async def register_cover(var, config):
async def register_cover(var: MockObj, config: ConfigType) -> None:
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
queue_entity_register("cover", config)
@@ -243,7 +243,7 @@ async def register_cover(var, config):
await setup_cover_core_(var, config)
async def new_cover(config, *args):
async def new_cover(config: ConfigType, *args: SafeExpType) -> MockObj:
var = cg.new_Pvariable(config[CONF_ID], *args)
await register_cover(var, config)
return var
@@ -259,7 +259,12 @@ COVER_ACTION_SCHEMA = maybe_simple_id(
@automation.register_action(
"cover.open", OpenAction, COVER_ACTION_SCHEMA, synchronous=True
)
async def cover_open_to_code(config, action_id, template_arg, args):
async def cover_open_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
paren = await cg.get_variable(config[CONF_ID])
return cg.new_Pvariable(action_id, template_arg, paren)
@@ -267,7 +272,12 @@ async def cover_open_to_code(config, action_id, template_arg, args):
@automation.register_action(
"cover.close", CloseAction, COVER_ACTION_SCHEMA, synchronous=True
)
async def cover_close_to_code(config, action_id, template_arg, args):
async def cover_close_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
paren = await cg.get_variable(config[CONF_ID])
return cg.new_Pvariable(action_id, template_arg, paren)
@@ -275,7 +285,12 @@ async def cover_close_to_code(config, action_id, template_arg, args):
@automation.register_action(
"cover.stop", StopAction, COVER_ACTION_SCHEMA, synchronous=True
)
async def cover_stop_to_code(config, action_id, template_arg, args):
async def cover_stop_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
paren = await cg.get_variable(config[CONF_ID])
return cg.new_Pvariable(action_id, template_arg, paren)
@@ -283,7 +298,12 @@ async def cover_stop_to_code(config, action_id, template_arg, args):
@automation.register_action(
"cover.toggle", ToggleAction, COVER_ACTION_SCHEMA, synchronous=True
)
async def cover_toggle_to_code(config, action_id, template_arg, args):
async def cover_toggle_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
paren = await cg.get_variable(config[CONF_ID])
return cg.new_Pvariable(action_id, template_arg, paren)
@@ -421,5 +441,5 @@ automation.register_condition(
@coroutine_with_priority(CoroPriority.CORE)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
cg.add_global(cover_ns.using)
-7
View File
@@ -135,10 +135,6 @@ CoverCall &CoverCall::set_stop(bool stop) {
this->stop_ = stop;
return *this;
}
bool CoverCall::get_stop() const { return this->stop_; }
CoverCall Cover::make_call() { return {this}; }
void Cover::publish_state(bool save) {
this->position = clamp(this->position, 0.0f, 1.0f);
this->tilt = clamp(this->tilt, 0.0f, 1.0f);
@@ -184,9 +180,6 @@ optional<CoverRestoreState> Cover::restore_state_() {
return recovered;
}
bool Cover::is_fully_open() const { return this->position == COVER_OPEN; }
bool Cover::is_fully_closed() const { return this->position == COVER_CLOSED; }
CoverCall CoverRestoreState::to_call(Cover *cover) {
auto call = cover->make_call();
auto traits = cover->get_traits();
+4 -4
View File
@@ -50,7 +50,7 @@ class CoverCall {
void perform();
const optional<float> &get_position() const;
bool get_stop() const;
bool get_stop() const { return this->stop_; }
const optional<float> &get_tilt() const;
const optional<bool> &get_toggle() const;
@@ -123,7 +123,7 @@ class Cover : public EntityBase {
float tilt{COVER_OPEN};
/// Construct a new cover call used to control the cover.
CoverCall make_call();
CoverCall make_call() { return {this}; }
template<typename F> void add_on_state_callback(F &&f) { this->state_callback_.add(std::forward<F>(f)); }
@@ -139,9 +139,9 @@ class Cover : public EntityBase {
virtual CoverTraits get_traits() = 0;
/// Helper method to check if the cover is fully open. Equivalent to comparing .position against 1.0
bool is_fully_open() const;
bool is_fully_open() const { return this->position == COVER_OPEN; }
/// Helper method to check if the cover is fully closed. Equivalent to comparing .position against 0.0
bool is_fully_closed() const;
bool is_fully_closed() const { return this->position == COVER_CLOSED; }
protected:
friend CoverCall;
@@ -2,6 +2,7 @@ import base64
from pathlib import Path
import re
import secrets
from typing import Any
import requests
from ruamel.yaml import YAML
@@ -13,6 +14,7 @@ import esphome.config_validation as cv
from esphome.const import CONF_ESPHOME, CONF_PROJECT, CONF_REF, CONF_WIFI
import esphome.final_validate as fv
from esphome.happy_eyeballs import ensure_happy_eyeballs
from esphome.types import ConfigType
from esphome.yaml_util import dump
dashboard_import_ns = cg.esphome_ns.namespace("dashboard_import")
@@ -23,14 +25,14 @@ DEPENDENCIES = ["api"]
CODEOWNERS = ["@esphome/core"]
def validate_import_url(value):
def validate_import_url(value: Any) -> str:
value = cv.string_strict(value)
value = cv.Length(max=255)(value)
validate_source_shorthand(value)
return value
def validate_full_url(config):
def validate_full_url(config: ConfigType) -> ConfigType:
if not config[CONF_IMPORT_FULL_CONFIG]:
return config
source = validate_source_shorthand(config[CONF_PACKAGE_IMPORT_URL])
@@ -55,7 +57,7 @@ CONFIG_SCHEMA = cv.All(
)
def _final_validate(config):
def _final_validate(config: ConfigType) -> None:
full_config = fv.full_config.get()[CONF_ESPHOME]
if CONF_PROJECT not in full_config:
raise cv.Invalid(
@@ -73,7 +75,7 @@ wifi:
"""
async def to_code(config):
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_DASHBOARD_IMPORT")
url = config[CONF_PACKAGE_IMPORT_URL]
if config[CONF_IMPORT_FULL_CONFIG]:
@@ -37,8 +37,6 @@ void DateEntity::publish_state() {
#endif
}
DateCall DateEntity::make_call() { return DateCall(this); }
void DateCall::validate_() {
if (this->year_.has_value() && (this->year_ < 1970 || this->year_ > 3000)) {
ESP_LOGE(TAG, "Year must be between 1970 and 3000");
@@ -96,6 +96,8 @@ class DateCall {
optional<uint8_t> day_;
};
inline DateCall DateEntity::make_call() { return DateCall(this); }
template<typename... Ts> class DateSetAction final : public Action<Ts...>, public Parented<DateEntity> {
public:
TEMPLATABLE_VALUE(ESPTime, date)
@@ -53,8 +53,6 @@ void DateTimeEntity::publish_state() {
#endif
}
DateTimeCall DateTimeEntity::make_call() { return DateTimeCall(this); }
ESPTime DateTimeEntity::state_as_esptime() const {
ESPTime obj;
obj.year = this->year_;
@@ -121,6 +121,8 @@ class DateTimeCall {
optional<uint8_t> second_;
};
inline DateTimeCall DateTimeEntity::make_call() { return DateTimeCall(this); }
template<typename... Ts> class DateTimeSetAction final : public Action<Ts...>, public Parented<DateTimeEntity> {
public:
TEMPLATABLE_VALUE(ESPTime, datetime)
@@ -33,8 +33,6 @@ void TimeEntity::publish_state() {
#endif
}
TimeCall TimeEntity::make_call() { return TimeCall(this); }
void TimeCall::validate_() {
if (this->hour_.has_value() && this->hour_ > 23) {
ESP_LOGE(TAG, "Hour must be between 0 and 23");
@@ -98,6 +98,8 @@ class TimeCall {
optional<uint8_t> second_;
};
inline TimeCall TimeEntity::make_call() { return TimeCall(this); }
template<typename... Ts> class TimeSetAction final : public Action<Ts...>, public Parented<TimeEntity> {
public:
TEMPLATABLE_VALUE(ESPTime, time)
+2 -1
View File
@@ -12,6 +12,7 @@ from esphome.const import (
PlatformFramework,
)
from esphome.core import CORE
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
DEPENDENCIES = ["logger"]
@@ -45,7 +46,7 @@ CONFIG_SCHEMA = cv.All(
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
if CORE.using_zephyr:
zephyr_add_prj_conf("HWINFO", True)
# gdb thread support
+2 -1
View File
@@ -21,6 +21,7 @@ from esphome.const import (
UNIT_MILLISECOND,
UNIT_PERCENT,
)
from esphome.types import ConfigType
from . import ( # noqa: F401 pylint: disable=unused-import
CONF_DEBUG_ID,
@@ -111,7 +112,7 @@ CONFIG_SCHEMA = {
}
async def to_code(config):
async def to_code(config: ConfigType) -> None:
debug_component = await cg.get_variable(config[CONF_DEBUG_ID])
if free_conf := config.get(CONF_FREE):
+2 -1
View File
@@ -7,6 +7,7 @@ from esphome.const import (
ICON_CHIP,
ICON_RESTART,
)
from esphome.types import ConfigType
from . import ( # noqa: F401 pylint: disable=unused-import
CONF_DEBUG_ID,
@@ -33,7 +34,7 @@ CONFIG_SCHEMA = cv.Schema(
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
debug_component = await cg.get_variable(config[CONF_DEBUG_ID])
if CONF_DEVICE in config:
@@ -163,6 +163,11 @@ def validate_config(config: ConfigType) -> ConfigType:
"You need to remove the global wakeup_pin_mode and define it per pin"
)
if wakeup_pins:
if CONF_WAKEUP_PIN_MODE in wakeup_pins[0]:
raise cv.Invalid(
"Specify wakeup_pin_mode either at the top level under deep_sleep "
"or under the pin entry, not both"
)
wakeup_pins[0][CONF_WAKEUP_PIN_MODE] = config.pop(CONF_WAKEUP_PIN_MODE)
elif (
isinstance(config.get(CONF_WAKEUP_PIN), list)
@@ -43,10 +43,6 @@ void DeepSleepComponent::loop() {
this->begin_sleep();
}
void DeepSleepComponent::set_sleep_duration(uint32_t time_ms) { this->sleep_duration_ = uint64_t(time_ms) * 1000; }
void DeepSleepComponent::set_run_duration(uint32_t time_ms) { this->run_duration_ = time_ms; }
void DeepSleepComponent::begin_sleep(bool manual) {
if (this->prevent_ && !manual) {
this->next_enter_deep_sleep_ = true;
@@ -76,8 +72,4 @@ void DeepSleepComponent::begin_sleep(bool manual) {
float DeepSleepComponent::get_setup_priority() const { return setup_priority::LATE; }
void DeepSleepComponent::prevent_deep_sleep() { this->prevent_ = true; }
void DeepSleepComponent::allow_deep_sleep() { this->prevent_ = false; }
} // namespace esphome::deep_sleep
@@ -132,7 +132,7 @@ template<typename... Ts> class PreventDeepSleepAction;
class DeepSleepComponent final : public Component {
public:
/// Set the duration in ms the component should sleep once it's in deep sleep mode.
void set_sleep_duration(uint32_t time_ms);
void set_sleep_duration(uint32_t time_ms) { this->sleep_duration_ = uint64_t(time_ms) * 1000; }
#if defined(USE_ESP32)
/** Set the pin to wake up to on the ESP32 once it's in deep sleep mode.
* Use the inverted property to set the wakeup level.
@@ -157,7 +157,7 @@ class DeepSleepComponent final : public Component {
#if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \
!defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \
!defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2)
void set_touch_wakeup(bool touch_wakeup);
void set_touch_wakeup(bool touch_wakeup) { this->touch_wakeup_ = touch_wakeup; }
#endif
// Set the duration in ms for how long the code should run before entering
@@ -166,7 +166,7 @@ class DeepSleepComponent final : public Component {
#endif // USE_ESP32
/// Set a duration in ms for how long the code should run before entering deep sleep mode.
void set_run_duration(uint32_t time_ms);
void set_run_duration(uint32_t time_ms) { this->run_duration_ = time_ms; }
void setup() override;
void dump_config() override;
@@ -176,8 +176,8 @@ class DeepSleepComponent final : public Component {
/// Helper to enter deep sleep mode
void begin_sleep(bool manual = false);
void prevent_deep_sleep();
void allow_deep_sleep();
void prevent_deep_sleep() { this->prevent_ = true; }
void allow_deep_sleep() { this->prevent_ = false; }
protected:
// Returns nullopt if no run duration is set. Otherwise, returns the run
@@ -74,12 +74,6 @@ void DeepSleepComponent::set_wakeup_pin_mode(WakeupPinMode wakeup_pin_mode) {
void DeepSleepComponent::set_ext1_wakeup(Ext1Wakeup ext1_wakeup) { this->ext1_wakeup_ = ext1_wakeup; }
#endif
#if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \
!defined(USE_ESP32_VARIANT_ESP32C5) && !defined(USE_ESP32_VARIANT_ESP32C6) && \
!defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2)
void DeepSleepComponent::set_touch_wakeup(bool touch_wakeup) { this->touch_wakeup_ = touch_wakeup; }
#endif
void DeepSleepComponent::set_run_duration(WakeupCauseToRunDuration wakeup_cause_to_run_duration) {
wakeup_cause_to_run_duration_ = wakeup_cause_to_run_duration;
}
-6
View File
@@ -685,9 +685,6 @@ void Display::show_page(DisplayPage *page) {
}
}
void Display::show_next_page() { this->page_->show_next(); }
void Display::show_prev_page() { this->page_->show_prev(); }
void Display::do_update_() {
if (this->auto_clear_enabled_) {
this->clear();
@@ -892,9 +889,6 @@ void DisplayPage::show_prev() {
this->prev_->show();
}
void DisplayPage::set_parent(Display *parent) { this->parent_ = parent; }
void DisplayPage::set_prev(DisplayPage *prev) { this->prev_ = prev; }
void DisplayPage::set_next(DisplayPage *next) { this->next_ = next; }
const display_writer_t &DisplayPage::get_writer() const { return this->writer_; }
const LogString *text_align_to_string(TextAlign textalign) {
+6 -3
View File
@@ -802,9 +802,9 @@ class DisplayPage final {
void show();
void show_next();
void show_prev();
void set_parent(Display *parent);
void set_prev(DisplayPage *prev);
void set_next(DisplayPage *next);
void set_parent(Display *parent) { this->parent_ = parent; }
void set_prev(DisplayPage *prev) { this->prev_ = prev; }
void set_next(DisplayPage *next) { this->next_ = next; }
const display_writer_t &get_writer() const;
protected:
@@ -814,6 +814,9 @@ class DisplayPage final {
DisplayPage *next_{nullptr};
};
inline void Display::show_next_page() { this->page_->show_next(); }
inline void Display::show_prev_page() { this->page_->show_prev(); }
template<typename... Ts> class DisplayPageShowAction final : public Action<Ts...> {
public:
TEMPLATABLE_VALUE(DisplayPage *, page)
+9
View File
@@ -12,6 +12,7 @@ from typing import Any
from esphome import yaml_util
import esphome.codegen as cg
from esphome.components.const import CONF_ENABLE_OTA_DOWNGRADE_PROTECTION
from esphome.config_helpers import filter_source_files_from_defines
import esphome.config_validation as cv
from esphome.const import (
CONF_ADVANCED,
@@ -233,6 +234,7 @@ DEFAULT_EXCLUDED_IDF_COMPONENTS = (
"esp_driver_touch_sens", # Touch sensor driver - only needed by esp32_touch
"esp_driver_twai", # TWAI/CAN driver - only needed by esp32_can component
"esp_eth", # Ethernet driver - only needed by ethernet component
"esp_gdbstub", # GDB stub panic handler - unused by ESPHome; bt pulls it back
"esp_hid", # HID host/device support - ESPHome doesn't implement HID functionality
"esp_http_client", # HTTP client - only needed by http_request component
"esp_https_ota", # ESP-IDF HTTPS OTA - ESPHome has its own OTA implementation
@@ -3450,3 +3452,10 @@ def process_stacktrace(config, line, backtrace_state):
_decode_pc(config, addr.group())
return backtrace_state
# gpio.cpp only implements ESP32InternalGPIOPin and its ISR helpers, which
# are instantiated solely by the pin schema codegen (esp32_pin_to_code)
FILTER_SOURCE_FILES = filter_source_files_from_defines(
{"gpio.cpp": "USE_ESP32_INTERNAL_GPIO"}
)
+54 -7
View File
@@ -124,6 +124,15 @@ static uint8_t IRAM_ATTR capture_riscv_backtrace(RvExcFrame *frame, uint32_t *ou
// Version is uint32_t because it would be padded to 4 bytes anyway before the next
// uint32_t field, so we use the full width rather than wasting 3 bytes of padding.
static constexpr uint32_t CRASH_DATA_VERSION = 4;
#if CONFIG_IDF_TARGET_ARCH_XTENSA
// EXCCAUSE is a 6-bit register; larger recorded values mean the frame's
// cause/vaddr slots were never written (not a real exception frame).
static constexpr uint32_t XTENSA_EXCCAUSE_COUNT = XCHAL_EXCCAUSE_NUM;
#elif CONFIG_IDF_TARGET_ARCH_RISCV
// Synchronous mcause exception codes are small and have no interrupt bit;
// anything else in a non-pseudo record is a stale slot.
static constexpr uint32_t RISCV_EXCEPTION_CAUSE_COUNT = 32;
#endif
struct RawCrashData {
uint32_t version;
uint32_t magic;
@@ -198,10 +207,28 @@ void crash_handler_clear() {
s_raw_crash_data.magic = 0;
}
// Whether the cause slot was written by a real exception frame.
static bool cause_slot_was_written() {
#if CONFIG_IDF_TARGET_ARCH_XTENSA
return s_raw_crash_data.cause < XTENSA_EXCCAUSE_COUNT;
#else
return s_raw_crash_data.cause < RISCV_EXCEPTION_CAUSE_COUNT;
#endif
}
// Look up the exception cause as a human-readable string.
// Tables mirror ESP-IDF's panic_arch_fill_info() which uses local static arrays
// not exposed via any public API.
static const char *get_exception_reason() {
uint8_t exception = s_raw_crash_data.exception;
if (exception == PANIC_EXCEPTION_ABORT || exception == PANIC_EXCEPTION_TWDT) {
// Abort-class panics carry no cause register
return nullptr;
}
if (!cause_slot_was_written()) {
// Garbage from old-build or corrupt records; report just the type
return nullptr;
}
#if CONFIG_IDF_TARGET_ARCH_XTENSA
if (s_raw_crash_data.pseudo_excause) {
// SoC-level panic: watchdog, cache error, etc.
@@ -354,10 +381,11 @@ static const char *const FAULT_ADDR_REG = "MTVAL";
static const char *const FAULT_ADDR_REG_LOWER = "mtval";
#endif
// Whether the fault address is meaningful real CPU faults only, not
// aborts/watchdogs or SoC-level pseudo exceptions.
// Whether the fault address is meaningful: real CPU faults with a validly
// written frame only.
static bool has_fault_addr() {
return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause;
return s_raw_crash_data.exception == PANIC_EXCEPTION_FAULT && !s_raw_crash_data.pseudo_excause &&
cause_slot_was_written();
}
// The record was captured by a different firmware build (it survives soft
@@ -458,6 +486,10 @@ void crash_handler_log() {
// into NOINIT memory before the normal panic handler runs.
//
extern "C" {
// Set by IDF's task watchdog (task_wdt.c, no header) before it simulates an
// abort; weak so builds without the task watchdog still link.
extern bool g_twdt_isr __attribute__((weak));
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
// Names are mandated by the --wrap linker mechanism
extern void __real_esp_panic_handler(panic_info_t *info);
@@ -470,6 +502,14 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) {
s_raw_crash_data.exception = (uint8_t) info->exception;
s_raw_crash_data.pseudo_excause = info->pseudo_excause ? 1 : 0;
s_raw_crash_data.crashed_core = (uint8_t) info->core;
if (g_panic_abort) {
// IDF reclassifies to ABORT only inside esp_panic_handler(), after this
// wrapper captured info->exception; correct it here. TWDT is our own
// distinction (IDF never assigns PANIC_EXCEPTION_TWDT). The abort text is
// not stored; the symbolized backtrace already identifies the site.
bool is_twdt = &g_twdt_isr != nullptr && g_twdt_isr;
s_raw_crash_data.exception = (uint8_t) (is_twdt ? PANIC_EXCEPTION_TWDT : PANIC_EXCEPTION_ABORT);
}
// Zero unconditionally so a null frame doesn't leave stale .noinit data from a previous boot
s_raw_crash_data.cause = 0;
s_raw_crash_data.fault_addr = 0;
@@ -487,8 +527,12 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) {
// Xtensa: walk the backtrace using the public API
if (info->frame != nullptr) {
auto *xt_frame = (XtExcFrame *) info->frame;
s_raw_crash_data.cause = xt_frame->exccause;
s_raw_crash_data.fault_addr = xt_frame->excvaddr;
if (!g_panic_abort) {
// Abort-class frames carry no useful cause/vaddr: TWDT task snapshots
// never wrote them and abort() traps describe only the synthetic trap.
s_raw_crash_data.cause = xt_frame->exccause;
s_raw_crash_data.fault_addr = xt_frame->excvaddr;
}
s_raw_crash_data.backtrace_count = walk_xtensa_backtrace(xt_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE);
}
@@ -510,8 +554,11 @@ void IRAM_ATTR __wrap_esp_panic_handler(panic_info_t *info) {
// RISC-V: capture MEPC + RA, then scan stack for code addresses
if (info->frame != nullptr) {
auto *rv_frame = (RvExcFrame *) info->frame;
s_raw_crash_data.cause = rv_frame->mcause;
s_raw_crash_data.fault_addr = rv_frame->mtval;
if (!g_panic_abort) {
// See the Xtensa branch: abort-class frames carry no valid cause/vaddr.
s_raw_crash_data.cause = rv_frame->mcause;
s_raw_crash_data.fault_addr = rv_frame->mtval;
}
s_raw_crash_data.backtrace_count =
capture_riscv_backtrace(rv_frame, s_raw_crash_data.backtrace, MAX_BACKTRACE, &s_raw_crash_data.reg_frame_count);
}
+5 -2
View File
@@ -1,4 +1,7 @@
#ifdef USE_ESP32
#include "esphome/core/defines.h"
// Also defines the core ISRInternalGPIOPin methods; those are only reachable
// via ESP32InternalGPIOPin::to_isr(), so the same define gates both safely.
#if defined(USE_ESP32) && defined(USE_ESP32_INTERNAL_GPIO)
#include "gpio.h"
#include "esphome/core/log.h"
@@ -204,4 +207,4 @@ void IRAM_ATTR ISRInternalGPIOPin::pin_mode(gpio::Flags flags) {
} // namespace esphome
#endif // USE_ESP32
#endif // USE_ESP32 && USE_ESP32_INTERNAL_GPIO
+1
View File
@@ -257,6 +257,7 @@ ESP32_PIN_SCHEMA = cv.All(
@pins.PIN_SCHEMA_REGISTRY.register(PLATFORM_ESP32, ESP32_PIN_SCHEMA)
async def esp32_pin_to_code(config):
cg.add_define("USE_ESP32_INTERNAL_GPIO")
var = cg.new_Pvariable(config[CONF_ID])
num = config[CONF_NUMBER]
cg.add(var.set_pin(getattr(gpio_num_t, f"GPIO_NUM_{num}")))
+21 -1
View File
@@ -643,8 +643,28 @@ void ESP32BLE::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_pa
App.wake_loop_threadsafe();
return;
// Log the result of connection parameter updates: a peer can reject or
// never answer an update, and without this the link silently stays on the
// old parameters (visible only as unexplained supervision timeouts).
case ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT: {
if (param->update_conn_params.status != ESP_BT_STATUS_SUCCESS) {
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
format_mac_addr_upper(param->update_conn_params.bda, mac_s);
ESP_LOGW(TAG, "[%s] Conn param update failed, status=%d", mac_s, param->update_conn_params.status);
}
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
else {
char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
format_mac_addr_upper(param->update_conn_params.bda, mac_s);
ESP_LOGV(TAG, "[%s] Conn params updated: interval=%u (x1.25ms) latency=%u timeout=%u (x10ms)", mac_s,
param->update_conn_params.conn_int, param->update_conn_params.latency,
param->update_conn_params.timeout);
}
#endif
return;
}
// Ignore these GAP events as they are not relevant for our use case
case ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT:
case ESP_GAP_BLE_SET_PKT_LENGTH_COMPLETE_EVT:
case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT: // BLE 5.0 PHY update complete
case ESP_GAP_BLE_CHANNEL_SELECT_ALGORITHM_EVT: // BLE 5.0 channel selection algorithm
@@ -7,6 +7,7 @@ import logging
from esphome import automation
import esphome.codegen as cg
from esphome.components import ble_device_base, esp32_ble, ota
from esphome.components.ble_device_base import CONF_CONNECTION_SCAN_WINDOW
from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW
from esphome.components.esp32 import (
add_idf_sdkconfig_option,
@@ -73,8 +74,9 @@ def _get_required_features() -> set[BLEFeatures]:
# Slot counters sizing the tracker's StaticVector storage; one request per
# registered listener or client.
CLIENT_COUNT_DEFINE = "ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT"
_request_listener_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT")
_request_client_slot = cg.slot_counter("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT")
_request_client_slot = cg.slot_counter(CLIENT_COUNT_DEFINE)
def register_ble_features(features: set[BLEFeatures]) -> None:
@@ -147,6 +149,7 @@ class TrackerData:
"""Per-run validation state, namespaced under DOMAIN in CORE.data."""
scan_window_defaulted: bool = False
connection_window_injected: bool = False
def _get_data() -> TrackerData:
@@ -175,17 +178,34 @@ def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType:
honors the window strictly (>= 5.5.5); without the arbiter a full-duty
scan would starve wifi outright, and a user-set window is never touched.
Raising to the interval cannot invalidate the already-validated
parameters, so no re-validation is needed.
parameters, so no re-validation is needed. The connection window is
checked against the window here, after the raise.
"""
params = config[CONF_SCAN_PARAMETERS]
if (
_get_data().scan_window_defaulted
and config.get(CONF_SOFTWARE_COEXISTENCE)
and idf_version() >= IDF_SCAN_WINDOW_FIX_VERSION
):
params = config[CONF_SCAN_PARAMETERS]
# Copy so the config dump shows a plain value instead of a YAML
# anchor/alias pair pointing at the interval.
params[CONF_WINDOW] = copy.copy(params[CONF_INTERVAL])
# Arm the connection-time fallback unless the user set one. Injected
# after validation; safe because it equals the validated window default.
if CONF_CONNECTION_SCAN_WINDOW not in params:
params[CONF_CONNECTION_SCAN_WINDOW] = cv.positive_time_period(
ble_device_base.DEFAULT_SCAN_WINDOW
)
_get_data().connection_window_injected = True
if (
connection_window := params.get(CONF_CONNECTION_SCAN_WINDOW)
) is not None and connection_window > params[CONF_WINDOW]:
# A larger value would widen the scan during connections.
raise cv.Invalid(
f"{CONF_CONNECTION_SCAN_WINDOW} ({connection_window}) needs to be "
f"smaller than the scan window ({params[CONF_WINDOW]})",
path=[CONF_SCAN_PARAMETERS, CONF_CONNECTION_SCAN_WINDOW],
)
return config
@@ -194,7 +214,7 @@ def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType:
# window/interval pairs that collapse to the same 0.625 ms unit count.
# The window default is conditional (see _scan_window_default above).
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema(
"320ms", window_default=_scan_window_default
"320ms", window_default=_scan_window_default, connection_window=True
)
# Codegen helpers are owned by ble_device_base; kept under the historical names
@@ -288,6 +308,25 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_scan_duration(params[CONF_DURATION]))
cg.add(var.set_scan_interval(ble_device_base.to_ble_units(params[CONF_INTERVAL])))
cg.add(var.set_scan_window(ble_device_base.to_ble_units(params[CONF_WINDOW])))
if (connection_window := params.get(CONF_CONNECTION_SCAN_WINDOW)) is not None:
# Emitted at FINAL so a scan-only build, where the guarded C++ path
# compiles out, skips the call entirely.
window_units = ble_device_base.to_ble_units(connection_window)
@coroutine_with_priority(CoroPriority.FINAL)
async def _emit_connection_scan_window() -> None:
if cg.get_slot_count(CLIENT_COUNT_DEFINE):
cg.add(var.set_connection_scan_window(window_units))
elif not _get_data().connection_window_injected:
# Warn only for a user-set value; the injected default drops silently.
_LOGGER.warning(
"'%s' has no effect because this build has no BLE client "
"components (for example bluetooth_proxy with active "
"connections, or ble_client)",
CONF_CONNECTION_SCAN_WINDOW,
)
CORE.add_job(_emit_connection_scan_window)
cg.add(var.set_scan_active(params[CONF_ACTIVE]))
cg.add(var.set_scan_continuous(params[CONF_CONTINUOUS]))
@@ -122,6 +122,9 @@ void ESP32BLETracker::loop() {
// - start_scan_(): scanner_state_ becomes IDLE via set_scanner_state_() in cleanup_scan_state_()
// - try_promote_discovered_clients_(): client enters DISCOVERED via set_state(), or
// connecting client finishes (state change), or scanner reaches RUNNING/IDLE
// - connection-window restart: scan_params_ is only written in start_scan_()
// (which changes scanner state via set_scanner_state_()), and
// counts.active/disconnecting only change on client state changes
//
// All conditions that affect the logic below are tied to state changes that increment
// state_version_, so the fast path is safe.
@@ -144,6 +147,19 @@ void ESP32BLETracker::loop() {
(this->scan_set_param_failed_ && this->scanner_state_ == ScannerState::RUNNING)) {
this->handle_scanner_failure_();
}
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
// The programmed window no longer matches the connection state (typically
// the last connection dropped): restart so the right window applies now
// instead of at the end of the scan period. Continuous only (a user-started
// scan would not restart); !disconnecting matches the restart gate below.
if (this->scanner_state_ == ScannerState::RUNNING && this->scan_continuous_ && !counts.disconnecting &&
this->scan_params_.scan_window != this->desired_scan_window_(counts.active)) {
// Same logical scan period continues: no on_scan_end sweeps for this
// restart. Only armed when the stop was issued.
this->skip_next_scan_end_ = this->stop_scan_();
}
#endif
/*
Avoid starting the scanner if:
@@ -195,19 +211,23 @@ void ESP32BLETracker::stop_scan() {
// reason at D themselves, and the user-facing stop action is deliberate.
ESP_LOGV(TAG, "Stopping scan.");
this->scan_continuous_ = false;
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
// The window-change restart is abandoned with continuous scanning.
this->skip_next_scan_end_ = false;
#endif
this->stop_scan_();
}
void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_(); }
void ESP32BLETracker::stop_scan_() {
bool ESP32BLETracker::stop_scan_() {
if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) {
// IDLE means there is nothing to stop; STOPPING means a stop is already in
// flight and will finish on its own. Neither is an error.
if (this->scanner_state_ != ScannerState::IDLE && this->scanner_state_ != ScannerState::STOPPING) {
ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_));
}
return;
return false;
}
// Reset timeout state machine when stopping scan
this->scan_timeout_state_ = ScanTimeoutState::INACTIVE;
@@ -215,8 +235,9 @@ void ESP32BLETracker::stop_scan_() {
esp_err_t err = esp_ble_gap_stop_scanning();
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ble_gap_stop_scanning failed: %d", err);
return;
return false;
}
return true;
}
void ESP32BLETracker::start_scan_(bool first) {
@@ -230,16 +251,11 @@ void ESP32BLETracker::start_scan_(bool first) {
}
this->set_scanner_state_(ScannerState::STARTING);
ESP_LOGV(TAG, "Starting scan, set scanner state to STARTING.");
if (!first) {
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
for (auto *listener : this->listeners_)
listener->on_scan_end();
if (!first)
this->notify_scan_end_();
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
this->skip_next_scan_end_ = false;
#endif
#ifdef ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT
for (auto *listener : this->neutral_listeners_)
listener->on_scan_end();
#endif
}
#ifdef USE_ESP32_BLE_DEVICE
this->discovered_log_.clear();
#endif
@@ -247,7 +263,17 @@ void ESP32BLETracker::start_scan_(bool first) {
this->scan_params_.own_addr_type = BLE_ADDR_TYPE_PUBLIC;
this->scan_params_.scan_filter_policy = BLE_SCAN_FILTER_ALLOW_ALL;
this->scan_params_.scan_interval = this->scan_interval_;
this->scan_params_.scan_window = this->scan_window_;
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
// Count fresh: an automation can start a scan before loop() refreshes the counts.
const uint32_t window = this->desired_scan_window_(this->count_client_states_().active);
if (window != this->scan_window_) {
// Guarantee the connection airtime instead of scanning wall to wall.
ESP_LOGV(TAG, "Connection active, using %" PRIu32 " unit scan window", window);
}
#else
const uint32_t window = this->scan_window_;
#endif
this->scan_params_.scan_window = window;
// Start timeout monitoring in loop() instead of using scheduler
// This prevents false reboots when the loop is blocked
@@ -408,6 +434,11 @@ void ESP32BLETracker::dump_config() {
" Continuous Scanning: %s",
this->scan_duration_, this->scan_interval_ * 0.625f, this->scan_window_ * 0.625f,
this->scan_active_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_));
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
if (this->connection_scan_window_ != 0) {
ESP_LOGCONFIG(TAG, " Connection Scan Window: %.1f ms", this->connection_scan_window_ * 0.625f);
}
#endif
ESP_LOGCONFIG(TAG,
" Scanner State: %s\n"
" Connecting: %d, discovered: %d, disconnecting: %d, active: %d",
@@ -487,6 +518,18 @@ void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) {
// Reset timeout state machine instead of cancelling scheduler timeout
this->scan_timeout_state_ = ScanTimeoutState::INACTIVE;
this->notify_scan_end_();
this->set_scanner_state_(ScannerState::IDLE);
}
void ESP32BLETracker::notify_scan_end_() {
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
// Window-change restart continues the same scan period; the flag stays set
// across the stop and is cleared by the restart in start_scan_.
if (this->skip_next_scan_end_)
return;
#endif
#ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT
for (auto *listener : this->listeners_)
listener->on_scan_end();
@@ -495,8 +538,6 @@ void ESP32BLETracker::cleanup_scan_state_(bool is_stop_complete) {
for (auto *listener : this->neutral_listeners_)
listener->on_scan_end();
#endif
this->set_scanner_state_(ScannerState::IDLE);
}
void ESP32BLETracker::handle_scanner_failure_() {
@@ -534,6 +575,8 @@ void ESP32BLETracker::try_promote_discovered_clients_() {
}
ESP_LOGD(TAG, "Promoting client to connect");
// A connect ends the scan period a window-change restart was continuing.
this->skip_next_scan_end_ = false;
#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE
this->update_coex_preference_(true);
#endif
@@ -169,6 +169,9 @@ class ESP32BLETracker final : public Component,
void set_scan_duration(uint32_t scan_duration) { scan_duration_ = scan_duration; }
void set_scan_interval(uint32_t scan_interval) { scan_interval_ = scan_interval; }
void set_scan_window(uint32_t scan_window) { scan_window_ = scan_window; }
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
void set_connection_scan_window(uint32_t scan_window) { connection_scan_window_ = scan_window; }
#endif
void set_scan_active(bool scan_active) { scan_active_ = scan_active; }
bool get_scan_active() const { return scan_active_; }
void set_scan_continuous(bool scan_continuous) { scan_continuous_ = scan_continuous; }
@@ -226,7 +229,10 @@ class ESP32BLETracker final : public Component,
ScannerState get_scanner_state() const { return this->scanner_state_; }
protected:
void stop_scan_();
/// Returns true when a stop was issued to the controller.
bool stop_scan_();
/// Fire on_scan_end on every listener unless a window-change restart suppressed it.
void notify_scan_end_();
/// Start a single scan by setting up the parameters and doing some esp-idf calls.
void start_scan_(bool first);
/// Called when a `ESP_GAP_BLE_SCAN_RESULT_EVT` event is received.
@@ -313,6 +319,15 @@ class ESP32BLETracker final : public Component,
uint32_t scan_duration_;
uint32_t scan_interval_;
uint32_t scan_window_;
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
/// Window used while a GATT connection is active; set by the user, or
/// defaulted when the window was raised to full duty (0 = no fallback).
uint32_t connection_scan_window_{0};
/// The window to scan at for the given number of active GATT connections.
uint32_t desired_scan_window_(uint8_t active) const {
return (this->connection_scan_window_ != 0 && active > 0) ? this->connection_scan_window_ : this->scan_window_;
}
#endif
esp_bt_status_t scan_start_failed_{ESP_BT_STATUS_SUCCESS};
esp_bt_status_t scan_set_param_failed_{ESP_BT_STATUS_SUCCESS};
@@ -330,15 +345,20 @@ class ESP32BLETracker final : public Component,
/// state_version_ to detect if any state changed since last iteration.
uint8_t last_processed_version_{0};
ScannerState scanner_state_{ScannerState::IDLE};
bool scan_continuous_;
bool scan_active_;
// Packed 1-bit flags.
bool scan_continuous_ : 1;
bool scan_active_ : 1;
#ifdef USE_OTA_STATE_LISTENER
bool scan_continuous_before_ota_{false};
bool scan_continuous_before_ota_ : 1 {false};
#endif
bool ble_was_disabled_ : 1 {true};
bool parse_advertisements_ : 1 {false};
#ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT
/// Suppress the window-change restart's on_scan_end sweeps (stop and start).
bool skip_next_scan_end_ : 1 {false};
#endif
bool ble_was_disabled_{true};
bool parse_advertisements_{false};
#ifdef USE_ESP32_BLE_SOFTWARE_COEXISTENCE
bool coex_prefer_ble_{false};
bool coex_prefer_ble_ : 1 {false};
#endif
// Scan timeout state machine
enum class ScanTimeoutState : uint8_t {
@@ -346,10 +366,10 @@ class ESP32BLETracker final : public Component,
MONITORING, // Actively monitoring for timeout
EXCEEDED_WAIT, // Timeout exceeded, waiting one loop before reboot
};
ScanTimeoutState scan_timeout_state_{ScanTimeoutState::INACTIVE};
uint32_t scan_start_time_{0};
/// Precomputed timeout value: scan_duration_ * 2000
uint32_t scan_timeout_ms_{0};
ScanTimeoutState scan_timeout_state_{ScanTimeoutState::INACTIVE};
};
// NOLINTNEXTLINE
+10 -8
View File
@@ -3,6 +3,7 @@ from pathlib import Path
import platform
import re
import subprocess
from typing import Any
import esphome.codegen as cg
import esphome.config_validation as cv
@@ -31,6 +32,7 @@ from esphome.core import (
from esphome.core.config import BOARD_MAX_LENGTH
from esphome.helpers import IS_MACOS, copy_file_if_changed
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
@@ -88,7 +90,7 @@ def lambdas_use_scanf_float(config: ConfigType) -> bool:
return False
def set_core_data(config):
def set_core_data(config: ConfigType) -> ConfigType:
CORE.data[KEY_ESP8266] = {}
CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_ESP8266
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "arduino"
@@ -102,7 +104,7 @@ def set_core_data(config):
return config
def get_download_types(storage_json):
def get_download_types(storage_json: StorageJSON) -> list[dict[str, str]]:
"""Binary-download entries for a built ESP8266 firmware.
Used by device-builder (esphome/device-builder), via
@@ -157,7 +159,7 @@ ARDUINO_3_PLATFORM_VERSION = cv.Version(3, 2, 0)
ARDUINO_4_PLATFORM_VERSION = cv.Version(4, 2, 1)
def _arduino_check_versions(value):
def _arduino_check_versions(value: ConfigType) -> ConfigType:
value = value.copy()
lookups = {
"dev": (cv.Version(3, 1, 2), "https://github.com/esp8266/Arduino.git"),
@@ -200,7 +202,7 @@ def _arduino_check_versions(value):
return value
def _parse_platform_version(value):
def _parse_platform_version(value: Any) -> str:
try:
# if platform version is a valid version constraint, prefix the default package
cv.platformio_version_constraint(value)
@@ -275,7 +277,7 @@ def check_rosetta() -> None:
@coroutine_with_priority(CoroPriority.PLATFORM)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
cg.add(esp8266_ns.setup_preferences())
cg.add_platformio_option("lib_ldf_mode", "off")
@@ -504,7 +506,7 @@ ESP8266_EXCEPTION_CODES = {
}
def _decode_pc(config, addr):
def _decode_pc(config: ConfigType, addr: str) -> None:
from esphome.platformio import toolchain
idedata = toolchain.get_idedata(config)
@@ -525,7 +527,7 @@ def _decode_pc(config, addr):
_LOGGER.warning("Decoded %s", translation)
def _parse_register(config, regex, line):
def _parse_register(config: ConfigType, regex: re.Pattern[str], line: str) -> None:
match = regex.match(line)
if match is not None:
_decode_pc(config, match.group(1))
@@ -549,7 +551,7 @@ STACKTRACE_BAD_ALLOC_RE = re.compile(
STACKTRACE_ESP8266_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}")
def process_stacktrace(config, line, backtrace_state):
def process_stacktrace(config: ConfigType, line: str, backtrace_state: bool) -> bool:
line = line.strip()
# ESP8266 Exception type
match = re.match(STACKTRACE_ESP8266_EXCEPTION_TYPE_RE, line)
+9 -4
View File
@@ -118,8 +118,6 @@ static const LogString *get_exception_cause(uint32_t cause) {
}
static const LogString *get_reset_reason(uint32_t reason) {
if (reason == REASON_WDT_RST)
return LOG_STR("Hardware WDT");
if (reason == REASON_EXCEPTION_RST)
return LOG_STR("Exception");
if (reason == REASON_SOFT_WDT_RST)
@@ -162,13 +160,20 @@ void crash_handler_log() {
if (!is_crash_reason(resetInfo.reason))
return;
ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***");
if (resetInfo.reason == REASON_WDT_RST) {
// A hardware WDT reset happens entirely in hardware: the postmortem hook
// never runs, so rst_info epc1/exccause and the RTC backtrace are
// leftovers from an earlier crash. Don't misattribute them (#18596).
ESP_LOGE(TAG, " Reason: Hardware WDT (no crash state is recorded for hardware WDT resets)");
return;
}
// Read and filter backtrace from RTC into stack-local buffer (no persistent RAM cost).
// Both resetInfo and RTC data survive until the next reset, so this can be
// called multiple times (logger init + API subscribe) with the same result.
uint32_t backtrace[MAX_BACKTRACE];
uint8_t bt_count = read_rtc_backtrace(backtrace, MAX_BACKTRACE);
ESP_LOGE(TAG, "*** CRASH DETECTED ON PREVIOUS BOOT ***");
// GCC's ROM divide routine triggers IllegalInstruction (exccause=0) at specific
// ROM addresses instead of IntegerDivideByZero (exccause=6). Patch to match
// the Arduino core's postmortem handler behavior.
+9 -6
View File
@@ -1,5 +1,6 @@
from dataclasses import dataclass
import logging
from typing import Any
from esphome import pins
import esphome.codegen as cg
@@ -18,6 +19,8 @@ from esphome.const import (
PLATFORM_ESP8266,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
from . import boards
from .const import KEY_BOARD, KEY_ESP8266, KEY_PIN_INITIAL_STATES, esp8266_ns
@@ -27,7 +30,7 @@ _LOGGER = logging.getLogger(__name__)
ESP8266GPIOPin = esp8266_ns.class_("ESP8266GPIOPin", cg.InternalGPIOPin)
def _lookup_pin(value):
def _lookup_pin(value: str) -> int:
board = CORE.data[KEY_ESP8266][KEY_BOARD]
board_pins = boards.ESP8266_BOARD_PINS.get(board, {})
@@ -42,7 +45,7 @@ def _lookup_pin(value):
raise cv.Invalid(f"Cannot resolve pin name '{value}' for board {board}.")
def _translate_pin(value):
def _translate_pin(value: Any) -> int:
if isinstance(value, dict) or value is None:
raise cv.Invalid(
"This variable only supports pin numbers, not full pin schemas "
@@ -69,7 +72,7 @@ _ESP_SDIO_PINS = {
}
def validate_gpio_pin(value):
def validate_gpio_pin(value: Any) -> int:
value = _translate_pin(value)
if value < 0 or value > 17:
raise cv.Invalid(f"ESP8266: Invalid pin number: {value}")
@@ -86,7 +89,7 @@ def validate_gpio_pin(value):
return value
def validate_supports(value):
def validate_supports(value: ConfigType) -> ConfigType:
num = value[CONF_NUMBER]
mode = value[CONF_MODE]
is_input = mode[CONF_INPUT]
@@ -160,7 +163,7 @@ class PinInitialState:
@pins.PIN_SCHEMA_REGISTRY.register(PLATFORM_ESP8266, ESP8266_PIN_SCHEMA)
async def esp8266_pin_to_code(config):
async def esp8266_pin_to_code(config: ConfigType) -> MockObj:
var = cg.new_Pvariable(config[CONF_ID])
num = config[CONF_NUMBER]
mode = config[CONF_MODE]
@@ -192,7 +195,7 @@ async def esp8266_pin_to_code(config):
@coroutine_with_priority(CoroPriority.WORKAROUNDS)
async def add_pin_initial_states_array():
async def add_pin_initial_states_array() -> None:
# Add includes at the very end, so that they override everything
initial_states: list[PinInitialState] = CORE.data[KEY_ESP8266][
KEY_PIN_INITIAL_STATES
+125 -2
View File
@@ -1,12 +1,16 @@
import logging
import esphome.codegen as cg
from esphome.components.noise import decode_encryption_key, encryption_schema
from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code
from esphome.config_helpers import merge_config
import esphome.config_validation as cv
from esphome.const import (
CONF_API,
CONF_ENCRYPTION,
CONF_ESPHOME,
CONF_ID,
CONF_KEY,
CONF_NUM_ATTEMPTS,
CONF_OTA,
CONF_PASSWORD,
@@ -15,6 +19,7 @@ from esphome.const import (
CONF_REBOOT_TIMEOUT,
CONF_SAFE_MODE,
CONF_VERSION,
CONF_WEB_SERVER,
)
from esphome.core import CORE, coroutine_with_priority
from esphome.coroutine import CoroPriority
@@ -30,7 +35,15 @@ CODEOWNERS = ["@esphome/core"]
DEPENDENCIES = ["network"]
AUTO_LOAD = ["sha256", "socket"]
def AUTO_LOAD(config: ConfigType) -> list[str]:
"""Auto-load noise only when encryption is configured."""
base = ["sha256", "socket"]
# A falsy config is a tooling probe for the maximal set (None from
# dependency resolution, {} from the components-graph platform probe);
# a validated config always carries defaults, never empty
if not config or CONF_ENCRYPTION in config:
return base + ["noise"]
return base
esphome = cg.esphome_ns.namespace("esphome")
@@ -67,11 +80,24 @@ def ota_esphome_final_validate(config):
CONF_PASSWORD in merged_ota_esphome_configs_by_port[conf_port]
and CONF_PASSWORD in ota_conf
and merged_ota_esphome_configs_by_port[conf_port][CONF_PASSWORD]
!= ota_conf.get(CONF_PASSWORD)
!= ota_conf[CONF_PASSWORD]
):
raise cv.Invalid(
f"Found multiple configurations but {CONF_PASSWORD} is inconsistent"
)
# Encryption blocks conflict only when both pin a key; a bare
# `encryption:` (a package/device split) is compatible with a
# keyed one, and merge_config yields the keyed result
merged_key = (
merged_ota_esphome_configs_by_port[conf_port]
.get(CONF_ENCRYPTION, {})
.get(CONF_KEY)
)
other_key = ota_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY)
if merged_key and other_key and merged_key != other_key:
raise cv.Invalid(
f"Found multiple configurations but {CONF_ENCRYPTION} is inconsistent"
)
ports_with_merged_configs.append(conf_port)
merged_ota_esphome_configs_by_port[conf_port] = merge_config(
@@ -94,6 +120,73 @@ def ota_esphome_final_validate(config):
new_ota_conf.extend(merged_ota_esphome_configs_by_port.values())
# There is one encryption key per device: when the api component has one,
# ota uses it, and an explicit ota key must match it. A bare `encryption:`
# block resolves to the api key here so both codegen and the upload CLI
# see the actual key.
api_conf = full_conf.get(CONF_API) or {}
api_key = api_conf.get(CONF_ENCRYPTION, {}).get(CONF_KEY)
has_web_server_ota = any(
conf.get(CONF_PLATFORM) == CONF_WEB_SERVER for conf in full_ota_conf
)
for ota_conf in merged_ota_esphome_configs_by_port.values():
# Merging same-port blocks can combine a password from one block with
# encryption from another; re-check the exclusion on the merged result.
_validate_no_password_with_encryption(ota_conf)
if (encryption_conf := ota_conf.get(CONF_ENCRYPTION)) is None:
continue
if has_web_server_ota:
# The web_server ota platform accepts the same image over plain
# HTTP with basic auth, a full bypass of the encryption.
if CONF_WEB_SERVER in full_conf:
# With the web_server component the endpoint is always on;
# fail closed like the password combination
raise cv.Invalid(
f"'{CONF_OTA}' {CONF_ENCRYPTION} cannot be combined with the "
f"'{CONF_WEB_SERVER}' component; its '{CONF_OTA}' platform "
f"accepts the same image over plaintext HTTP, remove one of them"
)
# Without the component the platform is the captive_portal
# auto-load: the endpoint only exists while the fallback AP is
# active, so keep the recovery path and warn instead
_LOGGER.warning(
"OTA encryption does not cover the %s OTA platform (auto-loaded "
"by captive_portal); the plaintext /update endpoint stays "
"reachable while the fallback AP is active",
CONF_WEB_SERVER,
)
if ota_key := encryption_conf.get(CONF_KEY):
if api_key and ota_key != api_key:
raise cv.Invalid(
f"'{CONF_OTA}' {CONF_ENCRYPTION} {CONF_KEY} must match the "
f"'{CONF_API}' {CONF_ENCRYPTION} {CONF_KEY}; omit the "
f"'{CONF_OTA}' {CONF_KEY} to use the '{CONF_API}' one"
)
elif not api_key:
if CONF_ENCRYPTION in api_conf:
# A keyless `api: encryption:` block gets its key provisioned
# at runtime and stored in flash, so there is nothing to
# inherit at build time
raise cv.Invalid(
f"the '{CONF_API}' {CONF_ENCRYPTION} {CONF_KEY} is provisioned at "
f"runtime and cannot be inherited at build time; set an explicit "
f"'{CONF_OTA}' {CONF_ENCRYPTION} {CONF_KEY}"
)
raise cv.Invalid(
f"'{CONF_OTA}' {CONF_ENCRYPTION} has no {CONF_KEY} and there is no "
f"'{CONF_API}' {CONF_ENCRYPTION} {CONF_KEY} to inherit; set one of them"
)
else:
encryption_conf[CONF_KEY] = api_key
# The device treats the all-zeros PSK as "no key configured" (it is the
# api provisioning sentinel), so letting it through would leave the OTA
# port accepting plaintext while the YAML says encryption. Fail closed.
if not any(decode_encryption_key(encryption_conf[CONF_KEY])):
raise cv.Invalid(
f"The all-zeros {CONF_KEY} is reserved and provides no protection; "
f"generate a real key with: openssl rand -base64 32"
)
full_conf[CONF_OTA] = new_ota_conf
fv.full_config.set(full_conf)
@@ -107,6 +200,17 @@ def ota_esphome_final_validate(config):
)
# Not cv.has_at_most_one_key: this message explains the why, and the check is
# reused on merged same-port configs in final validate where schemas do not run
def _validate_no_password_with_encryption(config: ConfigType) -> ConfigType:
if CONF_PASSWORD in config and CONF_ENCRYPTION in config:
raise cv.Invalid(
f"'{CONF_PASSWORD}' cannot be combined with '{CONF_ENCRYPTION}'; the "
f"encryption key already authenticates the uploader, remove '{CONF_PASSWORD}'"
)
return config
def _consume_ota_sockets(config: ConfigType) -> ConfigType:
"""Register socket needs for OTA component."""
from esphome.components import socket
@@ -134,6 +238,7 @@ CONFIG_SCHEMA = cv.All(
): cv.port,
cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean,
cv.Optional(CONF_PASSWORD): cv.sensitive(),
cv.Optional(CONF_ENCRYPTION): encryption_schema,
cv.Optional(CONF_NUM_ATTEMPTS): cv.invalid(
f"'{CONF_SAFE_MODE}' (and its related configuration variables) has moved from 'ota' to its own component. See https://esphome.io/components/safe_mode"
),
@@ -147,12 +252,24 @@ CONFIG_SCHEMA = cv.All(
)
.extend(BASE_OTA_SCHEMA)
.extend(cv.COMPONENT_SCHEMA),
_validate_no_password_with_encryption,
_consume_ota_sockets,
)
FINAL_VALIDATE_SCHEMA = ota_esphome_final_validate
def FILTER_SOURCE_FILES() -> list[str]:
"""Filter out the noise transport when no ota entry configures encryption."""
for ota_conf in CORE.config.get(CONF_OTA, []):
if (
ota_conf.get(CONF_PLATFORM) == CONF_ESPHOME
and ota_conf.get(CONF_ENCRYPTION) is not None
):
return []
return ["ota_esphome_noise.cpp"]
@coroutine_with_priority(CoroPriority.OTA_UPDATES)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
@@ -171,6 +288,12 @@ async def to_code(config: ConfigType) -> None:
if config.get(CONF_ALLOW_PARTITION_ACCESS):
cg.add_define("USE_OTA_PARTITIONS")
if (encryption_conf := config.get(CONF_ENCRYPTION)) is not None:
# A missing key was resolved from the api component in final validate.
key = encryption_conf[CONF_KEY]
cg.add_define("USE_OTA_ENCRYPTION")
cg.add(var.set_noise_psk(list(decode_encryption_key(key))))
# Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it.
cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME")
+97 -29
View File
@@ -27,7 +27,6 @@ namespace esphome {
static const char *const TAG = "esphome.ota";
static constexpr uint16_t OTA_BLOCK_SIZE = 8192;
static constexpr size_t OTA_BUFFER_SIZE = 1024; // buffer size for OTA data transfer
static constexpr uint32_t OTA_SOCKET_TIMEOUT_HANDSHAKE = 20000; // milliseconds for initial handshake
static constexpr uint32_t OTA_SOCKET_TIMEOUT_DATA = 90000; // milliseconds for data transfer
@@ -105,6 +104,11 @@ void ESPHomeOTAComponent::dump_config() {
ESP_LOGCONFIG(TAG, " Password configured");
}
#endif
#ifdef USE_OTA_ENCRYPTION
if (this->noise_ctx_.has_psk()) {
ESP_LOGCONFIG(TAG, " Encryption configured");
}
#endif
#ifdef USE_OTA_PARTITIONS
ESP_LOGCONFIG(TAG,
" Partition access allowed\n"
@@ -148,8 +152,10 @@ void ESPHomeOTAComponent::loop() {
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01;
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02;
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04;
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_NOISE = 0x08;
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01;
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02;
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_NOISE = 0x04;
void ESPHomeOTAComponent::handle_handshake_() {
/// Handle the OTA handshake and authentication.
@@ -201,8 +207,7 @@ void ESPHomeOTAComponent::handle_handshake_() {
}
// Validate magic bytes
static const uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45};
if (memcmp(this->handshake_buf_, MAGIC_BYTES, 5) != 0) {
if (memcmp(this->handshake_buf_, MAGIC_BYTES, sizeof(MAGIC_BYTES)) != 0) {
ESP_LOGW(TAG, "Magic bytes mismatch! 0x%02X-0x%02X-0x%02X-0x%02X-0x%02X", this->handshake_buf_[0],
this->handshake_buf_[1], this->handshake_buf_[2], this->handshake_buf_[3], this->handshake_buf_[4]);
this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_MAGIC);
@@ -234,6 +239,19 @@ void ESPHomeOTAComponent::handle_handshake_() {
}
this->ota_features_ = this->handshake_buf_[0];
ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_);
#ifdef USE_OTA_ENCRYPTION
// Fail closed: with a PSK configured the client must negotiate encryption
// (which requires the extended protocol); refuse plaintext uploads.
static constexpr uint8_t NOISE_REQUIRED_FEATURES =
CLIENT_FEATURE_SUPPORTS_NOISE | CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL;
if (this->noise_ctx_.has_psk() && (this->ota_features_ & NOISE_REQUIRED_FEATURES) != NOISE_REQUIRED_FEATURES) {
ESP_LOGW(TAG, "Client does not support encryption");
this->send_error_and_cleanup_(ota::OTA_RESPONSE_ERROR_ENCRYPTION_REQUIRED);
return;
}
#endif
this->transition_ota_state_(OTAState::FEATURE_ACK);
const bool supports_compression =
@@ -249,6 +267,12 @@ void ESPHomeOTAComponent::handle_handshake_() {
this->handshake_buf_[1] = (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0);
#ifdef USE_OTA_PARTITIONS
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS;
#endif
#ifdef USE_OTA_ENCRYPTION
if (this->noise_ctx_.has_psk()) {
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_NOISE;
}
this->server_feature_flags_ = this->handshake_buf_[1];
#endif
} else {
this->handshake_buf_[0] =
@@ -264,6 +288,18 @@ void ESPHomeOTAComponent::handle_handshake_() {
if (!this->try_write_(ack_size, LOG_STR("ack feature"))) {
return;
}
#ifdef USE_OTA_ENCRYPTION
// With a PSK configured the rest of the session runs inside the noise
// transport; the client sends the first handshake frame next, so there
// is nothing to do until data arrives.
if (this->noise_ctx_.has_psk()) {
if (!this->noise_start_session_()) {
return;
}
this->transition_ota_state_(OTAState::NOISE_HANDSHAKE);
return;
}
#endif
#ifdef USE_OTA_PASSWORD
// If password is set, move to auth phase
if (!this->password_.empty()) {
@@ -301,6 +337,16 @@ void ESPHomeOTAComponent::handle_handshake_() {
this->handle_data_();
return;
#ifdef USE_OTA_ENCRYPTION
case OTAState::NOISE_HANDSHAKE:
if (!this->handle_noise_handshake_()) {
return;
}
this->transition_ota_state_(OTAState::DATA);
this->handle_data_();
return;
#endif
default:
break;
}
@@ -360,12 +406,13 @@ void ESPHomeOTAComponent::handle_data_() {
this->client_->setblocking(true);
// Acknowledge auth OK - 1 byte
this->write_byte_(ota::OTA_RESPONSE_AUTH_OK);
this->data_write_byte_(ota::OTA_RESPONSE_AUTH_OK);
if (this->extended_proto_) {
// Read ota type, 1 byte
if (!this->readall_(buf, 1)) {
if (!this->data_readall_(buf, 1)) {
this->log_read_error_(LOG_STR("OTA type"));
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
ota_type = static_cast<ota::OTAType>(buf[0]);
@@ -373,8 +420,9 @@ void ESPHomeOTAComponent::handle_data_() {
ESP_LOGV(TAG, "OTA type is 0x%02x", ota_type);
// Read size, 4 bytes MSB first
if (!this->readall_(buf, 4)) {
if (!this->data_readall_(buf, 4)) {
this->log_read_error_(LOG_STR("size"));
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
ota_size = (static_cast<size_t>(buf[0]) << 24) | (static_cast<size_t>(buf[1]) << 16) |
@@ -398,17 +446,18 @@ void ESPHomeOTAComponent::handle_data_() {
this->notify_state_(ota::OTA_STARTED, 0.0f, 0);
#endif
// begin() may block for a few seconds while it locks flash.
// begin() returns quickly; flash sectors are erased incrementally during write().
error_code = this->backend_->begin(ota_size, ota_type);
if (error_code != ota::OTA_RESPONSE_OK)
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
// Acknowledge prepare OK - 1 byte
this->write_byte_(ota::OTA_RESPONSE_UPDATE_PREPARE_OK);
this->data_write_byte_(ota::OTA_RESPONSE_UPDATE_PREPARE_OK);
// Read binary MD5, 32 bytes
if (!this->readall_(buf, 32)) {
if (!this->data_readall_(buf, 32)) {
this->log_read_error_(LOG_STR("MD5 checksum"));
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
sbuf[32] = '\0';
@@ -416,7 +465,7 @@ void ESPHomeOTAComponent::handle_data_() {
this->backend_->set_update_md5(sbuf);
// Acknowledge MD5 OK - 1 byte
this->write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK);
this->data_write_byte_(ota::OTA_RESPONSE_BIN_MD5_OK);
// Track when we last received data so a silently-vanished peer (no FIN/RST
// delivered, e.g. uploader killed mid-transfer or NAT/router dropped state)
@@ -432,19 +481,37 @@ void ESPHomeOTAComponent::handle_data_() {
}
size_t remaining = ota_size - total;
size_t requested = remaining < OTA_BUFFER_SIZE ? remaining : OTA_BUFFER_SIZE;
ssize_t read = this->client_->read(buf, requested);
if (read == -1) {
const int err = errno;
if (this->would_block_(err)) {
// read() already waited up to SO_RCVTIMEO for data, just feed WDT
App.feed_wdt();
continue;
ssize_t read;
#ifdef USE_OTA_ENCRYPTION
if (this->noise_ != nullptr) {
// One frame per call; noise_read_data_ waits internally (readall_), so
// there is no would-block retry here and failures are already logged.
read = this->noise_read_data_(buf, requested);
if (read <= 0) {
// error_code still holds the last OK; report a real failure instead
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
} else
#endif
{
read = this->client_->read(buf, requested);
if (read == -1) {
const int err = errno;
if (this->would_block_(err)) {
// read() already waited up to SO_RCVTIMEO for data, just feed WDT
App.feed_wdt();
continue;
}
ESP_LOGW(TAG, "Read err %d", err);
// error_code still holds the last OK; report a real failure instead
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
} else if (read == 0) {
ESP_LOGW(TAG, "Remote closed");
error_code = ota::OTA_RESPONSE_ERROR_UNKNOWN;
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
ESP_LOGW(TAG, "Read err %d", err);
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
} else if (read == 0) {
ESP_LOGW(TAG, "Remote closed");
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
}
last_data_ms = millis();
@@ -456,7 +523,7 @@ void ESPHomeOTAComponent::handle_data_() {
total += read;
#if USE_OTA_VERSION == 2
while (size_acknowledged + OTA_BLOCK_SIZE <= total || (total == ota_size && size_acknowledged < ota_size)) {
this->write_byte_(ota::OTA_RESPONSE_CHUNK_OK);
this->data_write_byte_(ota::OTA_RESPONSE_CHUNK_OK);
size_acknowledged += OTA_BLOCK_SIZE;
}
#endif
@@ -475,7 +542,7 @@ void ESPHomeOTAComponent::handle_data_() {
}
// Acknowledge receive OK - 1 byte
this->write_byte_(ota::OTA_RESPONSE_RECEIVE_OK);
this->data_write_byte_(ota::OTA_RESPONSE_RECEIVE_OK);
error_code = this->backend_->end();
if (error_code != ota::OTA_RESPONSE_OK) {
@@ -484,10 +551,10 @@ void ESPHomeOTAComponent::handle_data_() {
}
// Acknowledge Update end OK - 1 byte
this->write_byte_(ota::OTA_RESPONSE_UPDATE_END_OK);
this->data_write_byte_(ota::OTA_RESPONSE_UPDATE_END_OK);
// Read ACK
if (!this->readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) {
if (!this->data_readall_(buf, 1) || buf[0] != ota::OTA_RESPONSE_OK) {
this->log_read_error_(LOG_STR("ack"));
// do not go to error, this is not fatal
}
@@ -510,7 +577,7 @@ void ESPHomeOTAComponent::handle_data_() {
App.safe_reboot();
error:
this->write_byte_(static_cast<uint8_t>(error_code));
this->data_write_byte_(static_cast<uint8_t>(error_code));
// Abort backend before cleanup - cleanup_connection_() destroys the backend.
// Always call abort() unconditionally: backends register external partitions before
@@ -588,8 +655,6 @@ bool ESPHomeOTAComponent::writeall_(const uint8_t *buf, size_t len) {
}
float ESPHomeOTAComponent::get_setup_priority() const { return setup_priority::AFTER_WIFI; }
uint16_t ESPHomeOTAComponent::get_port() const { return this->port_; }
void ESPHomeOTAComponent::set_port(uint16_t port) { this->port_ = port; }
void ESPHomeOTAComponent::log_socket_error_(const LogString *msg) {
ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno);
@@ -679,6 +744,9 @@ void ESPHomeOTAComponent::cleanup_connection_() {
this->backend_ = nullptr;
#ifdef USE_OTA_PASSWORD
this->cleanup_auth_();
#endif
#ifdef USE_OTA_ENCRYPTION
this->noise_ = nullptr;
#endif
// Intentionally no disable_loop() — letting loop() run one more iteration catches
// any connection that queued on the listener mid-session (otherwise the wake flag,
+69 -3
View File
@@ -4,6 +4,9 @@
#ifdef USE_OTA
#include "esphome/components/ota/ota_backend_factory.h"
#include "esphome/components/socket/socket.h"
#ifdef USE_OTA_ENCRYPTION
#include "esphome/components/noise/noise_handshake.h"
#endif
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/preferences.h"
@@ -24,7 +27,10 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
AUTH_SEND, // Sending authentication request
AUTH_READ, // Reading authentication data
#endif // USE_OTA_PASSWORD
DATA, // BLOCKING! Processing OTA data (update, etc.)
#ifdef USE_OTA_ENCRYPTION
NOISE_HANDSHAKE, // Exchanging Noise handshake frames
#endif
DATA, // BLOCKING! Processing OTA data (update, etc.)
};
#ifdef USE_OTA_PASSWORD
void set_auth_password(const std::string &password) { password_ = password; }
@@ -38,15 +44,19 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
}
#endif // USE_OTA_PASSWORD
#ifdef USE_OTA_ENCRYPTION
void set_noise_psk(noise::psk_t psk) { this->noise_ctx_.set_psk(psk); }
#endif
/// Manually set the port OTA should listen on
void set_port(uint16_t port);
void set_port(uint16_t port) { this->port_ = port; }
void setup() override;
void dump_config() override;
float get_setup_priority() const override;
void loop() override;
uint16_t get_port() const;
uint16_t get_port() const { return this->port_; }
protected:
void handle_handshake_();
@@ -63,6 +73,48 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
bool writeall_(const uint8_t *buf, size_t len);
inline bool write_byte_(uint8_t byte) { return this->writeall_(&byte, 1); }
#ifdef USE_OTA_ENCRYPTION
// Heap-allocated only while an encrypted OTA session is active.
struct NoiseSession {
~NoiseSession();
noise::NoiseResponderHandshake handshake;
NoiseCipherState *send_cipher{nullptr};
NoiseCipherState *recv_cipher{nullptr};
uint16_t frame_len{0}; // total frame size once the header is parsed, 0 until then
uint16_t frame_pos{0}; // bytes read or written so far
bool writing{false}; // a produced handshake frame is still being flushed
uint8_t frame_buf[noise::FRAME_HEADER_SIZE + 1 + noise::MAX_HANDSHAKE_SIZE];
};
bool noise_start_session_();
bool handle_noise_handshake_();
bool noise_try_read_frame_();
bool noise_try_write_frame_();
void noise_send_reject_(const LogString *reason);
ssize_t noise_decrypt_(uint8_t *buf, size_t len);
ssize_t noise_read_frame_blocking_(uint8_t *buf, size_t min_ciphertext, size_t max_ciphertext);
bool noise_readall_(uint8_t *buf, size_t len);
ssize_t noise_read_data_(uint8_t *buf, size_t capacity);
bool noise_write_byte_(uint8_t byte);
#endif // USE_OTA_ENCRYPTION
// Data-phase I/O dispatch: through the noise transport when a session is
// active, straight to the socket otherwise.
inline bool data_write_byte_(uint8_t byte) {
#ifdef USE_OTA_ENCRYPTION
if (this->noise_ != nullptr)
return this->noise_write_byte_(byte);
#endif
return this->write_byte_(byte);
}
// When encrypted, buf must have room for len + noise::MAC_SIZE bytes.
inline bool data_readall_(uint8_t *buf, size_t len) {
#ifdef USE_OTA_ENCRYPTION
if (this->noise_ != nullptr)
return this->noise_readall_(buf, len);
#endif
return this->readall_(buf, len);
}
bool try_read_(size_t to_read, const LogString *desc);
bool try_write_(size_t to_write, const LogString *desc);
@@ -91,6 +143,11 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
std::string password_;
std::unique_ptr<uint8_t[]> auth_buf_;
#endif // USE_OTA_PASSWORD
#ifdef USE_OTA_ENCRYPTION
noise::NoiseContext noise_ctx_;
std::unique_ptr<NoiseSession> noise_;
uint8_t server_feature_flags_{0}; // as sent in the feature ack, bound into the prologue
#endif // USE_OTA_ENCRYPTION
socket::ListenSocket *server_{nullptr};
std::unique_ptr<socket::Socket> client_;
@@ -98,6 +155,15 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
uint32_t client_connect_time_{0};
static constexpr size_t HANDSHAKE_BUF_SIZE = 5;
// Buffer size for OTA data transfer. The upload client derives its maximum
// encrypted frame plaintext from this (espota2.NOISE_MAX_PLAINTEXT is this
// minus the 16-byte MAC); both must change together.
static constexpr size_t OTA_BUFFER_SIZE = 1024;
#ifdef USE_OTA_ENCRYPTION
// Shrinking the buffer would reject every frame a current CLI sends
static_assert(OTA_BUFFER_SIZE >= 1008 + noise::MAC_SIZE, "OTA_BUFFER_SIZE must fit a full encrypted data frame");
#endif
static constexpr uint8_t MAGIC_BYTES[5] = {0x6C, 0x26, 0xF7, 0x5C, 0x45};
#ifdef USE_OTA_PARTITIONS
uint32_t running_app_offset_{0};
size_t running_app_size_{0};
@@ -0,0 +1,274 @@
#include "ota_esphome.h"
#ifdef USE_OTA
#ifdef USE_OTA_ENCRYPTION
#include "esphome/components/noise/noise.h"
#include "esphome/components/ota/ota_backend.h"
#include "esphome/core/log.h"
#include <cstring>
#include <new>
#ifdef USE_ESP8266
#include <pgmspace.h>
#endif
namespace esphome {
static const char *const TAG = "esphome.ota";
#ifdef USE_ESP8266
static constexpr char OTA_NOISE_PROLOGUE_INIT[] PROGMEM = "NoiseOTAInit";
#else
static const char *const OTA_NOISE_PROLOGUE_INIT = "NoiseOTAInit";
#endif
static constexpr size_t OTA_NOISE_PROLOGUE_INIT_LEN = 12; // strlen("NoiseOTAInit")
ESPHomeOTAComponent::NoiseSession::~NoiseSession() {
if (this->send_cipher != nullptr) {
noise_cipherstate_free(this->send_cipher);
}
if (this->recv_cipher != nullptr) {
noise_cipherstate_free(this->recv_cipher);
}
}
/** Allocate the session and start the responder handshake.
*
* The prologue binds the whole plaintext preamble, so any tampering with the
* negotiation (a stripped feature flag, a changed version) breaks the first
* handshake MAC on either side:
* "NoiseOTAInit" | magic(5) | OK,version | client_features | FEATURE_FLAGS,server_flags
*/
bool ESPHomeOTAComponent::noise_start_session_() {
// NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks)
this->noise_ = std::unique_ptr<NoiseSession>(new (std::nothrow) NoiseSession());
if (this->noise_ == nullptr) {
ESP_LOGW(TAG, "Session allocation failed");
this->cleanup_connection_();
return false;
}
uint8_t prologue[OTA_NOISE_PROLOGUE_INIT_LEN + 5 + 2 + 1 + 2];
#ifdef USE_ESP8266
memcpy_P(prologue, OTA_NOISE_PROLOGUE_INIT, OTA_NOISE_PROLOGUE_INIT_LEN);
#else
std::memcpy(prologue, OTA_NOISE_PROLOGUE_INIT, OTA_NOISE_PROLOGUE_INIT_LEN);
#endif
uint8_t *p = prologue + OTA_NOISE_PROLOGUE_INIT_LEN;
// Magic bytes, already validated in MAGIC_READ
std::memcpy(p, MAGIC_BYTES, sizeof(MAGIC_BYTES));
p += sizeof(MAGIC_BYTES);
// Our magic ack
*p++ = ota::OTA_RESPONSE_OK;
*p++ = USE_OTA_VERSION;
// The feature byte the client sent
*p++ = this->ota_features_;
// The feature ack we sent (noise requires the extended protocol)
*p++ = ota::OTA_RESPONSE_FEATURE_FLAGS;
*p++ = this->server_feature_flags_;
int err = this->noise_->handshake.init(this->noise_ctx_.get_psk(), prologue, sizeof(prologue));
if (err != 0) {
ESP_LOGW(TAG, "Handshake init: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
this->cleanup_connection_();
return false;
}
return true;
}
/** Drive the non-blocking handshake from loop(); returns true once the
* transport ciphers are ready and the session can enter the data phase.
* On failure the connection is cleaned up and false is returned.
*/
bool ESPHomeOTAComponent::handle_noise_handshake_() {
NoiseSession &s = *this->noise_;
while (true) {
if (s.writing) {
if (!this->noise_try_write_frame_()) {
return false; // would block, or errored and cleaned up
}
s.writing = false;
s.frame_pos = 0;
s.frame_len = 0;
}
switch (s.handshake.action()) {
case noise::NoiseResponderHandshake::Action::ACTION_READ: {
if (!this->noise_try_read_frame_()) {
return false;
}
const uint16_t payload_len = s.frame_len - noise::FRAME_HEADER_SIZE;
s.frame_pos = 0;
s.frame_len = 0;
if (s.frame_buf[noise::FRAME_HEADER_SIZE] != noise::HANDSHAKE_STATUS_OK) {
ESP_LOGW(TAG, "Bad handshake error byte: %u", s.frame_buf[noise::FRAME_HEADER_SIZE]);
this->cleanup_connection_();
return false;
}
int err = s.handshake.read_message(s.frame_buf + noise::FRAME_HEADER_SIZE + 1, payload_len - 1);
if (err != 0) {
ESP_LOGW(TAG, "Handshake read: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
this->noise_send_reject_(noise::reject_reason_for(err));
this->cleanup_connection_();
return false;
}
break;
}
case noise::NoiseResponderHandshake::Action::ACTION_WRITE: {
size_t msg_len = 0;
int err =
s.handshake.write_message(s.frame_buf + noise::FRAME_HEADER_SIZE + 1, noise::MAX_HANDSHAKE_SIZE, msg_len);
if (err != 0) {
ESP_LOGW(TAG, "Handshake write: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
this->cleanup_connection_();
return false;
}
const uint16_t payload_len = msg_len + 1;
noise::write_frame_header(s.frame_buf, payload_len);
s.frame_buf[noise::FRAME_HEADER_SIZE] = noise::HANDSHAKE_STATUS_OK;
s.frame_len = noise::FRAME_HEADER_SIZE + payload_len;
s.frame_pos = 0;
s.writing = true;
break;
}
case noise::NoiseResponderHandshake::Action::ACTION_SPLIT: {
int err = s.handshake.split(s.send_cipher, s.recv_cipher);
if (err != 0) {
ESP_LOGW(TAG, "Handshake split: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
this->cleanup_connection_();
return false;
}
ESP_LOGD(TAG, "Noise handshake complete");
return true;
}
default: {
ESP_LOGW(TAG, "Bad handshake state");
this->cleanup_connection_();
return false;
}
}
}
}
/// Non-blocking read of one handshake frame into the session buffer.
bool ESPHomeOTAComponent::noise_try_read_frame_() {
NoiseSession &s = *this->noise_;
while (s.frame_pos < noise::FRAME_HEADER_SIZE) {
ssize_t read = this->client_->read(s.frame_buf + s.frame_pos, noise::FRAME_HEADER_SIZE - s.frame_pos);
if (!this->handle_read_error_(read, LOG_STR("read noise header"))) {
return false;
}
s.frame_pos += read;
}
if (s.frame_len == 0) {
const uint16_t payload_len = encode_uint16(s.frame_buf[1], s.frame_buf[2]);
if (s.frame_buf[0] != noise::FRAME_INDICATOR || payload_len < 1 || payload_len > 1 + noise::MAX_HANDSHAKE_SIZE) {
ESP_LOGW(TAG, "Bad handshake frame: 0x%02X, %u bytes", s.frame_buf[0], payload_len);
this->cleanup_connection_();
return false;
}
s.frame_len = noise::FRAME_HEADER_SIZE + payload_len;
}
while (s.frame_pos < s.frame_len) {
ssize_t read = this->client_->read(s.frame_buf + s.frame_pos, s.frame_len - s.frame_pos);
if (!this->handle_read_error_(read, LOG_STR("read noise frame"))) {
return false;
}
s.frame_pos += read;
}
return true;
}
/// Non-blocking write of the pending session-buffer frame.
bool ESPHomeOTAComponent::noise_try_write_frame_() {
NoiseSession &s = *this->noise_;
while (s.frame_pos < s.frame_len) {
ssize_t written = this->client_->write(s.frame_buf + s.frame_pos, s.frame_len - s.frame_pos);
if (!this->handle_write_error_(written, LOG_STR("write noise frame"))) {
return false;
}
s.frame_pos += written;
}
return true;
}
/// Best-effort explicit reject frame so the client can log a readable reason.
void ESPHomeOTAComponent::noise_send_reject_(const LogString *reason) {
uint8_t data[noise::FRAME_HEADER_SIZE + 1 + 32];
static_assert(sizeof(data) - noise::FRAME_HEADER_SIZE >= noise::MAC_FAILURE_PAYLOAD_SIZE,
"reject buffer must fit the MAC failure wire contract");
const size_t payload_len =
noise::format_reject_payload(data + noise::FRAME_HEADER_SIZE, sizeof(data) - noise::FRAME_HEADER_SIZE, reason);
noise::write_frame_header(data, payload_len);
this->client_->write(data, noise::FRAME_HEADER_SIZE + payload_len); // Best effort, non-blocking
}
/// Decrypt a ciphertext in place; returns the plaintext size or -1.
ssize_t ESPHomeOTAComponent::noise_decrypt_(uint8_t *buf, size_t len) {
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_inout(mbuf, buf, len, len);
int err = noise_cipherstate_decrypt(this->noise_->recv_cipher, &mbuf);
if (err != 0) {
ESP_LOGW(TAG, "Decrypt: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
return -1;
}
return mbuf.size;
}
/** Blocking read of one frame whose ciphertext size must be within the given
* bounds, decrypted in place; returns the plaintext size, or -1 on error.
* buf needs max_ciphertext capacity.
*/
ssize_t ESPHomeOTAComponent::noise_read_frame_blocking_(uint8_t *buf, size_t min_ciphertext, size_t max_ciphertext) {
uint8_t header[noise::FRAME_HEADER_SIZE];
if (!this->readall_(header, sizeof(header))) {
return -1;
}
const size_t ciphertext_len = encode_uint16(header[1], header[2]);
if (header[0] != noise::FRAME_INDICATOR || ciphertext_len < min_ciphertext || ciphertext_len > max_ciphertext) {
ESP_LOGW(TAG, "Bad frame: 0x%02X, %zu bytes", header[0], ciphertext_len);
return -1;
}
if (!this->readall_(buf, ciphertext_len)) {
return -1;
}
return this->noise_decrypt_(buf, ciphertext_len);
}
/** Blocking read of one frame whose plaintext must be exactly len bytes
* (control units are one unit per frame). buf needs len + noise::MAC_SIZE
* capacity; the plaintext lands at buf[0..len).
*/
bool ESPHomeOTAComponent::noise_readall_(uint8_t *buf, size_t len) {
return this->noise_read_frame_blocking_(buf, len + noise::MAC_SIZE, len + noise::MAC_SIZE) == (ssize_t) len;
}
/** Blocking read of one data-phase frame, decrypted in place; returns the
* plaintext size, or -1 on error. buf is the OTA_BUFFER_SIZE data buffer.
* The ciphertext must fit that buffer and its plaintext must fit what the
* caller accepts (the remaining image bytes).
*/
ssize_t ESPHomeOTAComponent::noise_read_data_(uint8_t *buf, size_t capacity) {
const size_t max_ciphertext = std::min(capacity + noise::MAC_SIZE, OTA_BUFFER_SIZE);
return this->noise_read_frame_blocking_(buf, noise::MAC_SIZE + 1, max_ciphertext);
}
/// Blocking write of one response byte as an encrypted frame.
bool ESPHomeOTAComponent::noise_write_byte_(uint8_t byte) {
uint8_t frame[noise::FRAME_HEADER_SIZE + 1 + noise::MAC_SIZE];
frame[noise::FRAME_HEADER_SIZE] = byte;
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_inout(mbuf, frame + noise::FRAME_HEADER_SIZE, 1, 1 + noise::MAC_SIZE);
int err = noise_cipherstate_encrypt(this->noise_->send_cipher, &mbuf);
if (err != 0) {
ESP_LOGW(TAG, "Encrypt: %s", LOG_STR_ARG(noise::noise_err_to_logstr(err)));
return false;
}
noise::write_frame_header(frame, mbuf.size);
return this->writeall_(frame, noise::FRAME_HEADER_SIZE + mbuf.size);
}
} // namespace esphome
#endif // USE_OTA_ENCRYPTION
#endif // USE_OTA
@@ -10,14 +10,6 @@ EthernetComponent *global_eth_component; // NOLINT(cppcoreguidelines-avoid-non-
EthernetComponent::EthernetComponent() { global_eth_component = this; }
float EthernetComponent::get_setup_priority() const { return setup_priority::WIFI; }
void EthernetComponent::set_type(EthernetType type) { this->type_ = type; }
#ifdef USE_ETHERNET_MANUAL_IP
void EthernetComponent::set_manual_ip(const ManualIP &manual_ip) { this->manual_ip_ = manual_ip; }
#endif
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
void EthernetComponent::notify_ip_state_listeners_() {
auto ips = this->get_ip_addresses();
@@ -125,7 +125,7 @@ class EthernetComponent final : public Component {
void setup() override;
void loop() override;
void dump_config() override;
float get_setup_priority() const override;
float get_setup_priority() const override { return setup_priority::ETHERNET; }
void on_powerdown() override { powerdown(); }
bool is_connected() { return this->state_ == EthernetComponentState::CONNECTED; }
@@ -146,9 +146,9 @@ class EthernetComponent final : public Component {
esp_netif_t *get_esp_netif() { return this->eth_netif_; }
#endif
void set_type(EthernetType type);
void set_type(EthernetType type) { this->type_ = type; }
#ifdef USE_ETHERNET_MANUAL_IP
void set_manual_ip(const ManualIP &manual_ip);
void set_manual_ip(const ManualIP &manual_ip) { this->manual_ip_ = manual_ip; }
#endif
void set_fixed_mac(const std::array<uint8_t, MAC_ADDRESS_SIZE> &mac) { this->fixed_mac_ = mac; }
@@ -159,9 +159,6 @@ class EthernetComponent final : public Component {
const char *get_use_address() const { return this->use_address_; }
void set_use_address(const char *use_address) { this->use_address_ = use_address; }
void get_eth_mac_address_raw(uint8_t *mac);
// Remove before 2026.9.0
ESPDEPRECATED("Use get_eth_mac_address_pretty_into_buffer() instead. Removed in 2026.9.0", "2026.3.0")
std::string get_eth_mac_address_pretty();
const char *get_eth_mac_address_pretty_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf);
eth_duplex_t get_duplex_mode();
eth_speed_t get_link_speed();
@@ -171,35 +168,35 @@ class EthernetComponent final : public Component {
esp_eth_handle_t get_eth_handle() const { return this->eth_handle_; }
#ifdef USE_ETHERNET_SPI
void set_clk_pin(uint8_t clk_pin);
void set_miso_pin(uint8_t miso_pin);
void set_mosi_pin(uint8_t mosi_pin);
void set_cs_pin(uint8_t cs_pin);
void set_interrupt_pin(uint8_t interrupt_pin);
void set_reset_pin(uint8_t reset_pin);
void set_clock_speed(int clock_speed);
void set_interface(spi_host_device_t interface);
void set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; }
void set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; }
void set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; }
void set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; }
void set_interrupt_pin(uint8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; }
void set_reset_pin(uint8_t reset_pin) { this->reset_pin_ = reset_pin; }
void set_clock_speed(int clock_speed) { this->clock_speed_ = clock_speed; }
void set_interface(spi_host_device_t interface) { this->interface_ = interface; }
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
void set_polling_interval(uint32_t polling_interval);
void set_polling_interval(uint32_t polling_interval) { this->polling_interval_ = polling_interval; }
#endif
#else
void set_phy_addr(uint8_t phy_addr);
void set_power_pin(int power_pin);
void set_mdc_pin(uint8_t mdc_pin);
void set_mdio_pin(uint8_t mdio_pin);
void set_clk_pin(uint8_t clk_pin);
void set_clk_mode(emac_rmii_clock_mode_t clk_mode);
void set_phy_addr(uint8_t phy_addr) { this->phy_addr_ = phy_addr; }
void set_power_pin(int power_pin) { this->power_pin_ = power_pin; }
void set_mdc_pin(uint8_t mdc_pin) { this->mdc_pin_ = mdc_pin; }
void set_mdio_pin(uint8_t mdio_pin) { this->mdio_pin_ = mdio_pin; }
void set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; }
void set_clk_mode(emac_rmii_clock_mode_t clk_mode) { this->clk_mode_ = clk_mode; }
void add_phy_register(PHYRegister register_value);
#endif // USE_ETHERNET_SPI
#endif // USE_ESP32
#ifdef USE_RP2
void set_clk_pin(uint8_t clk_pin);
void set_miso_pin(uint8_t miso_pin);
void set_mosi_pin(uint8_t mosi_pin);
void set_cs_pin(uint8_t cs_pin);
void set_interrupt_pin(int8_t interrupt_pin);
void set_reset_pin(int8_t reset_pin);
void set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; }
void set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; }
void set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; }
void set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; }
void set_interrupt_pin(int8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; }
void set_reset_pin(int8_t reset_pin) { this->reset_pin_ = reset_pin; }
#endif // USE_RP2
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
@@ -908,25 +908,7 @@ void EthernetComponent::dump_connect_params_() {
#endif /* USE_NETWORK_IPV6 */
}
#ifdef USE_ETHERNET_SPI
void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; }
void EthernetComponent::set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; }
void EthernetComponent::set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; }
void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; }
void EthernetComponent::set_interrupt_pin(uint8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; }
void EthernetComponent::set_reset_pin(uint8_t reset_pin) { this->reset_pin_ = reset_pin; }
void EthernetComponent::set_clock_speed(int clock_speed) { this->clock_speed_ = clock_speed; }
void EthernetComponent::set_interface(spi_host_device_t interface) { this->interface_ = interface; }
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
void EthernetComponent::set_polling_interval(uint32_t polling_interval) { this->polling_interval_ = polling_interval; }
#endif
#else
void EthernetComponent::set_phy_addr(uint8_t phy_addr) { this->phy_addr_ = phy_addr; }
void EthernetComponent::set_power_pin(int power_pin) { this->power_pin_ = power_pin; }
void EthernetComponent::set_mdc_pin(uint8_t mdc_pin) { this->mdc_pin_ = mdc_pin; }
void EthernetComponent::set_mdio_pin(uint8_t mdio_pin) { this->mdio_pin_ = mdio_pin; }
void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; }
void EthernetComponent::set_clk_mode(emac_rmii_clock_mode_t clk_mode) { this->clk_mode_ = clk_mode; }
#ifndef USE_ETHERNET_SPI
void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); }
#endif
@@ -946,11 +928,6 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) {
ESPHL_ERROR_CHECK(err, "ETH_CMD_G_MAC error");
}
std::string EthernetComponent::get_eth_mac_address_pretty() {
char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
return std::string(this->get_eth_mac_address_pretty_into_buffer(buf));
}
const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer(
std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) {
uint8_t mac[MAC_ADDRESS_SIZE];
@@ -249,11 +249,6 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) {
}
}
std::string EthernetComponent::get_eth_mac_address_pretty() {
char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
return std::string(this->get_eth_mac_address_pretty_into_buffer(buf));
}
const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer(
std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) {
uint8_t mac[MAC_ADDRESS_SIZE];
@@ -355,13 +350,6 @@ void EthernetComponent::dump_connect_params_() {
this->get_eth_mac_address_pretty_into_buffer(mac_buf));
}
void EthernetComponent::set_clk_pin(uint8_t clk_pin) { this->clk_pin_ = clk_pin; }
void EthernetComponent::set_miso_pin(uint8_t miso_pin) { this->miso_pin_ = miso_pin; }
void EthernetComponent::set_mosi_pin(uint8_t mosi_pin) { this->mosi_pin_ = mosi_pin; }
void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; }
void EthernetComponent::set_interrupt_pin(int8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; }
void EthernetComponent::set_reset_pin(int8_t reset_pin) { this->reset_pin_ = reset_pin; }
void EthernetComponent::enable() {
// RP2040 uses arduino-pico's LwipIntfDev which manages link state internally;
// there is no clean enable/disable hook today. The YAML option is accepted on
-5
View File
@@ -153,11 +153,6 @@ void FanRestoreState::apply(Fan &fan) {
fan.publish_state();
}
FanCall Fan::turn_on() { return this->make_call().set_state(true); }
FanCall Fan::turn_off() { return this->make_call().set_state(false); }
FanCall Fan::toggle() { return this->make_call().set_state(!this->state); }
FanCall Fan::make_call() { return FanCall(*this); }
const char *Fan::find_preset_mode_(const char *preset_mode) {
return this->find_preset_mode_(preset_mode, preset_mode ? strlen(preset_mode) : 0);
}
+4 -4
View File
@@ -115,10 +115,10 @@ class Fan : public EntityBase {
/// The current direction of the fan
FanDirection direction{FanDirection::FORWARD};
FanCall turn_on();
FanCall turn_off();
FanCall toggle();
FanCall make_call();
FanCall turn_on() { return this->make_call().set_state(true); }
FanCall turn_off() { return this->make_call().set_state(false); }
FanCall toggle() { return this->make_call().set_state(!this->state); }
FanCall make_call() { return FanCall(*this); }
/// Register a callback that will be called each time the state changes.
template<typename F> void add_on_state_callback(F &&callback) {
+10 -7
View File
@@ -5,6 +5,7 @@ import io
import logging
from pathlib import Path
import re
from typing import Any
from PIL import Image, UnidentifiedImageError
@@ -75,12 +76,12 @@ def compute_local_image_path(value: str | ConfigType) -> Path:
return external_files.compute_local_file_path(DOMAIN, url)
def local_path(value):
def local_path(value: str | ConfigType) -> str:
value = value[CONF_PATH] if isinstance(value, dict) else value
return str(CORE.relative_config_path(value))
def download_file(url, path):
def download_file(url: str, path: Path) -> str:
# The shared NETWORK_TIMEOUT applies; a per-caller timeout would be
# silently ignored on a per-run memo hit anyway (memos key by path).
external_files.download_content(url, path)
@@ -98,7 +99,7 @@ def download_gh_svg(value: str | ConfigType, source: str) -> str:
return download_file(url, path)
def download_image(value):
def download_image(value: str | ConfigType) -> str:
value = value[CONF_URL] if isinstance(value, dict) else value
return download_file(value, compute_local_image_path(value))
@@ -146,7 +147,7 @@ def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None:
PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref)
def validate_file_shorthand(value):
def validate_file_shorthand(value: Any) -> str:
value = cv.string_strict(value)
if (remote := _parse_remote_shorthand(value)) is not None:
return download_file(remote.url, remote.path)
@@ -163,8 +164,8 @@ LOCAL_SCHEMA = cv.All(
)
def mdi_schema(source):
def validate_mdi(value):
def mdi_schema(source: str) -> cv.All:
def validate_mdi(value: ConfigType) -> str:
return download_gh_svg(value, source)
return cv.All(
@@ -259,7 +260,9 @@ async def new_image(config: ConfigType) -> MockObj:
return var
async def write_image(config, all_frames=False):
async def write_image(
config: ConfigType, all_frames: bool = False
) -> tuple[MockObj, int, int, MockObj, MockObj, int]:
path = Path(config[CONF_FILE])
if not path.is_file():
raise core.EsphomeError(f"Could not load image file {path}")
+9 -3
View File
@@ -8,7 +8,8 @@ from esphome.const import (
CONF_TYPE,
CONF_VALUE,
)
from esphome.core import CoroPriority, coroutine_with_priority
from esphome.core import ID, CoroPriority, coroutine_with_priority
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
@@ -62,7 +63,7 @@ CONFIG_SCHEMA = _globals_schema
# Run with low priority so that namespaces are registered first
@coroutine_with_priority(CoroPriority.LATE)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
type_ = cg.RawExpression(config[CONF_TYPE])
restore = config[CONF_RESTORE_VALUE]
@@ -104,7 +105,12 @@ async def to_code(config):
),
synchronous=True,
)
async def globals_set_to_code(config, action_id, template_arg, args):
async def globals_set_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
full_id, paren = await cg.get_variable_with_full_id(config[CONF_ID])
template_arg = cg.TemplateArguments(full_id.type, *template_arg)
var = cg.new_Pvariable(action_id, template_arg, paren)
@@ -12,6 +12,7 @@ from esphome.const import (
CONF_PIN,
)
from esphome.core import CORE
from esphome.types import ConfigType
from .. import gpio_ns
@@ -68,7 +69,7 @@ def _pin_shared_only_with_deep_sleep(pin_num: int) -> bool:
return any(path and path[0] == "deep_sleep" for path, _, _ in pin_users)
def _final_validate(config) -> None:
def _final_validate(config: ConfigType) -> None:
use_interrupt = config[CONF_USE_INTERRUPT]
if not use_interrupt:
return
@@ -124,7 +125,7 @@ def _final_validate(config) -> None:
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = await binary_sensor.new_binary_sensor(config)
await cg.register_component(var, config)
+2 -1
View File
@@ -3,6 +3,7 @@ import esphome.codegen as cg
from esphome.components.one_wire import OneWireBus
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_PIN
from esphome.types import ConfigType
from .. import gpio_ns
@@ -18,7 +19,7 @@ CONFIG_SCHEMA = cv.Schema(
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
+2 -1
View File
@@ -3,6 +3,7 @@ import esphome.codegen as cg
from esphome.components import output
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_PIN
from esphome.types import ConfigType
from .. import gpio_ns
@@ -16,7 +17,7 @@ CONFIG_SCHEMA = output.BINARY_OUTPUT_SCHEMA.extend(
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await output.register_output(var, config)
await cg.register_component(var, config)
+2 -1
View File
@@ -3,6 +3,7 @@ import esphome.codegen as cg
from esphome.components import switch
import esphome.config_validation as cv
from esphome.const import CONF_INTERLOCK, CONF_PIN
from esphome.types import ConfigType
from .. import gpio_ns
@@ -24,7 +25,7 @@ CONFIG_SCHEMA = (
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = await switch.new_switch(config)
await cg.register_component(var, config)
+9 -3
View File
@@ -1,13 +1,19 @@
from collections.abc import Callable, Iterable
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_INTERNAL
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
CODEOWNERS = ["@OttoWinter", "@esphome/core"]
homeassistant_ns = cg.esphome_ns.namespace("homeassistant")
def validate_entity_domain(platform, supported_domains):
def validator(config):
def validate_entity_domain(
platform: str, supported_domains: Iterable[str]
) -> Callable[[ConfigType], ConfigType]:
def validator(config: ConfigType) -> ConfigType:
domain = config[CONF_ENTITY_ID].split(".", 1)[0]
if domain not in supported_domains:
raise cv.Invalid(
@@ -34,7 +40,7 @@ HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA = cv.Schema(
)
def setup_home_assistant_entity(var, config):
def setup_home_assistant_entity(var: MockObj, config: ConfigType) -> None:
cg.add(var.set_entity_id(config[CONF_ENTITY_ID]))
if CONF_ATTRIBUTE in config:
cg.add(var.set_attribute(config[CONF_ATTRIBUTE]))
@@ -1,5 +1,6 @@
import esphome.codegen as cg
from esphome.components import binary_sensor
from esphome.types import ConfigType
from .. import (
HOME_ASSISTANT_IMPORT_SCHEMA,
@@ -18,7 +19,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(HomeassistantBinarySensor).ex
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = await binary_sensor.new_binary_sensor(config)
await cg.register_component(var, config)
setup_home_assistant_entity(var, config)
@@ -1,6 +1,7 @@
import esphome.codegen as cg
from esphome.components import number
import esphome.config_validation as cv
from esphome.types import ConfigType
from .. import (
HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA,
@@ -22,7 +23,7 @@ CONFIG_SCHEMA = (
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
var = await number.new_number(
config,
@@ -1,5 +1,6 @@
import esphome.codegen as cg
from esphome.components import sensor
from esphome.types import ConfigType
from .. import (
HOME_ASSISTANT_IMPORT_SCHEMA,
@@ -18,7 +19,7 @@ CONFIG_SCHEMA = sensor.sensor_schema(HomeassistantSensor, accuracy_decimals=1).e
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
setup_home_assistant_entity(var, config)
@@ -2,6 +2,7 @@ import esphome.codegen as cg
from esphome.components import switch
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
from .. import (
HOME_ASSISTANT_IMPORT_CONTROL_SCHEMA,
@@ -36,7 +37,7 @@ CONFIG_SCHEMA = cv.All(
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
@@ -1,5 +1,6 @@
import esphome.codegen as cg
from esphome.components import text_sensor
from esphome.types import ConfigType
from .. import (
HOME_ASSISTANT_IMPORT_SCHEMA,
@@ -18,7 +19,7 @@ CONFIG_SCHEMA = text_sensor.text_sensor_schema(HomeassistantTextSensor).extend(
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = await text_sensor.new_text_sensor(config)
await cg.register_component(var, config)
setup_home_assistant_entity(var, config)
@@ -2,6 +2,7 @@ import esphome.codegen as cg
from esphome.components import time as time_
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_TIMEZONE
from esphome.types import ConfigType
from .. import homeassistant_ns
@@ -16,7 +17,7 @@ CONFIG_SCHEMA = time_.TIME_SCHEMA.extend(
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await time_.register_time(var, config)
await cg.register_component(var, config)
+3 -2
View File
@@ -11,6 +11,7 @@ from esphome.const import (
)
from esphome.core import CORE
from esphome.platformio.toolchain import copy_ccache_script
from esphome.types import ConfigType
from .const import KEY_HOST
@@ -22,7 +23,7 @@ AUTO_LOAD = ["network", "preferences"]
IS_TARGET_PLATFORM = True
def set_core_data(config):
def set_core_data(config: ConfigType) -> ConfigType:
CORE.data[KEY_HOST] = {}
CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = PLATFORM_HOST
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = "host"
@@ -40,7 +41,7 @@ CONFIG_SCHEMA = cv.All(
)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
cg.add_build_flag("-DUSE_HOST")
cg.add_define("USE_NATIVE_64BIT_TIME")
# The prefs file finds stored preferences by key, so key migration is possible
+6 -3
View File
@@ -1,4 +1,5 @@
import logging
from typing import Any
from esphome import pins
import esphome.codegen as cg
@@ -14,6 +15,8 @@ from esphome.const import (
CONF_PULLDOWN,
CONF_PULLUP,
)
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
from .const import host_ns
@@ -22,7 +25,7 @@ _LOGGER = logging.getLogger(__name__)
HostGPIOPin = host_ns.class_("HostGPIOPin", cg.InternalGPIOPin)
def _translate_pin(value):
def _translate_pin(value: Any) -> int | str:
if isinstance(value, dict) or value is None:
raise cv.Invalid(
"This variable only supports pin numbers, not full pin schemas "
@@ -41,7 +44,7 @@ def _translate_pin(value):
return value
def validate_gpio_pin(value):
def validate_gpio_pin(value: Any) -> int | str:
return _translate_pin(value)
@@ -53,7 +56,7 @@ HOST_PIN_SCHEMA = pins.gpio_base_schema(
@pins.PIN_SCHEMA_REGISTRY.register("host", HOST_PIN_SCHEMA)
async def host_pin_to_code(config):
async def host_pin_to_code(config: ConfigType) -> MockObj:
var = cg.new_Pvariable(config[CONF_ID])
num = config[CONF_NUMBER]
cg.add(var.set_pin(num))
+2 -1
View File
@@ -2,6 +2,7 @@ import esphome.codegen as cg
from esphome.components import time as time_
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
CODEOWNERS = ["@clydebarrow"]
@@ -14,7 +15,7 @@ CONFIG_SCHEMA = time_.TIME_SCHEMA.extend(
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await time_.register_time(var, config)
@@ -196,6 +196,9 @@ std::shared_ptr<HttpContainer> HttpRequestIDF::perform(const std::string &url, c
}
container->feed_wdt();
// IDF is the only backend reusing the container across redirect hops;
// drop the previous hop's headers (Arduino/host collect only the final response)
container->response_headers_.clear();
container->content_length = esp_http_client_fetch_headers(client);
container->set_chunked(esp_http_client_is_chunked_response(client));
container->feed_wdt();
@@ -64,8 +64,9 @@ void OtaHttpRequestComponent::flash() {
}
}
void OtaHttpRequestComponent::cleanup_(ota::OTABackendPtr backend, const std::shared_ptr<HttpContainer> &container) {
if (this->update_started_) {
void OtaHttpRequestComponent::cleanup_(ota::OTABackendPtr backend, const std::shared_ptr<HttpContainer> &container,
bool abort_backend) {
if (abort_backend) {
ESP_LOGV(TAG, "Aborting OTA backend");
backend->abort();
}
@@ -106,7 +107,8 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
auto error_code = backend->begin(container->content_length);
if (error_code != ota::OTA_RESPONSE_OK) {
ESP_LOGW(TAG, "backend->begin error: %d", error_code);
this->cleanup_(std::move(backend), container);
// Nothing to abort: begin() failed, so no OTA handle was opened
this->cleanup_(std::move(backend), container, /*abort_backend=*/false);
return error_code;
}
@@ -140,7 +142,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
} else {
ESP_LOGE(TAG, "Error reading data: %d", bufsize_or_error);
}
this->cleanup_(std::move(backend), container);
this->cleanup_(std::move(backend), container, /*abort_backend=*/true);
return OTA_CONNECTION_ERROR;
}
@@ -150,14 +152,13 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
md5_receive.add(buf, bufsize_or_error);
// write bytes to OTA backend
this->update_started_ = true;
error_code = backend->write(buf, bufsize_or_error);
if (error_code != ota::OTA_RESPONSE_OK) {
// error code explanation available at
// https://github.com/esphome/esphome/blob/dev/esphome/components/ota/ota_backend.h
ESP_LOGE(TAG, "Error code (%02X) writing binary data to flash at offset %d and size %d", error_code,
container->get_bytes_read() - bufsize_or_error, container->content_length);
this->cleanup_(std::move(backend), container);
this->cleanup_(std::move(backend), container, /*abort_backend=*/true);
return error_code;
}
}
@@ -181,7 +182,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
this->md5_computed_ = md5_receive_str;
if (strncmp(this->md5_computed_.c_str(), this->md5_expected_.c_str(), MD5_SIZE) != 0) {
ESP_LOGE(TAG, "MD5 computed: %s - Aborting due to MD5 mismatch", this->md5_computed_.c_str());
this->cleanup_(std::move(backend), container);
this->cleanup_(std::move(backend), container, /*abort_backend=*/true);
return ota::OTA_RESPONSE_ERROR_MD5_MISMATCH;
} else {
backend->set_update_md5(md5_receive_str);
@@ -197,7 +198,7 @@ uint8_t OtaHttpRequestComponent::do_ota_() {
error_code = backend->end();
if (error_code != ota::OTA_RESPONSE_OK) {
ESP_LOGW(TAG, "Error ending update! error_code: %d", error_code);
this->cleanup_(std::move(backend), container);
this->cleanup_(std::move(backend), container, /*abort_backend=*/true);
return error_code;
}
@@ -38,7 +38,7 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented<
void flash();
protected:
void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr<HttpContainer> &container);
void cleanup_(ota::OTABackendPtr backend, const std::shared_ptr<HttpContainer> &container, bool abort_backend);
uint8_t do_ota_();
std::string get_url_with_auth_(const std::string &url);
bool http_get_md5_();
@@ -51,7 +51,6 @@ class OtaHttpRequestComponent final : public ota::OTAComponent, public Parented<
std::string username_{};
std::string url_{};
int status_ = -1;
bool update_started_ = false;
static const uint16_t HTTP_RECV_BUFFER = 256; // the firmware GET chunk size
};
+17 -15
View File
@@ -1,6 +1,7 @@
import logging
import re
import sys
from typing import Any
from esphome import pins
import esphome.codegen as cg
@@ -52,9 +53,10 @@ from esphome.const import (
PLATFORM_RP2,
PlatformFramework,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.cpp_generator import MockObj
import esphome.final_validate as fv
from esphome.types import ConfigType
LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@esphome/core"]
@@ -96,13 +98,13 @@ CONF_SCL_PULLUP_ENABLED = "scl_pullup_enabled"
MULTI_CONF = True
def validate_device(value):
def validate_device(value: str) -> str:
if not re.match(r"^/(?:[^/]+/)*[^/]+$", value):
raise cv.Invalid("Device must be an absolute device path (e.g., /dev/i2c-0)")
return value
def _bus_declare_type(value):
def _bus_declare_type(value: Any) -> ID:
if CORE.is_esp32:
return cv.declare_id(IDFI2CBus)(value)
if CORE.using_arduino:
@@ -114,7 +116,7 @@ def _bus_declare_type(value):
raise NotImplementedError
def _rp2040_i2c_controller(pin):
def _rp2040_i2c_controller(pin: int) -> int:
"""Return the I2C controller number (0 or 1) for a given RP2040/RP2350 GPIO pin.
See RP2040 datasheet Table 2 (section 1.4.3, "GPIO Functions"):
@@ -125,7 +127,7 @@ def _rp2040_i2c_controller(pin):
return (pin // 2) % 2
def validate_config(config):
def validate_config(config: ConfigType) -> ConfigType:
if CORE.is_esp32:
return cv.require_framework_version(
esp_idf=cv.Version(5, 4, 2), esp32_arduino=cv.Version(3, 2, 1)
@@ -142,7 +144,7 @@ def validate_config(config):
return config
def validate_host_config(config):
def validate_host_config(config: ConfigType) -> ConfigType:
if CORE.is_host:
# Host I2C is currently only supported on Linux
if not sys.platform.lower().startswith("linux"):
@@ -229,7 +231,7 @@ CONFIG_SCHEMA = cv.All(
)
def _final_validate(config):
def _final_validate(config: ConfigType) -> None:
full_config = fv.full_config.get()[CONF_I2C]
if CORE.using_zephyr and len(full_config) > 1:
raise cv.Invalid("Second i2c is not implemented on Zephyr yet")
@@ -281,7 +283,7 @@ FINAL_VALIDATE_SCHEMA = _final_validate
@coroutine_with_priority(CoroPriority.BUS)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
cg.add_global(i2c_ns.using)
cg.add_define("USE_I2C")
if CORE.is_esp32:
@@ -358,7 +360,7 @@ async def to_code(config):
cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE])))
def i2c_device_schema(default_address):
def i2c_device_schema(default_address: int | None) -> cv.Schema:
"""Create a schema for a i2c device.
:param default_address: The default address of the i2c device, can be None to represent
@@ -375,7 +377,7 @@ def i2c_device_schema(default_address):
return cv.Schema(schema)
async def register_i2c_device(var, config):
async def register_i2c_device(var: MockObj, config: ConfigType) -> None:
"""Register an i2c device with the given config.
Sets the i2c bus to use and the i2c address.
@@ -390,11 +392,11 @@ async def register_i2c_device(var, config):
def final_validate_device_schema(
name: str,
*,
min_frequency: cv.frequency = None,
max_frequency: cv.frequency = None,
min_timeout: cv.time_period = None,
max_timeout: cv.time_period = None,
):
min_frequency: Any = None,
max_frequency: Any = None,
min_timeout: Any = None,
max_timeout: Any = None,
) -> cv.Schema:
hub_schema = {}
if (min_frequency is not None) and (max_frequency is not None):
hub_schema[cv.Required(CONF_FREQUENCY)] = cv.Range(
-2
View File
@@ -75,8 +75,6 @@ void Infrared::dump_config() {
YESNO(this->traits_.get_supports_receiver()));
}
InfraredCall Infrared::make_call() { return InfraredCall(this); }
void Infrared::control(const InfraredCall &call) {
if (this->transmitter_ == nullptr) {
ESP_LOGW(TAG, "No transmitter configured");
+1 -1
View File
@@ -134,7 +134,7 @@ class Infrared : public Component, public EntityBase, public remote_base::Remote
const InfraredTraits &get_traits() const { return this->traits_; }
/// Create a call object for transmitting
InfraredCall make_call();
InfraredCall make_call() { return InfraredCall(this); }
/// Get capability flags for this infrared instance
uint32_t get_capability_flags() const;
@@ -13,8 +13,6 @@ ESPColorView ESPRangeView::operator[](int32_t index) const {
index = interpret_index(index, this->size()) + this->begin_;
return (*this->parent_)[index];
}
ESPRangeIterator ESPRangeView::begin() { return {*this, this->begin_}; }
ESPRangeIterator ESPRangeView::end() { return {*this, this->end_}; }
void ESPRangeView::set(const Color &color) {
for (int32_t i = this->begin_; i < this->end_; i++) {
@@ -75,4 +75,7 @@ class ESPRangeIterator {
int32_t i_;
};
inline ESPRangeIterator ESPRangeView::begin() { return {*this, this->begin_}; }
inline ESPRangeIterator ESPRangeView::end() { return {*this, this->end_}; }
} // namespace esphome::light
-18
View File
@@ -157,8 +157,6 @@ void LightState::loop() {
}
}
float LightState::get_setup_priority() const { return setup_priority::HARDWARE - 1.0f; }
void LightState::publish_state() {
if (this->remote_values_listeners_) {
for (auto *listener : *this->remote_values_listeners_) {
@@ -194,25 +192,11 @@ void LightState::add_target_state_reached_listener(LightTargetStateReachedListen
this->target_state_reached_listeners_->push_back(listener);
}
void LightState::set_default_transition_length(uint32_t default_transition_length) {
this->default_transition_length_ = default_transition_length;
}
uint32_t LightState::get_default_transition_length() const { return this->default_transition_length_; }
void LightState::set_flash_transition_length(uint32_t flash_transition_length) {
this->flash_transition_length_ = flash_transition_length;
}
uint32_t LightState::get_flash_transition_length() const { return this->flash_transition_length_; }
void LightState::set_gamma_correct(float gamma_correct) { this->gamma_correct_ = gamma_correct; }
void LightState::set_restore_mode(LightRestoreMode restore_mode) { this->restore_mode_ = restore_mode; }
void LightState::set_initial_state(void (*callback)(LightStateRTCState &)) { this->initial_state_callback_ = callback; }
bool LightState::supports_effects() { return !this->effects_.empty(); }
const FixedVector<LightEffect *> &LightState::get_effects() const { return this->effects_; }
void LightState::add_effects(const std::initializer_list<LightEffect *> &effects) {
// Called once from Python codegen during setup with all effects from YAML config
this->effects_ = effects;
}
void LightState::current_values_as_binary(bool *binary) { this->current_values.as_binary(binary); }
void LightState::current_values_as_brightness(float *brightness) {
this->current_values.as_brightness(brightness);
*brightness = this->gamma_correct_lut(*brightness);
@@ -333,8 +317,6 @@ float LightState::gamma_uncorrect_lut(float value) const {
}
#endif // USE_LIGHT_GAMMA_LUT
bool LightState::is_transformer_active() { return this->is_transformer_active_; }
void LightState::start_effect_(uint32_t effect_index) {
this->stop_effect_();
if (effect_index == 0)
+16 -12
View File
@@ -109,7 +109,7 @@ class LightState : public EntityBase, public Component {
void dump_config() override;
void loop() override;
/// Shortly after HARDWARE.
float get_setup_priority() const override;
float get_setup_priority() const override { return setup_priority::HARDWARE - 1.0f; }
/** The current values of the light as outputted to the light.
*
@@ -157,15 +157,19 @@ class LightState : public EntityBase, public Component {
void add_target_state_reached_listener(LightTargetStateReachedListener *listener);
/// Set the default transition length, i.e. the transition length when no transition is provided.
void set_default_transition_length(uint32_t default_transition_length);
uint32_t get_default_transition_length() const;
void set_default_transition_length(uint32_t default_transition_length) {
this->default_transition_length_ = default_transition_length;
}
uint32_t get_default_transition_length() const { return this->default_transition_length_; }
/// Set the flash transition length
void set_flash_transition_length(uint32_t flash_transition_length);
uint32_t get_flash_transition_length() const;
void set_flash_transition_length(uint32_t flash_transition_length) {
this->flash_transition_length_ = flash_transition_length;
}
uint32_t get_flash_transition_length() const { return this->flash_transition_length_; }
/// Set the gamma correction factor
void set_gamma_correct(float gamma_correct);
void set_gamma_correct(float gamma_correct) { this->gamma_correct_ = gamma_correct; }
float get_gamma_correct() const { return this->gamma_correct_; }
#ifdef USE_LIGHT_GAMMA_LUT
@@ -186,17 +190,17 @@ class LightState : public EntityBase, public Component {
#endif // USE_LIGHT_GAMMA_LUT
/// Set the restore mode of this light
void set_restore_mode(LightRestoreMode restore_mode);
void set_restore_mode(LightRestoreMode restore_mode) { this->restore_mode_ = restore_mode; }
/// Set a callback to populate the initial state defaults during setup.
/// The callback is called once, then cleared. Values live in flash as code.
void set_initial_state(void (*callback)(LightStateRTCState &));
void set_initial_state(void (*callback)(LightStateRTCState &)) { this->initial_state_callback_ = callback; }
/// Return whether the light has any effects that meet the trait requirements.
bool supports_effects();
bool supports_effects() const { return !this->effects_.empty(); }
/// Get all effects for this light state.
const FixedVector<LightEffect *> &get_effects() const;
const FixedVector<LightEffect *> &get_effects() const { return this->effects_; }
/// Add effects for this light state.
void add_effects(const std::initializer_list<LightEffect *> &effects);
@@ -254,7 +258,7 @@ class LightState : public EntityBase, public Component {
}
/// The result of all the current_values_as_* methods have gamma correction applied.
void current_values_as_binary(bool *binary);
void current_values_as_binary(bool *binary) { this->current_values.as_binary(binary); }
void current_values_as_brightness(float *brightness);
@@ -281,7 +285,7 @@ class LightState : public EntityBase, public Component {
* return;
* }
*/
bool is_transformer_active();
bool is_transformer_active() const { return this->is_transformer_active_; }
protected:
friend LightOutput;
+25 -9
View File
@@ -12,13 +12,14 @@ from esphome.const import (
CONF_ON_UNLOCK,
CONF_WEB_SERVER,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
entity_duplicate_validator,
queue_entity_register,
setup_entity,
)
from esphome.cpp_generator import MockObjClass
from esphome.cpp_generator import MockObj, MockObjClass, TemplateArgsType
from esphome.types import ConfigType, SafeExpType
CODEOWNERS = ["@esphome/core"]
IS_PLATFORM_COMPONENT = True
@@ -102,7 +103,7 @@ _CALLBACK_AUTOMATIONS = (
@setup_entity("lock")
async def _setup_lock_core(var, config):
async def _setup_lock_core(var: MockObj, config: ConfigType) -> None:
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
if mqtt_id := config.get(CONF_MQTT_ID):
@@ -113,7 +114,7 @@ async def _setup_lock_core(var, config):
await web_server.add_entity_config(var, web_server_config)
async def register_lock(var, config):
async def register_lock(var: MockObj, config: ConfigType) -> None:
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
queue_entity_register("lock", config)
@@ -121,7 +122,7 @@ async def register_lock(var, config):
await _setup_lock_core(var, config)
async def new_lock(config, *args):
async def new_lock(config: ConfigType, *args: SafeExpType) -> MockObj:
var = cg.new_Pvariable(config[CONF_ID], *args)
await register_lock(var, config)
return var
@@ -143,23 +144,38 @@ LOCK_ACTION_SCHEMA = maybe_simple_id(
@automation.register_action(
"lock.open", OpenAction, LOCK_ACTION_SCHEMA, synchronous=True
)
async def lock_action_to_code(config, action_id, template_arg, args):
async def lock_action_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
paren = await cg.get_variable(config[CONF_ID])
return cg.new_Pvariable(action_id, template_arg, paren)
@automation.register_condition("lock.is_locked", LockCondition, LOCK_ACTION_SCHEMA)
async def lock_is_on_to_code(config, condition_id, template_arg, args):
async def lock_is_on_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
paren = await cg.get_variable(config[CONF_ID])
return cg.new_Pvariable(condition_id, template_arg, paren, True)
@automation.register_condition("lock.is_unlocked", LockCondition, LOCK_ACTION_SCHEMA)
async def lock_is_off_to_code(config, condition_id, template_arg, args):
async def lock_is_off_to_code(
config: ConfigType,
condition_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
paren = await cg.get_variable(config[CONF_ID])
return cg.new_Pvariable(condition_id, template_arg, paren, False)
@coroutine_with_priority(CoroPriority.CORE)
async def to_code(config):
async def to_code(config: ConfigType) -> None:
cg.add_global(lock_ns.using)
-7
View File
@@ -201,17 +201,10 @@ void Logger::process_messages_() {
#endif // USE_ESPHOME_TASK_LOG_BUFFER
}
void Logger::set_baud_rate(uint32_t baud_rate) { this->baud_rate_ = baud_rate; }
#ifdef USE_LOGGER_RUNTIME_TAG_LEVELS
void Logger::set_log_level(const char *tag, uint8_t log_level) { this->log_levels_[tag] = log_level; }
#endif
#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR)
UARTSelection Logger::get_uart() const { return this->uart_; }
#endif
float Logger::get_setup_priority() const { return setup_priority::BUS + 500.0f; }
// Log level strings - packed into flash on ESP8266, indexed by log level (0-7)
PROGMEM_STRING_TABLE(LogLevelStrings, "NONE", "ERROR", "WARN", "INFO", "CONFIG", "DEBUG", "VERBOSE", "VERY_VERBOSE");
+3 -3
View File
@@ -148,7 +148,7 @@ class Logger final : public Component {
void loop() override;
#endif
/// Manually set the baud rate for serial, set to 0 to disable.
void set_baud_rate(uint32_t baud_rate);
void set_baud_rate(uint32_t baud_rate) { this->baud_rate_ = baud_rate; }
uint32_t get_baud_rate() const { return baud_rate_; }
#if defined(USE_ARDUINO) && !defined(USE_ESP32)
Stream *get_hw_serial() const { return hw_serial_; }
@@ -163,7 +163,7 @@ class Logger final : public Component {
#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR)
void set_uart_selection(UARTSelection uart_selection) { uart_ = uart_selection; }
/// Get the UART used by the logger.
UARTSelection get_uart() const;
UARTSelection get_uart() const { return this->uart_; }
#endif
/// Set the default log level for this logger.
@@ -197,7 +197,7 @@ class Logger final : public Component {
void add_level_listener(LoggerLevelListener *listener) { this->level_listeners_.push_back(listener); }
#endif
float get_setup_priority() const override;
float get_setup_priority() const override { return setup_priority::BUS + 500.0f; }
void log_vprintf_(uint8_t level, const char *tag, int line, const char *format, va_list args); // NOLINT
#ifdef USE_STORE_LOG_STR_IN_FLASH
@@ -8,6 +8,7 @@ from esphome.const import (
CONF_ON_STATE,
CONF_TEMPERATURE,
CONF_UPDATE_INTERVAL,
CONF_USE_FAHRENHEIT,
)
from esphome.core import ID, Lambda
from esphome.cpp_generator import LambdaExpression, MockObj
@@ -71,6 +72,7 @@ CONFIG_SCHEMA = (
cv.Optional(
CONF_TELEMETRY_REQUEST_MIN_INTERVAL, default="60s"
): cv.update_interval,
cv.Optional(CONF_USE_FAHRENHEIT, default=False): cv.boolean,
cv.Optional(CONF_VANE): cv.Schema(
{
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
@@ -114,6 +116,7 @@ async def to_code(config: ConfigType) -> None:
config[CONF_TELEMETRY_REQUEST_MIN_INTERVAL]
)
)
cg.add(var.set_use_fahrenheit(config[CONF_USE_FAHRENHEIT]))
if on_state := config.get(CONF_VANE, {}).get(CONF_ON_STATE):
cg.add_global(mitsubishi_ns.using)
for conf in on_state:
@@ -9,7 +9,7 @@
namespace esphome::mitsubishi_cn105 {
template<typename... Ts>
class SetRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
class SetRemoteTemperatureAction final : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
public:
TEMPLATABLE_VALUE(float, temperature)
@@ -17,12 +17,12 @@ class SetRemoteTemperatureAction : public Action<Ts...>, public Parented<Mitsubi
};
template<typename... Ts>
class ClearRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
class ClearRemoteTemperatureAction final : public Action<Ts...>, public Parented<MitsubishiCN105Component> {
public:
void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); }
};
template<typename... Ts> class VaneControlAction : public Action<Ts...> {
template<typename... Ts> class VaneControlAction final : public Action<Ts...> {
public:
using ApplyFn = void (*)(VaneCall &, const std::remove_cvref_t<Ts> &...);
@@ -83,6 +83,7 @@ class MitsubishiCN105 {
return this->is_telemetry_polling_enabled() ? !std::isnan(this->status_.room_temperature)
: !std::isnan(this->status_.target_temperature);
}
bool is_temperature_encoding_b() const { return this->property_context_.use_temperature_encoding_b; }
void set_power(bool power_on);
void set_target_temperature(float target_temperature);
@@ -50,7 +50,11 @@ static constexpr std::optional<Left> reverse_map_lookup(const std::array<std::pa
return key.has_value() ? reverse_map_lookup(map, *key) : std::nullopt;
}
void MitsubishiCN105Climate::dump_config() { LOG_CLIMATE("", "Mitsubishi CN105 Climate", this); }
void MitsubishiCN105Climate::dump_config() {
LOG_CLIMATE("", "Mitsubishi CN105 Climate", this);
ESP_LOGCONFIG(TAG, " Temperature unit: °%c",
this->parent_->get_temperature_mapping().get_use_fahrenheit() ? 'F' : 'C');
}
void MitsubishiCN105Climate::setup() {
this->parent_->add_on_status_callback([this]() { this->apply_values_(); });
@@ -70,15 +74,17 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() {
traits.add_supported_fan_mode(p.second);
}
traits.set_supported_swing_modes(this->supported_swing_modes_);
traits.set_supported_swing_modes(this->swing_mode_manager_.supported_swing_modes());
traits.set_visual_min_temperature(16.0f);
traits.set_visual_max_temperature(31.0f);
const bool use_fahrenheit = this->parent_->get_temperature_mapping().get_use_fahrenheit();
traits.set_temperature_unit(use_fahrenheit ? TemperatureUnit::FAHRENHEIT : TemperatureUnit::CELSIUS);
traits.set_visual_min_temperature(use_fahrenheit ? 61.0f : 16.0f);
traits.set_visual_max_temperature(use_fahrenheit ? 88.0f : 31.0f);
traits.set_visual_temperature_step(1.0f);
if (this->parent_->is_telemetry_polling_enabled()) {
traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE);
traits.set_visual_current_temperature_step(0.5f);
traits.set_visual_current_temperature_step(use_fahrenheit ? 1.0f : 0.5f);
}
return traits;
@@ -86,7 +92,7 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() {
void MitsubishiCN105Climate::control(const climate::ClimateCall &call) {
if (const auto target_temperature = call.get_target_temperature()) {
this->parent_->set_target_temperature(*target_temperature);
this->parent_->set_target_temperature(this->parent_->get_temperature_mapping().to_mitsubishi(*target_temperature));
}
if (const auto mode = call.get_mode()) {
@@ -103,33 +109,11 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) {
}
if (const auto swing_mode = call.get_swing_mode()) {
auto vane = this->last_non_swing_vane_mode_;
auto wide = this->last_non_swing_wide_vane_mode_;
switch (*swing_mode) {
case climate::CLIMATE_SWING_BOTH:
vane = MitsubishiCN105::VaneMode::SWING;
wide = MitsubishiCN105::WideVaneMode::SWING;
break;
case climate::CLIMATE_SWING_VERTICAL:
vane = MitsubishiCN105::VaneMode::SWING;
break;
case climate::CLIMATE_SWING_HORIZONTAL:
wide = MitsubishiCN105::WideVaneMode::SWING;
break;
case climate::CLIMATE_SWING_OFF:
default:
break;
if (const auto vane = this->swing_mode_manager_.vane_from(*swing_mode)) {
this->parent_->set_vane_mode(*vane);
}
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) {
this->parent_->set_vane_mode(vane);
}
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) {
this->parent_->set_wide_vane_mode(wide);
if (const auto wide = this->swing_mode_manager_.wide_vane_from(*swing_mode)) {
this->parent_->set_wide_vane_mode(*wide);
}
}
@@ -139,10 +123,10 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) {
void MitsubishiCN105Climate::apply_values_() {
const auto &status = this->parent_->status();
this->target_temperature = status.target_temperature;
this->target_temperature = this->parent_->get_temperature_mapping().from_mitsubishi(status.target_temperature);
if (this->parent_->is_telemetry_polling_enabled()) {
this->current_temperature = status.room_temperature;
this->current_temperature = this->parent_->get_temperature_mapping().from_mitsubishi(status.room_temperature);
}
if (status.power_on) {
@@ -160,64 +144,39 @@ void MitsubishiCN105Climate::apply_values_() {
ESP_LOGD(TAG, "Unable to map fan mode");
}
if (!this->supported_swing_modes_.empty()) {
bool vertical_swinging = false;
bool horizontal_swinging = false;
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) {
if (status.vane_mode == MitsubishiCN105::VaneMode::SWING) {
vertical_swinging = true;
} else if (status.vane_mode != MitsubishiCN105::VaneMode::UNKNOWN) {
this->last_non_swing_vane_mode_ = status.vane_mode;
}
}
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) {
if (status.wide_vane_mode == MitsubishiCN105::WideVaneMode::SWING) {
horizontal_swinging = true;
} else if (status.wide_vane_mode != MitsubishiCN105::WideVaneMode::UNKNOWN) {
this->last_non_swing_wide_vane_mode_ = status.wide_vane_mode;
}
}
if (vertical_swinging && horizontal_swinging) {
this->swing_mode = climate::CLIMATE_SWING_BOTH;
} else if (vertical_swinging) {
this->swing_mode = climate::CLIMATE_SWING_VERTICAL;
} else if (horizontal_swinging) {
this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL;
} else {
this->swing_mode = climate::CLIMATE_SWING_OFF;
}
if (const auto swing_mode =
this->swing_mode_manager_.update_and_get_swing_mode(status.vane_mode, status.wide_vane_mode)) {
this->swing_mode = *swing_mode;
}
this->publish_state();
}
void MitsubishiCN105Climate::set_supported_swing_mode(climate::ClimateSwingMode mode) {
this->supported_swing_modes_.clear();
climate::ClimateSwingModeMask supported_swing_modes;
switch (mode) {
case climate::CLIMATE_SWING_VERTICAL:
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF);
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_VERTICAL);
supported_swing_modes.insert(climate::CLIMATE_SWING_OFF);
supported_swing_modes.insert(climate::CLIMATE_SWING_VERTICAL);
break;
case climate::CLIMATE_SWING_HORIZONTAL:
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF);
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_HORIZONTAL);
supported_swing_modes.insert(climate::CLIMATE_SWING_OFF);
supported_swing_modes.insert(climate::CLIMATE_SWING_HORIZONTAL);
break;
case climate::CLIMATE_SWING_BOTH:
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF);
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_VERTICAL);
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_HORIZONTAL);
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_BOTH);
supported_swing_modes.insert(climate::CLIMATE_SWING_OFF);
supported_swing_modes.insert(climate::CLIMATE_SWING_VERTICAL);
supported_swing_modes.insert(climate::CLIMATE_SWING_HORIZONTAL);
supported_swing_modes.insert(climate::CLIMATE_SWING_BOTH);
break;
case climate::CLIMATE_SWING_OFF:
default:
break;
}
this->swing_mode_manager_.set_supported_swing_modes(supported_swing_modes);
}
} // namespace esphome::mitsubishi_cn105
@@ -6,10 +6,13 @@
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/components/climate/climate.h"
#include "mitsubishi_cn105_swing_mode_manager.h"
namespace esphome::mitsubishi_cn105 {
class MitsubishiCN105Climate : public climate::Climate, public Component, public Parented<MitsubishiCN105Component> {
class MitsubishiCN105Climate final : public climate::Climate,
public Component,
public Parented<MitsubishiCN105Component> {
public:
void setup() override;
void dump_config() override;
@@ -25,14 +28,12 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public
protected:
void apply_values_();
climate::ClimateSwingModeMask supported_swing_modes_{};
MitsubishiCN105::VaneMode last_non_swing_vane_mode_{MitsubishiCN105::VaneMode::AUTO};
MitsubishiCN105::WideVaneMode last_non_swing_wide_vane_mode_{MitsubishiCN105::WideVaneMode::CENTER};
SwingModeManager swing_mode_manager_;
};
// Legacy climate action compatibility. Remove in 2027.2.0.
template<typename... Ts>
class LegacySetRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
class LegacySetRemoteTemperatureAction final : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
public:
TEMPLATABLE_VALUE(float, temperature)
@@ -41,7 +42,7 @@ class LegacySetRemoteTemperatureAction : public Action<Ts...>, public Parented<M
// Legacy climate action compatibility. Remove in 2027.2.0.
template<typename... Ts>
class LegacyClearRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
class LegacyClearRemoteTemperatureAction final : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
public:
void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); }
};

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