Merge branch 'esp8266-native-converter-hardening' into esp8266-native-library-converter

This commit is contained in:
J. Nick Koston
2026-08-23 21:10:46 -05:00
552 changed files with 2425 additions and 1937 deletions
@@ -3,10 +3,15 @@
from __future__ import annotations
from collections.abc import Callable
import logging
from pathlib import Path
import pytest
from esphome.core import CORE
INTERRUPT_DEFINE = "USE_GPIO_BINARY_SENSOR_INTERRUPT"
def test_gpio_binary_sensor_basic_setup(
generate_main: Callable[[str | Path], str],
@@ -69,3 +74,62 @@ def test_gpio_binary_sensor_explicit_polling_mode(
)
assert "bs_polling->set_use_interrupt(false);" in main_cpp
def test_gpio_binary_sensor_interrupt_emits_define(
generate_main: Callable[[str | Path], str],
) -> None:
"""
An interrupt-mode sensor must emit the define that compiles the ISR code,
since the platform ISR pin implementation is only built when needed
"""
generate_main("tests/component_tests/gpio/test_gpio_binary_sensor.yaml")
assert INTERRUPT_DEFINE in {d.name for d in CORE.defines}
def test_gpio_binary_sensor_polling_omits_define(
generate_main: Callable[[str | Path], str],
) -> None:
"""
A polling-only config must not emit the interrupt define, so the ISR code
(and its reference to ISRInternalGPIOPin) is compiled out
"""
generate_main("tests/component_tests/gpio/test_gpio_binary_sensor_polling.yaml")
assert INTERRUPT_DEFINE not in {d.name for d in CORE.defines}
def test_gpio_binary_sensor_mixed_modes_emit_define(
generate_main: Callable[[str | Path], str],
) -> None:
"""
With one interrupt and one polling sensor, the define is emitted and the
polling instance still opts out via its setter
"""
main_cpp = generate_main(
"tests/component_tests/gpio/test_gpio_binary_sensor_mixed.yaml"
)
assert INTERRUPT_DEFINE in {d.name for d in CORE.defines}
assert "bs_polling->set_use_interrupt(false);" in main_cpp
assert "bs_interrupt->set_use_interrupt" not in main_cpp
def test_gpio_binary_sensor_expander_pin_omits_define(
generate_main: Callable[[str | Path], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""
An expander pin can't use interrupts: final validation falls back to
polling and the interrupt define must not be emitted. This is the config
that fails to link if the ISR code is compiled without an internal pin
"""
with caplog.at_level(logging.INFO):
main_cpp = generate_main(
"tests/component_tests/gpio/test_gpio_binary_sensor_expander.yaml"
)
assert "bs_expander->set_use_interrupt(false);" in main_cpp
assert INTERRUPT_DEFINE not in {d.name for d in CORE.defines}
assert "falling back to polling mode" in caplog.text
@@ -0,0 +1,21 @@
esphome:
name: test
esp32:
board: esp32dev
i2c:
scl: 16
sda: 17
ch422g:
- id: ch422g_hub
binary_sensor:
- platform: gpio
name: "Expander Sensor"
id: bs_expander
pin:
ch422g: ch422g_hub
number: 1
mode: INPUT
@@ -0,0 +1,17 @@
esphome:
name: test
esp32:
board: esp32dev
binary_sensor:
- platform: gpio
pin: 5
name: "Interrupt Sensor"
id: bs_interrupt
- platform: gpio
pin: 4
name: "Polling Sensor"
id: bs_polling
use_interrupt: false
@@ -0,0 +1,47 @@
"""Schema-level config validation for custom_pdu and the deprecated custom_command alias.
custom_command took a raw frame with a leading device address byte; custom_pdu takes the PDU only.
The old key is still accepted at the schema level and auto-migrated later in final validate (which a
bare-schema test can't reach), so these tests only cover what the schema itself enforces: the two keys
are mutually exclusive, and custom_pdu takes byte-sized values.
"""
import pytest
from voluptuous import Invalid, MultipleInvalid
from esphome.components.modbus_controller import ModbusItemBaseSchema
from esphome.components.modbus_controller.const import (
CONF_CUSTOM_COMMAND,
CONF_CUSTOM_PDU,
)
def test_custom_command_accepted_at_schema_level() -> None:
"""custom_command validates at the schema level; migration/rejection happens in final validate."""
config = ModbusItemBaseSchema(
{CONF_CUSTOM_COMMAND: [0x01, 0x03, 0x00, 0x2A, 0x00, 0x01]}
)
assert config[CONF_CUSTOM_COMMAND] == [0x01, 0x03, 0x00, 0x2A, 0x00, 0x01]
def test_custom_pdu_and_custom_command_mutually_exclusive() -> None:
"""Only one custom source may be given; supplying both is a schema error."""
with pytest.raises((Invalid, MultipleInvalid)):
ModbusItemBaseSchema(
{
CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01],
CONF_CUSTOM_COMMAND: [0x01, 0x03, 0x00, 0x2A, 0x00, 0x01],
}
)
def test_custom_pdu_accepted() -> None:
"""The new key takes PDU bytes (function code + data, no address byte)."""
config = ModbusItemBaseSchema({CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01]})
assert config[CONF_CUSTOM_PDU] == [0x03, 0x00, 0x2A, 0x00, 0x01]
def test_custom_pdu_rejects_non_byte_values() -> None:
"""PDU entries are bytes; a word-sized value is a sign the old raw format is being used."""
with pytest.raises((Invalid, MultipleInvalid)):
ModbusItemBaseSchema({CONF_CUSTOM_PDU: [0x0103, 0x002A]})
@@ -0,0 +1,14 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
network:
tcp_send_buffer: 32kB
@@ -0,0 +1,15 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
network:
enable_high_performance: true
tcp_send_buffer: 16384
@@ -0,0 +1,85 @@
"""Tests for the ``network: tcp_send_buffer:`` option.
The option sets lwIP's per-socket TCP send buffer
(CONFIG_LWIP_TCP_SND_BUF_DEFAULT) on ESP-IDF. The stock default (5744 bytes)
stalls bursty senders such as a Bluetooth proxy streaming GATT notifications;
until now the only way to raise it was the all-or-nothing
``enable_high_performance`` bundle.
"""
from collections.abc import Callable
from pathlib import Path
import pytest
from voluptuous import Invalid
from esphome import config_validation as cv
from esphome.components.esp32.const import (
KEY_SDKCONFIG_OPTIONS,
KEY_VARIANT,
VARIANT_ESP32,
)
from esphome.components.network import (
CONF_TCP_SEND_BUFFER,
CONFIG_SCHEMA,
TCP_SEND_BUFFER_MAX,
TCP_SEND_BUFFER_MIN,
)
from esphome.const import KEY_ESP32, KEY_FRAMEWORK_VERSION, PlatformFramework
from esphome.core import CORE
from tests.component_tests.types import SetCoreConfigCallable
def _sdkconfig_option(name: str) -> int | None:
return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get(name)
def test_tcp_send_buffer_sets_sdkconfig(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
generate_main(component_config_path("tcp_send_buffer.yaml"))
assert _sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT") == 32000
def test_tcp_send_buffer_overrides_high_performance(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""An explicit size wins over the high performance bundle's 65534."""
generate_main(component_config_path("tcp_send_buffer_high_perf.yaml"))
assert _sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT") == 16384
@pytest.mark.parametrize("value", [TCP_SEND_BUFFER_MIN, TCP_SEND_BUFFER_MAX])
def test_boundary_values_accepted(
set_core_config: SetCoreConfigCallable, value: int
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
core_data={KEY_FRAMEWORK_VERSION: cv.Version(5, 5, 5)},
platform_data={KEY_VARIANT: VARIANT_ESP32},
)
assert CONFIG_SCHEMA({"tcp_send_buffer": value})[CONF_TCP_SEND_BUFFER] == value
@pytest.mark.parametrize("value", ["1kB", "128kB"])
def test_out_of_range_rejected(
set_core_config: SetCoreConfigCallable, value: str
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
core_data={KEY_FRAMEWORK_VERSION: cv.Version(5, 5, 5)},
platform_data={KEY_VARIANT: VARIANT_ESP32},
)
with pytest.raises(Invalid):
CONFIG_SCHEMA({"tcp_send_buffer": value})
def test_rejected_on_esp8266(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(
PlatformFramework.ESP8266_ARDUINO,
core_data={KEY_FRAMEWORK_VERSION: cv.Version(3, 1, 2)},
)
with pytest.raises(Invalid, match="esp32"):
CONFIG_SCHEMA({"tcp_send_buffer": "32kB"})
@@ -1,10 +0,0 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The scan list helper is header-only and needs none of the component's real
# dependencies. Pulling them in breaks the host build: web_server_base
# includes ESPAsyncWebServer.h and ota.web_server includes md5/md5.h, neither
# of which exists there.
manifest.dependencies = []
manifest.auto_load = []
@@ -108,6 +108,22 @@ select:
return value;
sensor:
# custom_pdu polls a ready-made PDU (function code + data - no device address byte, no CRC); covers
# the set_custom_pdu codegen path and the custom-range polling constructor.
- platform: modbus_controller
modbus_controller_id: modbus_controller1
id: modbus_sensor_custom_pdu
name: Test Custom PDU Sensor
custom_pdu: [0x03, 0x00, 0x2A, 0x00, 0x01]
value_type: U_WORD
# Deprecated custom_command (leading byte 0x02 == modbus_controller1's address) drives the
# migrate_custom_command final-validate auto-migration path in CI.
- platform: modbus_controller
modbus_controller_id: modbus_controller1
id: modbus_sensor_custom_command
name: Test Custom Command Sensor
custom_command: [0x02, 0x03, 0x00, 0x2B, 0x00, 0x01]
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller1
id: modbus_sensor1
@@ -2,3 +2,4 @@
network:
enable_high_performance: true
tcp_send_buffer: 32kB
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
network:
api:
time:
- platform: homeassistant
# Angle-bracket name pins the explicit-timezone host codegen path
# (setenv/tzset plus pre-parsed struct emission) with characters that
# would break unescaped string interpolation.
timezone: "<+07>-7"
@@ -4,13 +4,13 @@
#include <string>
#include <vector>
#include "esphome/components/captive_portal/scan_list.h"
#include "esphome/components/wifi/scan_list.h"
namespace esphome::captive_portal::testing {
namespace esphome::wifi::testing {
namespace {
// Stand-in for wifi::WiFiScanResult, which does not compile on the host.
// Stand-in for WiFiScanResult, which does not compile on the host.
struct Entry {
std::string ssid;
int8_t rssi;
@@ -24,7 +24,7 @@ struct Entry {
bool get_is_hidden() const { return this->is_hidden; }
};
// One row as the portal would emit it.
// One network as a consumer would emit it.
struct Row {
std::string ssid;
int8_t rssi;
@@ -33,7 +33,7 @@ struct Row {
bool operator==(const Row &rhs) const { return ssid == rhs.ssid && rssi == rhs.rssi && lock == rhs.lock; }
};
// Walk the results the way handle_config does and collect the rows that survive.
// Walk the results the way the consumers do and collect the rows that survive.
std::vector<Row> rows(const std::vector<Entry> &results) {
std::vector<Row> out;
for (size_t i = 0; i < results.size(); i++) {
@@ -127,4 +127,4 @@ TEST(ScanList, EmptyListShowsNothing) {
EXPECT_TRUE(rows(results).empty());
}
} // namespace esphome::captive_portal::testing
} // namespace esphome::wifi::testing
@@ -0,0 +1,20 @@
esphome:
name: get-time-tz-test
host:
api:
logger:
time:
- platform: homeassistant
id: ha_time
sensor:
# Exposes the standard offset of the effective timezone so the test can
# observe which GetTimeResponse messages changed it
- platform: template
name: "TZ Offset"
id: tz_offset
accuracy_decimals: 0
update_interval: 100ms
lambda: |-
return time::get_global_tz().std_offset_seconds;
@@ -1,5 +1,5 @@
esphome:
name: uart-mock-modbus-custom-command
name: uart-mock-modbus-custom-pdu
host:
api:
@@ -69,13 +69,14 @@ sensor:
address: 0x01
register_type: holding
value_type: U_WORD
# Custom command: a raw frame {device address, function code, address hi, address lo,
# count hi, count lo}; the CRC is appended by the hub. Reads holding register 0x0001,
# count 1; the lambda parses the response payload (the register value, big-endian).
# Custom PDU: read holding register 0x0001, count 1. The PDU is
# {function code, address hi, address lo, count hi, count lo}; the device
# address and CRC are added by the hub. The lambda parses the response payload
# (the register value, big-endian).
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "custom_read"
custom_command: [0x01, 0x03, 0x00, 0x01, 0x00, 0x01]
custom_pdu: [0x03, 0x00, 0x01, 0x00, 0x01]
lambda: |-
if (data.size() < 2) return {};
return (float) ((data[0] << 8) | data[1]);
@@ -134,7 +134,8 @@ sensor:
value_type: U_WORD
modbus_controller_id: modbus_controller_ok
# F - contiguous registers where the second asks for a slower rate.
# F - contiguous registers that historically carried differing skip_updates rates and were split by
# the rate merge; with per-range rates gone they group like any contiguous pair.
- platform: modbus_controller
name: "rate_first"
address: 0x150
@@ -146,7 +147,6 @@ sensor:
address: 0x151
register_type: holding
value_type: U_WORD
skip_updates: 5
modbus_controller_id: modbus_controller_ok
# B - a wide value and one of its halves share a start address, with a contiguous sensor after them.
@@ -51,9 +51,8 @@ modbus_controller:
modbus_id: virtual_modbus_client
id: ctl
max_cmd_retries: 1
# offline_skip_updates and the sensor's skip_updates deliberately share a period: offline
# probing must follow the offline cadence alone, or phase combinations like this one can
# leave the device never probing again.
# offline_skip_updates: 1 -> once offline, the controller re-probes every second update cycle;
# the test silences the device to force it offline, then answers again and checks it recovers.
offline_skip_updates: 1
update_interval: never
on_offline:
@@ -71,7 +70,6 @@ sensor:
address: 0x03
register_type: holding
value_type: U_WORD
skip_updates: 1
# Mirrors the controller's online state so the test can await the transitions.
- platform: template
name: link_state
@@ -49,14 +49,10 @@ uart_mock:
inject_rx: [0x01, 0x03, 0x02, 0x01, 0x41, 0x79, 0xE4] # 0x101 = 0x0141 = 321
- expect_tx: [0x01, 0x03, 0x01, 0x03, 0x00, 0x01, 0x75, 0xF6] # Read holding 0x103 count 1
inject_rx: [0x01, 0x03, 0x02, 0x01, 0xA5, 0x79, 0xAF] # 0x103 = 0x01A5 = 421
# A widened shared-address range at 0x200 plus a sensor at 0x201 carrying its own skip_updates.
# The sensor must keep its own range so the polling rates stay independent; if it were folded into
# the widened range it would decode 0x201 from THAT response (2, not 777) and drag the range's
# rate down to its own.
# Two sensors sharing start address 0x200 (a word and a dword) widen the range to 2 registers
# and both decode from the single response.
- expect_tx: [0x01, 0x03, 0x02, 0x00, 0x00, 0x02, 0xC5, 0xB3] # Read holding 0x200 count 2
inject_rx: [0x01, 0x03, 0x04, 0x01, 0x41, 0x00, 0x02, 0x2A, 0x1A] # 0x200=0x0141, 0x201=0x0002
- expect_tx: [0x01, 0x03, 0x02, 0x01, 0x00, 0x01, 0xD4, 0x72] # Read holding 0x201 count 1
inject_rx: [0x01, 0x03, 0x02, 0x03, 0x09, 0x78, 0xB2] # 0x201 = 0x0309 = 777
modbus:
uart_id: virtual_uart_dev
@@ -128,26 +124,17 @@ sensor:
modbus_controller_id: modbus_controller_ok
# Shared address 0x200: the dword widens the range the word opened (or vice versa)
- platform: modbus_controller
name: "rate_word"
name: "widen_word"
address: 0x200
register_type: holding
value_type: U_WORD
modbus_controller_id: modbus_controller_ok
- platform: modbus_controller
name: "rate_dword"
name: "widen_dword"
address: 0x200
register_type: holding
value_type: U_DWORD
modbus_controller_id: modbus_controller_ok
# Inside the widened range but with its own skip_updates: must NOT be folded in, or the two sensors
# above would silently drop to this sensor's polling rate
- platform: modbus_controller
name: "own_rate"
address: 0x201
register_type: holding
value_type: U_WORD
skip_updates: 100
modbus_controller_id: modbus_controller_ok
button:
- platform: template
@@ -0,0 +1,67 @@
"""Integration test for GetTimeResponse parsed_timezone presence handling."""
from __future__ import annotations
from aioesphomeapi import connection as api_connection
from aioesphomeapi.api_pb2 import GetTimeResponse
import pytest
from .state_utils import SensorTracker, build_key_to_entity_mapping
from .types import APIClientConnectedFactory, RunCompiledFunction
# 2024-01-01 00:00:00 UTC
EPOCH = 1704067200
# POSIX offsets are positive west of UTC, so UTC+7 is -25200 and UTC-5 is 18000
UTC_PLUS_7 = -25200
UTC_MINUS_5 = 18000
@pytest.mark.asyncio
async def test_api_get_time_response_timezone(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A present parsed_timezone is applied even when all zero; an absent one is ignored."""
# The client answers the device's own GetTimeRequest with the host timezone;
# strip the parsed field from that reply so only the messages sent below
# can change the device timezone.
monkeypatch.setattr(api_connection, "_build_parsed_tz_proto", lambda tz: None)
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
tracker = SensorTracker(["tz_offset"])
tracker.key_to_sensor = build_key_to_entity_mapping(entities, ["tz_offset"])
client.subscribe_states(tracker.on_state)
await tracker.await_change(tracker.expect_any("tz_offset"), "tz_offset")
initial = tracker.sensor_states["tz_offset"][-1]
# Pick a zone that differs from the codegen default so the change is visible
target = UTC_PLUS_7 if initial != UTC_PLUS_7 else UTC_MINUS_5
# Present, non-zero: applied
future = tracker.expect("tz_offset", target)
resp = GetTimeResponse(epoch_seconds=EPOCH)
resp.parsed_timezone.std_offset_seconds = target
resp.parsed_timezone.dst_offset_seconds = target
client._connection.send_messages((resp,))
await tracker.await_change(future, "tz_offset")
# Absent (legacy client with only the deprecated string): ignored, and in
# particular not mistaken for an all-zero UTC zone
future = tracker.expect("tz_offset", 0)
resp = GetTimeResponse(epoch_seconds=EPOCH, timezone="UTC0")
client._connection.send_messages((resp,))
await tracker.await_must_not_change(future, "tz_offset", timeout=1.0)
assert tracker.sensor_states["tz_offset"][-1] == target
# Retire the expectation so it cannot swallow the first matching state
# meant for the next phase
future.cancel()
# Present but all zero (genuine UTC): applied
future = tracker.expect("tz_offset", 0)
resp = GetTimeResponse(epoch_seconds=EPOCH)
resp.parsed_timezone.SetInParent()
client._connection.send_messages((resp,))
await tracker.await_change(future, "tz_offset")
+12 -16
View File
@@ -675,9 +675,7 @@ async def test_uart_mock_modbus_shared_address(
wide sensor's span keep polling separately, and that the sensor at the span's tail address does not
anchor a re-use join on a mid-range predecessor (which would make it decode that sensor's bytes).
A sensor at 0x201 carrying skip_updates sits inside a widened shared-address range at 0x200 but
keeps its own range, so polling rates stay independent; folding it in would also make it decode
0x201 out of the shared response (2) instead of its own poll (777).
A word and a dword sharing 0x200 widen that range to two registers and both decode from the one read.
"""
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
@@ -693,9 +691,8 @@ async def test_uart_mock_modbus_shared_address(
"wide_qword": 100,
"inside_wide": 321,
"tail_of_wide": 421,
"rate_word": 321,
"rate_dword": pytest.approx(21037058),
"own_rate": 777,
"widen_word": 321,
"widen_dword": pytest.approx(21037058),
}
tracker = SensorTracker(list(expected_values.keys()))
futures = tracker.expect_all(expected_values)
@@ -710,18 +707,18 @@ async def test_uart_mock_modbus_shared_address(
@pytest.mark.asyncio
async def test_uart_mock_modbus_custom_command(
async def test_uart_mock_modbus_custom_pdu(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test a custom_command sensor polling a register served by the mock server.
"""Test a custom_pdu sensor reading a register served by the mock server.
The custom_command is a raw frame (device address + PDU); the hub appends the CRC and
routes the response back to the polling command, whose sensor lambda parses the payload.
Guards the custom polling wiring: the command must reference the sensor's custom_data and
decode the real function code, or nothing is ever transmitted. A plain read on the same
register anchors the bus.
The custom_pdu is a raw read-holding PDU (function code + address + count); the
controller prepends its own device address and appends the CRC, sends it, and the
sensor's lambda parses the response payload. Confirms the custom PDU path decodes
the function code and routes the response to the sensor (the gap that hid the
step-2 raw-vs-PDU bug). A plain read on the same register anchors the bus.
"""
line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback()
@@ -754,9 +751,8 @@ async def test_uart_mock_modbus_offline(
publishes. This pins the pooled non-response counter, can_send() gating, the
offline retry cadence, and recovery - none of which the responding-path tests touch.
The fixture gives offline_skip_updates and the sensor's skip_updates the same period
on purpose: offline probing must follow the offline cadence alone, since requiring
both cadences to coincide leaves phase combinations where no probe ever goes out.
Offline probing follows the offline cadence alone; regular every-update polling
resumes once the device answers again.
"""
tracker = SensorTracker(["link_state", "reg"])