Merge remote-tracking branch 'upstream/dev' into integration

This commit is contained in:
J. Nick Koston
2026-03-13 09:07:55 -10:00
9 changed files with 192 additions and 12 deletions
+1
View File
@@ -3,6 +3,7 @@
CODEOWNERS = ["@esphome/core"]
CONF_BYTE_ORDER = "byte_order"
CONF_CLIMATE_ID = "climate_id"
BYTE_ORDER_LITTLE = "little_endian"
BYTE_ORDER_BIG = "big_endian"
+1 -1
View File
@@ -1,5 +1,6 @@
import esphome.codegen as cg
from esphome.components import sensor
from esphome.components.const import CONF_CLIMATE_ID
import esphome.config_validation as cv
from esphome.const import CONF_TYPE, ICON_GAUGE, STATE_CLASS_MEASUREMENT, UNIT_PERCENT
@@ -21,7 +22,6 @@ PID_CLIMATE_SENSOR_TYPES = {
"KD": PIDClimateSensorType.PID_SENSOR_TYPE_KD,
}
CONF_CLIMATE_ID = "climate_id"
CONFIG_SCHEMA = (
sensor.sensor_schema(
PIDClimateSensor,
@@ -26,6 +26,10 @@ class BmpDecoder : public ImageDecoder {
int HOT decode(uint8_t *buffer, size_t size) override;
bool is_finished() const override {
if (this->bits_per_pixel_ == 0) {
// header not yet received, so dimensions not yet determined
return false;
}
// BMP is finished when we've decoded all pixel data
return this->paint_index_ >= static_cast<size_t>(this->width_ * this->height_);
}
+1 -1
View File
@@ -6,7 +6,7 @@
#include <type_traits>
#ifdef USE_ESP32
#if (ESP_IDF_VERSION_MAJOR >= 5 && ESP_IDF_VERSION_MINOR >= 1)
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0)
#include <esp_eap_client.h>
#else
#include <esp_wpa2.h>
+1 -1
View File
@@ -18,7 +18,7 @@
#endif
#if defined(USE_ESP32) && defined(USE_WIFI_WPA2_EAP)
#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1)
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0)
#include <esp_eap_client.h>
#else
#include <esp_wpa2.h>
@@ -17,7 +17,7 @@
#include <memory>
#include <utility>
#ifdef USE_WIFI_WPA2_EAP
#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1)
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0)
#include <esp_eap_client.h>
#else
#include <esp_wpa2.h>
@@ -75,7 +75,11 @@ struct IDFWiFiEvent {
#if USE_NETWORK_IPV6
ip_event_got_ip6_t ip_got_ip6;
#endif /* USE_NETWORK_IPV6 */
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
ip_event_assigned_ip_to_client_t ip_assigned_ip_to_client;
#else
ip_event_ap_staipassigned_t ip_ap_staipassigned;
#endif
} data;
};
@@ -116,8 +120,13 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi
memcpy(&event.data.ap_staconnected, event_data, sizeof(wifi_event_ap_staconnected_t));
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_AP_STADISCONNECTED) {
memcpy(&event.data.ap_stadisconnected, event_data, sizeof(wifi_event_ap_stadisconnected_t));
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
} else if (event_base == IP_EVENT && event_id == IP_EVENT_ASSIGNED_IP_TO_CLIENT) {
memcpy(&event.data.ip_assigned_ip_to_client, event_data, sizeof(ip_event_assigned_ip_to_client_t));
#else
} else if (event_base == IP_EVENT && event_id == IP_EVENT_AP_STAIPASSIGNED) {
memcpy(&event.data.ip_ap_staipassigned, event_data, sizeof(ip_event_ap_staipassigned_t));
#endif
} else {
// did not match any event, don't send anything
return;
@@ -407,7 +416,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
if (eap_opt.has_value()) {
// note: all certificates and keys have to be null terminated. Lengths are appended by +1 to include \0.
EAPAuth eap = *eap_opt;
#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1)
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0)
err = esp_eap_client_set_identity((uint8_t *) eap.identity.c_str(), eap.identity.length());
#else
err = esp_wifi_sta_wpa2_ent_set_identity((uint8_t *) eap.identity.c_str(), eap.identity.length());
@@ -419,7 +428,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
int client_cert_len = strlen(eap.client_cert);
int client_key_len = strlen(eap.client_key);
if (ca_cert_len) {
#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1)
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0)
err = esp_eap_client_set_ca_cert((uint8_t *) eap.ca_cert, ca_cert_len + 1);
#else
err = esp_wifi_sta_wpa2_ent_set_ca_cert((uint8_t *) eap.ca_cert, ca_cert_len + 1);
@@ -432,7 +441,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
// validation is not required as the config tool has already validated it
if (client_cert_len && client_key_len) {
// if we have certs, this must be EAP-TLS
#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1)
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0)
err = esp_eap_client_set_certificate_and_key((uint8_t *) eap.client_cert, client_cert_len + 1,
(uint8_t *) eap.client_key, client_key_len + 1,
(uint8_t *) eap.password.c_str(), eap.password.length());
@@ -446,7 +455,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
}
} else {
// in the absence of certs, assume this is username/password based
#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1)
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0)
err = esp_eap_client_set_username((uint8_t *) eap.username.c_str(), eap.username.length());
#else
err = esp_wifi_sta_wpa2_ent_set_username((uint8_t *) eap.username.c_str(), eap.username.length());
@@ -454,7 +463,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
if (err != ESP_OK) {
ESP_LOGV(TAG, "set_username failed %d", err);
}
#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1)
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0)
err = esp_eap_client_set_password((uint8_t *) eap.password.c_str(), eap.password.length());
#else
err = esp_wifi_sta_wpa2_ent_set_password((uint8_t *) eap.password.c_str(), eap.password.length());
@@ -463,7 +472,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
ESP_LOGV(TAG, "set_password failed %d", err);
}
// set TTLS Phase 2, defaults to MSCHAPV2
#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1)
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0)
err = esp_eap_client_set_ttls_phase2_method(eap.ttls_phase_2);
#else
err = esp_wifi_sta_wpa2_ent_set_ttls_phase2_method(eap.ttls_phase_2);
@@ -472,7 +481,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) {
ESP_LOGV(TAG, "set_ttls_phase2_method failed %d", err);
}
}
#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 1)
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0)
err = esp_wifi_sta_enterprise_enable();
#else
err = esp_wifi_sta_wpa2_ent_enable();
@@ -628,14 +637,26 @@ const char *get_disconnect_reason_str(uint8_t reason) {
return "Auth Expired";
case WIFI_REASON_AUTH_LEAVE:
return "Auth Leave";
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
case WIFI_REASON_DISASSOC_DUE_TO_INACTIVITY:
return "Disassociated Due to Inactivity";
#else
case WIFI_REASON_ASSOC_EXPIRE:
return "Association Expired";
#endif
case WIFI_REASON_ASSOC_TOOMANY:
return "Too Many Associations";
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
case WIFI_REASON_CLASS2_FRAME_FROM_NONAUTH_STA:
return "Class 2 Frame from Non-Authenticated STA";
case WIFI_REASON_CLASS3_FRAME_FROM_NONASSOC_STA:
return "Class 3 Frame from Non-Associated STA";
#else
case WIFI_REASON_NOT_AUTHED:
return "Not Authenticated";
case WIFI_REASON_NOT_ASSOCED:
return "Not Associated";
#endif
case WIFI_REASON_ASSOC_LEAVE:
return "Association Leave";
case WIFI_REASON_ASSOC_NOT_AUTHED:
@@ -688,7 +709,7 @@ const char *get_disconnect_reason_str(uint8_t reason) {
return "Association comeback time too long";
case WIFI_REASON_SA_QUERY_TIMEOUT:
return "SA query timeout";
#if (ESP_IDF_VERSION_MAJOR >= 5) && (ESP_IDF_VERSION_MINOR >= 2)
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 2, 0)
case WIFI_REASON_NO_AP_FOUND_W_COMPATIBLE_SECURITY:
return "No AP found with compatible security";
case WIFI_REASON_NO_AP_FOUND_IN_AUTHMODE_THRESHOLD:
@@ -917,8 +938,13 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) {
ESP_LOGV(TAG, "AP client disconnected MAC=%s", mac_buf);
#endif
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
} else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_ASSIGNED_IP_TO_CLIENT) {
const auto &it = data->data.ip_assigned_ip_to_client;
#else
} else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_AP_STAIPASSIGNED) {
const auto &it = data->data.ip_ap_staipassigned;
#endif
ESP_LOGV(TAG, "AP client assigned IP " IPSTR, IP2STR(&it.ip));
}
}
+3
View File
@@ -589,7 +589,10 @@ async def _add_looping_components() -> None:
async def to_code(config: ConfigType) -> None:
cg.add_global(cg.global_ns.namespace("esphome").using)
# These can be used by user lambdas, put them to default scope
# picolibc (IDF 6.0+) declares isnan in global scope, conflicting with using std::isnan
cg.add_global(cg.RawStatement("#ifndef __PICOLIBC__"))
cg.add_global(cg.RawExpression("using std::isnan"))
cg.add_global(cg.RawStatement("#endif"))
cg.add_global(cg.RawExpression("using std::min"))
cg.add_global(cg.RawExpression("using std::max"))
@@ -0,0 +1,27 @@
esphome:
name: online-image-bmp
host:
http_request:
display:
online_image:
- url: http://127.0.0.1:HTTP_PORT/foo.bmp
id: myimg
format: BMP
type: RGB
on_download_finished:
logger.log:
format: "download finished. cache hit: %u"
args: [cached]
api:
actions:
- action: fetch_image
then:
- component.update: myimg
logger:
level: DEBUG
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
# black 8x8 RGB BMP, generated with
# from PIL import Image
# from io import BytesIO
# b = BytesIO()
# img = Image.new("RGB", (8, 8))
# img.save(b, format="BMP")
# b.getvalue()
BMP_IMAGE = b"BM\xf6\x00\x00\x00\x00\x00\x00\x006\x00\x00\x00(\x00\x00\x00\x08\x00\x00\x00\x08\x00\x00\x00\x01\x00\x18\x00\x00\x00\x00\x00\xc0\x00\x00\x00\xc4\x0e\x00\x00\xc4\x0e\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
LEN_BMP_IMAGE = len(BMP_IMAGE)
def handle_http(http_request_future):
async def handler(reader, writer):
try:
async with asyncio.timeout(1.0):
data = await reader.readuntil(b"\r\n")
# ensure our request matches the expectation
expected_request = b"GET /foo.bmp HTTP/1.1\r\n"
assert data[: len(expected_request)] == expected_request
# consume rest of request
async with asyncio.timeout(1.0):
data = await reader.readuntil(b"\r\n\r\n")
http_request_future.set_result(True)
http_response = [
b"HTTP/1.1 200 OK",
b"Content-Length: %d" % LEN_BMP_IMAGE,
b"Content-Type: text/plain",
b"Connection: close",
b"",
b"",
]
writer.write(b"\r\n".join(http_response))
await writer.drain()
writer.write(BMP_IMAGE)
await writer.drain()
except Exception as exc:
if not http_request_future.done():
http_request_future.set_exception(exc)
raise
finally:
writer.close()
return handler
@pytest.mark.asyncio
async def test_online_image_bmp(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Esphome shouldn't block the main loop when a http response is slow"""
loop = asyncio.get_running_loop()
# Track http request
http_request_future = loop.create_future()
download_finished_future = loop.create_future()
downloaded_bytes_future = loop.create_future()
def check_output(line: str) -> None:
"""Check log output for expected messages."""
if match := re.search(r"Image fully downloaded, (\d+) bytes", line):
downloaded_bytes_future.set_result(int(match.group(1)))
if "download finished" in line:
download_finished_future.set_result(True)
server = await asyncio.start_server(
handle_http(http_request_future), "127.0.0.1", 0
)
http_server_port = server.sockets[0].getsockname()[1]
config = yaml_config.replace("HTTP_PORT", str(http_server_port))
# Run with log monitoring
async with (
server,
run_compiled(config, line_callback=check_output),
api_client_connected() as client,
):
# Verify device info
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "online-image-bmp"
# List services to find our test service
_, services = await client.list_entities_services()
# Find test service
request_service = next((s for s in services if s.name == "fetch_image"), None)
assert request_service is not None, "fetch_image service not found"
await client.execute_service(request_service, {})
async with asyncio.timeout(0.1):
await http_request_future
async with asyncio.timeout(0.5):
numbytes = await downloaded_bytes_future
assert numbytes == LEN_BMP_IMAGE
await download_finished_future