Merge branch 'neutral-ble-client' into radon-eye-single-node

This commit is contained in:
J. Nick Koston
2026-08-26 19:50:16 -05:00
298 changed files with 13885 additions and 2073 deletions
+1 -1
View File
@@ -4,6 +4,6 @@ from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code(config):
cg.add_build_flag("-DUSE_TIME_TIMEZONE")
cg.add_define("USE_TIME_TIMEZONE")
manifest.to_code = to_code
@@ -13,17 +13,18 @@ namespace api {
class APIConnection;
} // namespace api
namespace uart {
enum class UARTFlushResult : uint8_t {
UART_FLUSH_RESULT_SUCCESS,
UART_FLUSH_RESULT_ASSUMED_SUCCESS,
UART_FLUSH_RESULT_TIMEOUT,
UART_FLUSH_RESULT_FAILED,
};
} // namespace uart
namespace serial_proxy {
enum class SerialProxyResult : uint8_t {
SERIAL_PROXY_RESULT_OK,
SERIAL_PROXY_RESULT_ASSUMED_SUCCESS,
SERIAL_PROXY_RESULT_PORT_IN_USE,
SERIAL_PROXY_RESULT_INVALID_ARGUMENT,
SERIAL_PROXY_RESULT_ERROR,
SERIAL_PROXY_RESULT_TIMEOUT,
SERIAL_PROXY_RESULT_NOT_SUPPORTED,
};
class SerialProxy {
public:
void set_instance_index(uint32_t index) { this->instance_index_ = index; }
@@ -31,13 +32,20 @@ class SerialProxy {
const char *get_name() const { return ""; }
api::enums::SerialProxyPortType get_port_type() const { return {}; }
api::APIConnection *get_api_connection() { return nullptr; }
void serial_proxy_request(api::APIConnection *conn, api::enums::SerialProxyRequestType type) {}
void configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity,
uint32_t stop_bits, uint32_t data_size) {}
SerialProxyResult serial_proxy_request(api::APIConnection *conn, api::enums::SerialProxyRequestType type) {
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
SerialProxyResult configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity,
uint8_t stop_bits, uint8_t data_size) {
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
void write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {}
void set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {}
SerialProxyResult set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
uint32_t get_modem_pins() const { return 0; }
uart::UARTFlushResult flush_port() { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; }
uint32_t get_configured_modem_pins() const { return 0; }
SerialProxyResult flush_port(api::APIConnection *api_connection) { return SerialProxyResult::SERIAL_PROXY_RESULT_OK; }
protected:
uint32_t instance_index_{0};
@@ -15,7 +15,9 @@ namespace zwave_proxy {
class ZWaveProxy {
public:
api::APIConnection *get_api_connection() { return nullptr; }
void zwave_proxy_request(api::APIConnection *conn, api::enums::ZWaveProxyRequestType type) {}
api::enums::ZWaveProxyStatus zwave_proxy_request(api::APIConnection *conn, api::enums::ZWaveProxyRequestType type) {
return api::enums::ZWAVE_PROXY_STATUS_OK;
}
void send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length) {}
void api_connection_authenticated(api::APIConnection *conn) {}
uint32_t get_feature_flags() const { return 0; }
@@ -0,0 +1,63 @@
"""Tests for variables handling in homeassistant.event and homeassistant.action."""
from collections.abc import Callable
import logging
from pathlib import Path
import pytest
CONFIG = "tests/component_tests/api/test_homeassistant_variables.yaml"
def test_plain_string_with_return_is_compiled_as_lambda_with_warning(
generate_main: Callable[[str | Path], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""A plain string with a return statement compiles as a lambda and warns."""
with caplog.at_level(logging.WARNING):
main_cpp = generate_main(CONFIG)
assert main_cpp.count('add_variable(ESPHOME_F("lambda_var"), []() {') == 2
assert "return millis();" in main_cpp
# The source text must not be sent as a static string value.
assert '"return millis();"' not in main_cpp
assert "missing the !lambda tag" in caplog.text
def test_static_string_is_kept_as_static_value(
generate_main: Callable[[str | Path], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""A static string stays static, PROGMEM wrapped, with no warning."""
with caplog.at_level(logging.WARNING):
main_cpp = generate_main(CONFIG)
assert (
main_cpp.count(
'add_variable(ESPHOME_F("static_var"), ESPHOME_F("static value"));'
)
== 2
)
assert "static value" not in caplog.text
def test_static_id_value_stays_literal_with_hint(
generate_main: Callable[[str | Path], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Lambda source without a return stays literal text but warns."""
with caplog.at_level(logging.WARNING):
main_cpp = generate_main(CONFIG)
assert 'ESPHOME_F("id(test_sensor).state")' in main_cpp
assert "sent as literal text" in caplog.text
def test_explicit_lambda_tag_is_compiled_as_lambda(
generate_main: Callable[[str | Path], str],
) -> None:
"""A !lambda value keeps working unchanged."""
main_cpp = generate_main(CONFIG)
assert 'add_variable(ESPHOME_F("tagged_var"), []() {' in main_cpp
assert "return App.get_name();" in main_cpp
@@ -0,0 +1,32 @@
esphome:
name: test
on_boot:
then:
# Plain strings with a return statement compile as lambdas
- homeassistant.event:
event: esphome.test_event
data_template:
message: "{{ lambda_var }} {{ static_var }} {{ tagged_var }}"
variables:
lambda_var: |-
return millis();
static_var: static value
tagged_var: !lambda return App.get_name();
hint_var: id(test_sensor).state
- homeassistant.action:
action: notify.notify
data_template:
message: "{{ lambda_var }} {{ static_var }}"
variables:
lambda_var: |-
return millis();
static_var: static value
esp32:
board: esp32dev
wifi:
ssid: SomeNetwork
password: SomePassword
api:
+8
View File
@@ -57,6 +57,14 @@ def reset_core() -> Generator[None]:
CORE.reset()
@pytest.fixture(autouse=True)
def reset_full_config() -> Generator[None]:
"""Give each test a clean final-validate config and restore it after."""
token = final_validate.full_config.set({})
yield
final_validate.full_config.reset(token)
@pytest.fixture
def set_core_config() -> Generator[SetCoreConfigCallable]:
"""Fixture to set up the core configuration for tests."""
@@ -6,9 +6,28 @@ from esphome.components import sensor
from esphome.components.emontx.sensor import CONFIG_SCHEMA, apply_tag_defaults
from esphome.const import (
CONF_ACCURACY_DECIMALS,
CONF_DEVICE_CLASS,
CONF_STATE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
DEVICE_CLASS_APPARENT_POWER,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_FREQUENCY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_POWER_FACTOR,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLTAGE,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_CELSIUS,
UNIT_EMPTY,
UNIT_HERTZ,
UNIT_PULSES,
UNIT_VOLT,
UNIT_VOLT_AMPS,
UNIT_WATT,
UNIT_WATT_HOURS,
)
@@ -61,9 +80,25 @@ def _make_config(tag: str) -> dict:
("PULSE1", STATE_CLASS_TOTAL_INCREASING, 0),
("PULSE12", STATE_CLASS_TOTAL_INCREASING, 0),
("PF1", STATE_CLASS_MEASUREMENT, 2),
("AP1", STATE_CLASS_MEASUREMENT, 2),
("AP12", STATE_CLASS_MEASUREMENT, 2),
# Frequency: reported as a single, un-numbered tag
("F", STATE_CLASS_MEASUREMENT, 2),
# Unknown / free-form tags fall back to generic defaults
("CUSTOM1", STATE_CLASS_MEASUREMENT, 0),
("X", STATE_CLASS_MEASUREMENT, 0),
# "F1" is not the exact "F" tag, so it falls back to generic defaults
("F1", STATE_CLASS_MEASUREMENT, 0),
# "PULSE" (no index) is how some real emonTx firmware reports a
# single pulse counter, so it still resolves to the PULSE defaults
("PULSE", STATE_CLASS_TOTAL_INCREASING, 0),
# Real firmware sends this lowercase; tag_upper's case-folding must
# still match it against the PULSE pattern
("pulse", STATE_CLASS_TOTAL_INCREASING, 0),
# PF/AP require a numeric index; the bare prefix alone (no index)
# falls back to generic defaults
("PF", STATE_CLASS_MEASUREMENT, 0),
("AP", STATE_CLASS_MEASUREMENT, 0),
],
)
def test_apply_tag_defaults(tag, expected_state_class, expected_decimals):
@@ -76,6 +111,80 @@ def test_apply_tag_defaults(tag, expected_state_class, expected_decimals):
assert result[CONF_ACCURACY_DECIMALS] == expected_decimals
@pytest.mark.parametrize(
("tag", "expected_unit", "expected_device_class"),
[
# Known numeric-index prefixes
("E1", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY),
("E12", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY),
("P1", UNIT_WATT, DEVICE_CLASS_POWER),
("V1", UNIT_VOLT, DEVICE_CLASS_VOLTAGE),
("I1", UNIT_AMPERE, DEVICE_CLASS_CURRENT),
("T1", UNIT_CELSIUS, DEVICE_CLASS_TEMPERATURE),
# Known patterns
("PULSE1", UNIT_PULSES, DEVICE_CLASS_ENERGY),
("PULSE12", UNIT_PULSES, DEVICE_CLASS_ENERGY),
# Bare "PULSE" (no index), as reported by some real emonTx firmware
("PULSE", UNIT_PULSES, DEVICE_CLASS_ENERGY),
# Real firmware sends this lowercase; tag_upper's case-folding must
# still match it against the PULSE pattern
("pulse", UNIT_PULSES, DEVICE_CLASS_ENERGY),
("PF1", UNIT_EMPTY, DEVICE_CLASS_POWER_FACTOR),
("AP1", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER),
("AP12", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER),
# Frequency: reported as a single, un-numbered tag
("F", UNIT_HERTZ, DEVICE_CLASS_FREQUENCY),
],
)
def test_apply_tag_defaults_unit_and_device_class(
tag, expected_unit, expected_device_class
):
"""apply_tag_defaults must inject the correct, validated unit_of_measurement
and device_class for each tag type when no user overrides are present."""
config = _make_config(tag)
result = apply_tag_defaults(config)
assert result[CONF_UNIT_OF_MEASUREMENT] == sensor.validate_unit_of_measurement(
expected_unit
)
assert result[CONF_DEVICE_CLASS] == sensor.validate_device_class(
expected_device_class
)
@pytest.mark.parametrize(
"tag",
[
"CUSTOM1",
"X",
# Non-numeric suffixes must not collide with a PATTERN_CONFIGS prefix
# (e.g. "APPLE" starting with "AP", "PFX" starting with "PF").
"APPLE",
"PFX",
"PULSE_A",
# "F1" is not the exact "F" tag
"F1",
# Bare "PF"/"AP" (no numeric index) don't match; unlike "PULSE",
# real firmware never reports these without an index
"PF",
"AP",
],
)
def test_apply_tag_defaults_unknown_tag_has_no_unit_or_device_class(tag):
"""Unknown / free-form tags only get generic state_class and
accuracy_decimals defaults; unit_of_measurement and device_class are left
for the user to set explicitly."""
config = _make_config(tag)
result = apply_tag_defaults(config)
assert CONF_UNIT_OF_MEASUREMENT not in result
assert CONF_DEVICE_CLASS not in result
assert result[CONF_STATE_CLASS] == sensor.validate_state_class(
STATE_CLASS_MEASUREMENT
)
assert result[CONF_ACCURACY_DECIMALS] == 0
@pytest.mark.parametrize(
("tag", "user_state_class", "user_decimals"),
[
@@ -0,0 +1,9 @@
esphome:
name: test
libraries:
- NetworkClientSecure
esp32:
board: esp32dev
framework:
type: arduino
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
advanced:
use_full_certificate_bundle: true
@@ -0,0 +1,14 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
http_request:
verify_ssl: true
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
sdkconfig_options:
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE: y
@@ -0,0 +1,15 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
esp32_camera_web_server:
port: 8080
mode: stream
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
sdkconfig_options:
CONFIG_NVS_ENCRYPTION: y
CONFIG_NVS_SEC_KEY_PROTECT_USING_HMAC: y
CONFIG_NVS_SEC_HMAC_EFUSE_KEY_ID: "0"
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
sdkconfig_options:
CONFIG_NVS_ENCRYPTION: n
+101 -1
View File
@@ -17,6 +17,7 @@ from esphome.components.esp32 import (
VARIANT_ESP32,
VARIANTS,
NetworkSdkconfigData,
RawSdkconfigValue,
_ota_downgrade_protection_errors,
_reconcile_network_sdkconfig,
_reconcile_vfs_fatfs_sdkconfig,
@@ -131,6 +132,20 @@ def test_esp32_rejects_unsupported_toolchains(
CONFIG_SCHEMA({"variant": VARIANT_ESP32, "toolchain": config_toolchain})
def test_esp32_rejects_unsupported_cli_toolchain(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A --toolchain the platform cannot serve fails instead of silently
building with PlatformIO (the CLI path bypasses the YAML validator)."""
set_core_config(PlatformFramework.ESP32_IDF)
from esphome.components.esp32 import CONFIG_SCHEMA
CORE.toolchain = Toolchain.ARDUINO
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"):
CONFIG_SCHEMA({"variant": VARIANT_ESP32})
@pytest.mark.parametrize(
("config", "error_match"),
[
@@ -260,9 +275,24 @@ def test_esp32_configuration_errors(
),
pytest.param(
"exclusion_reincludes_web_server.yaml",
("esp-tls",),
("esp-tls", "esp_http_server"),
id="web_server_idf",
),
pytest.param(
"nvs_encryption_s3.yaml",
("nvs_sec_provider",),
id="nvs_encryption",
),
pytest.param(
"exclusion_reincludes_nvs_sdkconfig.yaml",
("nvs_sec_provider",),
id="nvs_encryption_raw_sdkconfig",
),
pytest.param(
"exclusion_reincludes_camera_web_server.yaml",
("esp_http_server",),
id="esp32_camera_web_server",
),
pytest.param(
"exclusion_reincludes_nextion.yaml",
("esp-tls", "esp_http_client"),
@@ -290,6 +320,76 @@ def test_default_exclusions_reincluded_by_owning_components(
# Components no part of this config touches stay excluded.
assert "unity" in excluded
assert "fatfs" in excluded
# The HTTP server only comes back for configs that run one.
assert ("esp_http_server" in excluded) == ("esp_http_server" not in reincluded)
def test_nvs_sec_provider_stays_excluded_when_encryption_is_off(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""An explicit CONFIG_NVS_ENCRYPTION=n keeps nvs_sec_provider excluded."""
from esphome.components.esp32.const import KEY_EXCLUDE_COMPONENTS
generate_main(component_config_path("exclusion_stays_nvs_sdkconfig_off.yaml"))
assert "nvs_sec_provider" in CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
_BUNDLE_OPTIONS = (
"CONFIG_MBEDTLS_CERTIFICATE_BUNDLE",
"CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN",
"CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL",
)
@pytest.mark.parametrize(
("config_file", "expected"),
[
pytest.param("exclusion_reincludes.yaml", (False, None, None), id="no_tls"),
pytest.param(
"certificate_bundle_http_request.yaml",
(True, True, False),
id="http_request",
),
pytest.param(
"exclusion_reincludes_http_request.yaml",
(False, None, None),
id="http_request_no_verify",
),
pytest.param(
"certificate_bundle_full.yaml", (True, None, True), id="full_option"
),
pytest.param(
"certificate_bundle_arduino_tls.yaml",
(True, True, False),
id="arduino_network_client_secure",
),
],
)
def test_certificate_bundle_sdkconfig(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
expected: tuple[bool | None, ...],
) -> None:
"""The bundle and its CMN/FULL variant are written only when requested."""
generate_main(component_config_path(config_file))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert tuple(sdkconfig.get(name) for name in _BUNDLE_OPTIONS) == expected
def test_user_sdkconfig_certificate_bundle_wins(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A raw sdkconfig_options bundle setting is kept and still pins CMN."""
generate_main(component_config_path("certificate_bundle_sdkconfig.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
value = sdkconfig["CONFIG_MBEDTLS_CERTIFICATE_BUNDLE"]
assert isinstance(value, RawSdkconfigValue)
assert value.value == "y"
assert sdkconfig.get("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN") is True
assert sdkconfig.get("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL") is False
def test_execute_from_psram_s3_sdkconfig(
@@ -12,6 +12,7 @@ arbiter a full-duty scan would starve wifi, so the 30 ms default is kept.
from __future__ import annotations
from collections.abc import Callable
import logging
from pathlib import Path
import pytest
@@ -221,3 +222,54 @@ def test_connection_scan_window_codegen(
assert window_call in main_cpp
assert ("set_connection_scan_window(48)" in main_cpp) == connection_call
assert ("'connection_scan_window' has no effect" in caplog.text) == warns
@pytest.mark.parametrize(
("wifi", "params", "expect_warning"),
[
(True, {"interval": "1100ms", "window": "1100ms"}, True),
(True, {"interval": "1100ms", "window": "601ms"}, True),
(True, {"interval": "1100ms", "window": "600ms"}, False),
(False, {"interval": "1100ms", "window": "1100ms"}, False),
],
)
def test_long_window_with_wifi_warns(
stage_esp32: Callable[..., None],
caplog: pytest.LogCaptureFixture,
wifi: bool,
params: ConfigType,
expect_warning: bool,
) -> None:
"""A scan window above 600 ms warns only when wifi shares the radio."""
stage_esp32("5.5.5", wifi=wifi)
with caplog.at_level(logging.WARNING):
_scan_params({"scan_parameters": params})
assert ("starves wifi" in caplog.text) is expect_warning
def test_long_window_warns_with_coexistence_disabled(
stage_esp32: Callable[..., None],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Disabling the arbiter is the worst case for a long window, so it still warns."""
stage_esp32("5.5.5", wifi=True)
with caplog.at_level(logging.WARNING):
_scan_params(
{
CONF_SOFTWARE_COEXISTENCE: False,
"scan_parameters": {"interval": "1100ms", "window": "1100ms"},
}
)
assert "BLE scan window of 1100ms" in caplog.text
def test_raised_window_warning_points_at_interval(
stage_esp32: Callable[..., None],
caplog: pytest.LogCaptureFixture,
) -> None:
"""When the window was raised to a long interval, the warning names the interval."""
stage_esp32("5.5.5", wifi=True)
with caplog.at_level(logging.WARNING):
_scan_params({"scan_parameters": {"interval": "1s"}})
assert "BLE scan interval of 1s" in caplog.text
assert "BLE scan window of" not in caplog.text
@@ -0,0 +1,21 @@
esphome:
name: test
esp32:
board: esp32dev
spi:
- id: spi_bus
interface: spi2
clk_pin: GPIO18
mosi_pin: GPIO23
miso_pin: GPIO19
ethernet:
id: eth_component
type: W5500
spi_id: spi_bus
cs_pin: GPIO5
interrupt_pin: GPIO36
reset_pin: GPIO22
clock_speed: 20MHz
@@ -0,0 +1,16 @@
esphome:
name: test
esp32:
board: esp32dev
ethernet:
id: eth_component
type: W5500
clk_pin: GPIO18
mosi_pin: GPIO23
miso_pin: GPIO19
cs_pin: GPIO5
interrupt_pin: GPIO36
reset_pin: GPIO22
clock_speed: 20MHz
@@ -27,14 +27,6 @@ _CH390_CONFIG = {
}
@pytest.fixture(autouse=True)
def _reset_full_config():
"""Reset fv.full_config so each test starts with a clean slate."""
token = fv.full_config.set({})
yield
fv.full_config.reset(token)
def test_rejects_wifi_and_ethernet_without_priority() -> None:
"""Wi-Fi + ethernet without a network: priority: list must be rejected."""
fv.full_config.set({"wifi": {}, "ethernet": {}})
@@ -0,0 +1,235 @@
"""Tests for the ethernet `spi_id:` option (attach to a shared spi bus)."""
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 import (
KEY_BOARD,
KEY_IDF_VERSION,
KEY_VARIANT,
VARIANT_ESP32S3,
)
from esphome.components.ethernet import CONF_INTERFACE, CONFIG_SCHEMA, _final_validate
from esphome.components.rp2.const import KEY_BOARD as RP2_KEY_BOARD
# Registers the rp2 pin schema so RP2 configs can validate pins.
import esphome.components.rp2.gpio # noqa: F401
from esphome.components.spi import CONF_INTERFACE_INDEX
from esphome.const import (
CONF_CLK_PIN,
CONF_ID,
CONF_MISO_PIN,
CONF_MOSI_PIN,
CONF_SPI,
CONF_SPI_ID,
CONF_TYPE,
PlatformFramework,
)
from esphome.core import CORE, ID
import esphome.final_validate as fv
from ..types import SetCoreConfigCallable
_W5500_PIN_CONFIG = {
"type": "W5500",
"clk_pin": 47,
"mosi_pin": 48,
"miso_pin": 14,
"cs_pin": 21,
}
_W5500_SPI_ID_CONFIG = {
"type": "W5500",
"spi_id": "spi_bus",
"cs_pin": 21,
}
def _set_esp32_s3(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
KEY_IDF_VERSION: cv.Version(5, 3, 2),
},
)
# _validate derives use_address from the node name, which has no default here.
CORE.name = "spi-id-test"
def test_spi_id_accepted_without_pins_or_interface(
set_core_config: SetCoreConfigCallable,
) -> None:
"""With spi_id set, the pin options are not required and no interface is defaulted."""
_set_esp32_s3(set_core_config)
config = CONFIG_SCHEMA(dict(_W5500_SPI_ID_CONFIG))
assert config[CONF_SPI_ID] == ID("spi_bus")
# The interface comes from the referenced bus; no default may be injected.
assert CONF_INTERFACE not in config
@pytest.mark.parametrize(
("key", "value"),
[
(CONF_CLK_PIN, 47),
(CONF_MOSI_PIN, 48),
(CONF_MISO_PIN, 14),
(CONF_INTERFACE, "spi2"),
],
)
def test_spi_id_rejects_bus_options(
set_core_config: SetCoreConfigCallable, key: str, value: int | str
) -> None:
"""Options provided by the referenced bus must be rejected alongside spi_id."""
_set_esp32_s3(set_core_config)
with pytest.raises(Invalid, match=f"'{key}' cannot be used together with 'spi_id'"):
CONFIG_SCHEMA({**_W5500_SPI_ID_CONFIG, key: value})
@pytest.mark.parametrize("key", [CONF_CLK_PIN, CONF_MOSI_PIN, CONF_MISO_PIN])
def test_bus_pins_still_required_without_spi_id(
set_core_config: SetCoreConfigCallable, key: str
) -> None:
"""Without spi_id, the bus pin options stay required."""
_set_esp32_s3(set_core_config)
config = {k: v for k, v in _W5500_PIN_CONFIG.items() if k != key}
with pytest.raises(
Invalid, match=f"'{key}' is a required option when 'spi_id' is not set"
):
CONFIG_SCHEMA(config)
def test_spi_id_rejected_on_rp2(set_core_config: SetCoreConfigCallable) -> None:
"""spi_id is ESP32-only; the RP2 path is unchanged."""
set_core_config(
PlatformFramework.RP2_ARDUINO, platform_data={RP2_KEY_BOARD: "rpipicow"}
)
CORE.name = "spi-id-test"
config = {
"type": "W5500",
"spi_id": "spi_bus",
"clk_pin": 18,
"mosi_pin": 19,
"miso_pin": 16,
"cs_pin": 17,
}
with pytest.raises(Invalid, match="only available on"):
CONFIG_SCHEMA(config)
def _eth_spi_id_final_config() -> dict:
return {CONF_TYPE: "W5500", CONF_SPI_ID: ID("spi_bus")}
class _FakeFinalConfig(dict):
"""Dict-backed FinalValidateConfig with just enough ID resolution for
fv.id_declaration_match_schema to find an spi bus fragment."""
def get_path_for_id(self, id: ID) -> list:
for index, conf in enumerate(self[CONF_SPI]):
if conf[CONF_ID] == id:
return [CONF_SPI, index, CONF_ID]
raise KeyError(id)
def get_config_for_path(self, path: list) -> dict:
return self[path[0]][path[1]]
def _set_spi_buses(*buses: dict) -> None:
fv.full_config.set(_FakeFinalConfig({CONF_SPI: list(buses)}))
_SHAREABLE_BUS = {
CONF_ID: ID("spi_bus"),
CONF_INTERFACE_INDEX: 0,
CONF_MISO_PIN: {},
CONF_MOSI_PIN: {},
}
def test_final_validate_accepts_hardware_bus_with_data_pins(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A hardware spi bus that declares miso_pin and mosi_pin may be shared."""
_set_esp32_s3(set_core_config)
# An unrelated bus first: the ID lookup must skip past it.
_set_spi_buses({CONF_ID: ID("other_bus"), CONF_INTERFACE_INDEX: 1}, _SHAREABLE_BUS)
_final_validate(_eth_spi_id_final_config())
def test_final_validate_rejects_software_bus(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A software spi bus (no hardware interface index) cannot be shared."""
_set_esp32_s3(set_core_config)
bus = {k: v for k, v in _SHAREABLE_BUS.items() if k != CONF_INTERFACE_INDEX}
_set_spi_buses(bus)
with pytest.raises(Invalid, match="requires this spi bus to use a hardware"):
_final_validate(_eth_spi_id_final_config())
@pytest.mark.parametrize("pin_key", [CONF_MISO_PIN, CONF_MOSI_PIN])
def test_final_validate_rejects_bus_without_data_pin(
set_core_config: SetCoreConfigCallable, pin_key: str
) -> None:
"""The shared bus must declare both data pins to drive the ethernet chip."""
_set_esp32_s3(set_core_config)
bus = {k: v for k, v in _SHAREABLE_BUS.items() if k != pin_key}
_set_spi_buses(bus)
with pytest.raises(Invalid, match=f"requires this spi bus to declare a {pin_key}"):
_final_validate(_eth_spi_id_final_config())
def test_final_validate_rejects_colliding_host_without_spi_id(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Without spi_id, claiming the same host as an spi bus stays an error."""
_set_esp32_s3(set_core_config)
fv.full_config.set({CONF_SPI: [{CONF_ID: ID("spi_bus"), CONF_INTERFACE_INDEX: 0}]})
config = {CONF_TYPE: "W5500", CONF_INTERFACE: "spi2"}
with pytest.raises(Invalid, match="both using interface 'SPI2_HOST'"):
_final_validate(config)
def test_final_validate_accepts_distinct_host_without_spi_id(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Without spi_id, a different host than the spi bus is accepted."""
_set_esp32_s3(set_core_config)
fv.full_config.set({CONF_SPI: [{CONF_ID: ID("spi_bus"), CONF_INTERFACE_INDEX: 0}]})
_final_validate({CONF_TYPE: "W5500", CONF_INTERFACE: "spi3"})
def test_generated_code_uses_spi_parent(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""With spi_id, codegen wires the spi parent and skips the bus options."""
main_cpp = generate_main(component_config_path("spi_id_shared_bus.yaml"))
assert "eth_component->set_spi_parent(spi_bus);" in main_cpp
assert "eth_component->set_cs_pin(5);" in main_cpp
assert "eth_component->set_clk_pin(" not in main_cpp
assert "eth_component->set_miso_pin(" not in main_cpp
assert "eth_component->set_mosi_pin(" not in main_cpp
assert "eth_component->set_interface(" not in main_cpp
def test_generated_code_without_spi_id_initializes_own_bus(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Without spi_id, codegen still emits the pin and interface setters."""
main_cpp = generate_main(component_config_path("spi_own_bus.yaml"))
assert "eth_component->set_spi_parent(" not in main_cpp
assert "eth_component->set_clk_pin(18);" in main_cpp
assert "eth_component->set_miso_pin(19);" in main_cpp
assert "eth_component->set_mosi_pin(23);" in main_cpp
assert "eth_component->set_cs_pin(5);" in main_cpp
assert "eth_component->set_interface(::SPI3_HOST);" in main_cpp
@@ -0,0 +1,12 @@
esphome:
name: test
esp32:
board: nodemcu-32s
wifi:
ssid: test
password: testtest
http_request:
timeout: 10s
@@ -0,0 +1,13 @@
esphome:
name: test
esp32:
board: nodemcu-32s
wifi:
ssid: test
password: testtest
http_request:
timeout: 10s
watchdog_timeout: 20s
@@ -0,0 +1,13 @@
esphome:
name: test
esp32:
board: nodemcu-32s
watchdog_timeout: 60s
wifi:
ssid: test
password: testtest
http_request:
timeout: 10s
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: nodemcu-32s
wifi:
ssid: test
password: testtest
http_request:
@@ -0,0 +1,13 @@
esphome:
name: test
esp8266:
board: d1_mini
wifi:
ssid: test
password: testtest
http_request:
timeout: 10s
verify_ssl: false
@@ -0,0 +1,13 @@
esphome:
name: test
rp2:
board: rpipicow
wifi:
ssid: test
password: testtest
http_request:
timeout: 10s
verify_ssl: false
@@ -0,0 +1,42 @@
"""Tests for the http_request watchdog timeout default."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.config import read_config
from esphome.const import CONF_WATCHDOG_TIMEOUT
from esphome.core import CORE, TimePeriodMilliseconds
@pytest.mark.parametrize(
("yaml_file", "expected_ms"),
[
# stock 4.5s timeout: 3 x 4.5s plus 1s margin
("test_esp32_stock.yaml", 14500),
# 3 x 10s plus 1s margin
("test_esp32_default.yaml", 31000),
# esp32.watchdog_timeout: 60s is wider than the derived value and wins
("test_esp32_platform_wider.yaml", 60000),
# explicit value is kept as is
("test_esp32_explicit.yaml", 20000),
],
)
def test_esp32_watchdog_timeout(
component_config_path: Callable[[str], Path], yaml_file: str, expected_ms: int
) -> None:
CORE.config_path = component_config_path(yaml_file)
config = read_config({})
assert config["http_request"][CONF_WATCHDOG_TIMEOUT] == TimePeriodMilliseconds(
milliseconds=expected_ms
)
@pytest.mark.parametrize("yaml_file", ["test_esp8266.yaml", "test_rp2040.yaml"])
def test_other_platforms_leave_watchdog_unset(
component_config_path: Callable[[str], Path], yaml_file: str
) -> None:
CORE.config_path = component_config_path(yaml_file)
config = read_config({})
assert CONF_WATCHDOG_TIMEOUT not in config["http_request"]
@@ -0,0 +1,36 @@
esphome:
name: test-dropdown-update-event
on_boot:
- lvgl.dropdown.update:
id: test_dropdown
selected_index: 2
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin: GPIO3
lvgl:
widgets:
- dropdown:
id: test_dropdown
options:
- First
- Second
- Third
on_update:
- lambda: |-
ESP_LOGD("test", "dropdown updated");
@@ -0,0 +1,36 @@
esphome:
name: test-list-on-add-lvgl-action
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin: GPIO3
lvgl:
widgets:
- label:
id: later_label
text: orig
- list:
id: test_list
on_add:
- lvgl.label.update:
id: later_label
text: "changed"
on_remove:
- lvgl.label.update:
id: later_label
text: "removed"
@@ -0,0 +1,41 @@
esphome:
name: test-list-outside-block
on_boot:
priority: -100
then:
- lvgl.list.add:
id: test_list
switch:
transform_rotation: 100
drop_shadow_color: 0x000000
bg_image_src: my_image
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin: GPIO3
image:
- platform: file
file: mdi:battery
id: my_image
resize: 8x8
type: binary
lvgl:
widgets:
- list:
id: test_list
@@ -0,0 +1,77 @@
esphome:
name: test-list
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin: GPIO3
lvgl:
theme:
label:
bg_color: 0xFF0000
widgets:
- list:
id: test_list
pad_row: 4
on_add:
- delay: 10ms
- delay: 20ms
on_remove:
- delay: 10ms
- button:
id: trigger_button
text: "Trigger"
on_click:
- lvgl.list.add_text:
id: test_list
text: "Header"
- lvgl.list.add_text:
id: test_list
text: "Pinned"
index: 0
- lvgl.list.add:
id: test_list
button:
text: "Entry"
checkable: true
- lvgl.list.add:
id: test_list
index: 1
obj:
widgets:
- label:
text: "Nested"
- dropdown:
options:
- "One"
- "Two"
- lvgl.list.add:
id: test_list
obj:
widgets:
- obj:
widgets:
- label:
text: "Grandchild"
- lvgl.list.remove:
id: test_list
index: 0
- lvgl.list.clear:
id: test_list
- lvgl.list.update:
id: test_list
pad_row: 8
@@ -0,0 +1,41 @@
"""Regression test: lvgl.dropdown.update with selected_index must fire on_value/on_update.
LvSelect (backing both dropdown and roller) did not set `value_property`, so the generic
update-action machinery in automation.py never sent the synthetic update event for a
`selected_index:` change made via `lvgl.dropdown.update`/`lvgl.roller.update`, unlike `value:`
on number widgets or `text:` on text widgets. Fixed by setting `LvSelect.value_property` to
`CONF_SELECTED_INDEX`.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
from esphome.core import CORE
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
config_path = (
Path(request.fspath).parent / "config" / "dropdown_update_fires_event_test.yaml"
)
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_dropdown_update_sends_update_event(main_cpp: str) -> None:
assert (
"lv_obj_send_event(test_dropdown->obj, lvgl::lv_update_event, nullptr)"
in main_cpp
)
+403
View File
@@ -0,0 +1,403 @@
"""Tests for the LVGL ``list`` widget: schema validation for its actions
(``lvgl.list.add_text``/``add``/``remove``/``clear``) and the code they generate.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.components.lvgl.widgets.lv_list import (
LIST_CREATE_SCHEMA,
LIST_REMOVE_SCHEMA,
LIST_SCHEMA,
list_add_schema,
)
from esphome.config import read_config
import esphome.config_validation as cv
from esphome.core import CORE
# ---------------------------------------------------------------------------
# lvgl.list.add schema: id + optional index + exactly one widget-type key
# ---------------------------------------------------------------------------
class TestListAddSchema:
def test_valid_single_widget(self) -> None:
result = list_add_schema({"id": "my_list", "label": {"text": "hi"}})
assert result["id"].id == "my_list"
assert "widget" in result
def test_index_optional_and_templatable(self) -> None:
result = list_add_schema({"id": "my_list", "index": 2, "label": {"text": "hi"}})
assert result["index"] == 2
def test_index_omitted_when_not_given(self) -> None:
result = list_add_schema({"id": "my_list", "label": {"text": "hi"}})
assert "index" not in result
def test_missing_id_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="required key 'id' not provided"):
list_add_schema({"label": {"text": "hi"}})
def test_no_widget_key_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="exactly one widget definition"):
list_add_schema({"id": "my_list"})
def test_two_widget_keys_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="exactly one widget definition"):
list_add_schema(
{
"id": "my_list",
"label": {"text": "a"},
"button": {"text": "b"},
}
)
def test_non_mapping_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="Expected a mapping"):
list_add_schema("not_a_mapping")
def test_any_registered_widget_type_accepted(self) -> None:
for widget_key, widget_conf in (
("checkbox", {"text": "Option"}),
("switch", {}),
("spinner", {}),
("obj", {}),
("dropdown", {"options": ["a", "b"]}),
):
result = list_add_schema({"id": "my_list", widget_key: widget_conf})
assert widget_key in result["widget"][0]
@pytest.mark.parametrize(
("widget_key", "widget_conf"),
[
("buttonmatrix", {"rows": [{"buttons": [{"text": "A"}]}]}),
("tabview", {"tabs": [{"name": "Tab1"}]}),
("tileview", {"tiles": [{"row": 0, "column": 0}]}),
("meter", {"scales": [{"range_from": 0, "range_to": 100}]}),
("canvas", {"width": 20, "height": 20}),
],
)
def test_dynamic_widget_unsupported_rejected(
self, widget_key: str, widget_conf: dict
) -> None:
"""buttonmatrix/tabview/tileview all register their own child widgets into
the global widget map from inside their to_code - fine for a widget built
once at boot, but broken if lvgl.list.add re-enters that on every call.
meter/canvas are rejected for a related but distinct reason: they declare a
Pvariable (meter's scale/indicator objects; canvas's draw buffer) with
cg.Pvariable()/cg.new_Pvariable(), which emits its assignment wherever code
is currently being generated -- fine at the top level of a boot-time
to_code, but lvgl.list.add's do_add runs inside a lambda. meter's assignment
would then end up outside the very lambda that declares the local object it
refers to (doesn't compile); canvas's Pvariable is declared once per config
site rather than per call, so every call overwrites its one draw buffer
(compiles, but leaks the old buffer and shares one buffer across every row).
"""
with pytest.raises(cv.Invalid, match="cannot be used with lvgl.list.add"):
list_add_schema({"id": "my_list", widget_key: widget_conf})
def test_dynamic_widget_unsupported_rejected_when_nested(self) -> None:
"""The check must recurse into `widgets:` so a tabview hidden a few levels
deep inside another widget is caught too, not just at the top level.
"""
with pytest.raises(cv.Invalid, match="cannot be used with lvgl.list.add"):
list_add_schema(
{
"id": "my_list",
"obj": {"widgets": [{"tabview": {"tabs": [{"name": "Tab1"}]}}]},
}
)
def test_explicit_id_rejected(self) -> None:
"""A dynamically-added widget is LocalVariable-scoped and rebuilt fresh
on every call, never registered anywhere an id could be looked up by --
an explicit id: would otherwise validate fine and then fail confusingly
(an uncaught traceback, not a clean config error) the moment anything
tries to reference it.
"""
with pytest.raises(cv.Invalid, match="'id' is not allowed"):
list_add_schema(
{"id": "my_list", "label": {"id": "dyn_label", "text": "hi"}}
)
def test_explicit_id_rejected_when_nested(self) -> None:
with pytest.raises(cv.Invalid, match="'id' is not allowed"):
list_add_schema(
{
"id": "my_list",
"obj": {"widgets": [{"label": {"id": "dyn_label", "text": "hi"}}]},
}
)
def test_no_explicit_id_still_valid(self) -> None:
"""An id is auto-generated (and simply unused) when none is given --
only an explicit one is rejected."""
result = list_add_schema({"id": "my_list", "label": {"text": "hi"}})
assert "id" in result["widget"][0]["label"]
@pytest.mark.parametrize(
("key", "conf"),
[
("on_swipe_left", [{"logger.log": "swiped"}]),
("on_swipe_right", [{"logger.log": "swiped"}]),
("on_swipe_up", [{"logger.log": "swiped"}]),
("on_swipe_down", [{"logger.log": "swiped"}]),
("on_boot", [{"logger.log": "booted"}]),
("align_to", {"id": "some_other_widget", "align": "OUT_LEFT_TOP"}),
],
)
def test_unsupported_trigger_rejected(self, key: str, conf: list) -> None:
"""_wire_dynamic_triggers only wires LV_EVENT_TRIGGERS/on_value/on_update --
on_swipe_*/on_boot would otherwise validate fine and then silently generate
nothing at all for a widget added via lvgl.list.add. align_to is in the same
bucket: it's only ever consumed by generate_triggers() reading
get_widget_map(), which a widget built via lvgl.list.add never enters.
"""
with pytest.raises(cv.Invalid, match="is not supported"):
list_add_schema({"id": "my_list", "obj": {key: conf}})
def test_unsupported_trigger_rejected_when_nested(self) -> None:
with pytest.raises(cv.Invalid, match="is not supported"):
list_add_schema(
{
"id": "my_list",
"obj": {
"widgets": [
{
"label": {
"text": "hi",
"on_swipe_left": [{"logger.log": "swiped"}],
}
}
]
},
}
)
# ---------------------------------------------------------------------------
# lvgl.list.remove: index must be non-negative -- LVGL treats a negative index as
# counting back from the end, which would silently delete the wrong row while
# reporting a list_index that matches nothing real to on_remove.
# ---------------------------------------------------------------------------
class TestListRemoveSchema:
def test_negative_index_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="at least 0"):
LIST_REMOVE_SCHEMA({"id": "my_list", "index": -1})
def test_zero_index_accepted(self) -> None:
result = LIST_REMOVE_SCHEMA({"id": "my_list", "index": 0})
assert result["index"] == 0
# ---------------------------------------------------------------------------
# The list widget's own schema: pad_row is shared between create/update, but
# on_add/on_remove only make sense at creation time.
# ---------------------------------------------------------------------------
class TestListCreateVsModifySchema:
def test_create_schema_has_pad_row_and_triggers(self) -> None:
keys = {str(k) for k in LIST_CREATE_SCHEMA.schema}
assert "pad_row" in keys
assert "on_add" in keys
assert "on_remove" in keys
def test_modify_schema_has_pad_row_but_not_triggers(self) -> None:
"""``lvgl.list.update`` can change pad_row but can't (re-)declare triggers."""
keys = {str(k) for k in LIST_SCHEMA.schema}
assert "pad_row" in keys
assert "on_add" not in keys
assert "on_remove" not in keys
def test_on_add_single_automation_with_multiple_actions(self) -> None:
"""A bare action list under on_add: is one automation with a multi-step
`then:`, not multiple independent automations.
"""
config = LIST_CREATE_SCHEMA({"on_add": [{"delay": "10ms"}, {"delay": "20ms"}]})
assert len(config["on_add"]) == 1
assert len(config["on_add"][0]["then"]) == 2
def test_on_add_accepts_multiple_independent_automations(self) -> None:
"""Each explicit `then:` entry gets its own Trigger, so on_add can fire
more than one independent automation.
"""
config = LIST_CREATE_SCHEMA(
{
"on_add": [
{"then": [{"delay": "10ms"}]},
{"then": [{"delay": "20ms"}]},
]
}
)
assert len(config["on_add"]) == 2
# ---------------------------------------------------------------------------
# Code generation
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
"""Generate the C++ output for the shared list-widget YAML config once per
module -- see test_widget_state.py for why this is module-scoped and
inlines the generate_main fixture logic rather than depending on it.
"""
config_path = Path(request.fspath).parent / "config" / "list_test.yaml"
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_global_section + CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_pad_row_set_at_creation(main_cpp: str) -> None:
assert "lv_obj_set_style_pad_row(test_list, 4, LV_PART_MAIN);" in main_cpp
def test_pad_row_updated_via_update_action(main_cpp: str) -> None:
assert "lv_obj_set_style_pad_row(test_list, 8, LV_PART_MAIN);" in main_cpp
def test_add_text_appends(main_cpp: str) -> None:
assert 'lv_list_add_text(test_list, "Header");' in main_cpp
def test_add_text_with_index_moves_before_firing_on_add(main_cpp: str) -> None:
"""The index move must happen before on_add fires, so the reported
list_index reflects the entry's final position, not where it was appended.
"""
assert (
'lv_obj_t *list_entry_VAR_ = lv_list_add_text(test_list, "Pinned");\n'
" lv_obj_move_to_index(list_entry_VAR_, 0);\n"
" triggerint_id->trigger(lvgl::lv_list_get_row_index(test_list, list_entry_VAR_));"
) in main_cpp
def test_add_button_with_checkable_flag(main_cpp: str) -> None:
assert "lv_obj_t *dyn_button_VAR_ = lv_btn_create(test_list);" in main_cpp
assert (
"lv_obj_add_flag(dyn_button_VAR_, (lv_obj_flag_t)(LV_OBJ_FLAG_CHECKABLE));"
in main_cpp
)
assert (
'lv_label_set_text(lv_obj_get_child(dyn_button_VAR_, 0), "Entry");' in main_cpp
)
def test_add_nested_hierarchy_with_compound_child(main_cpp: str) -> None:
"""`obj: {widgets: [label, dropdown]}` builds a plain label child and a
heap-allocated (compound) dropdown child, both parented to the new row.
The child variable names carry a `_1` (depth) suffix, distinguishing them
from the row's own top-level variable -- necessary so that a child of the
*same* widget type as its parent (e.g. `obj: {widgets: [{obj: {...}}]}`)
doesn't declare a C++ variable that shadows its own not-yet-initialized
self, silently parenting the child to garbage.
"""
assert "lv_obj_t *dyn_obj_VAR_ = lv_obj_create(test_list);" in main_cpp
assert (
"lv_obj_t *dyn_label_1_VAR_ = lv_label_create(dyn_obj_VAR_);\n"
" lv_obj_add_style(dyn_label_1_VAR_, _lv_theme_style_label_main_default, "
"(lv_state_t)(LV_PART_MAIN));\n"
' lv_label_set_text(dyn_label_1_VAR_, "Nested");'
) in main_cpp
def test_add_applies_theme_styles_to_dynamic_widget(main_cpp: str) -> None:
"""A widget added via lvgl.list.add must pick up the same `theme:` styling a
statically-declared widget of the same type gets, not render unthemed.
"""
assert (
"lv_obj_add_style(dyn_label_1_VAR_, _lv_theme_style_label_main_default, "
"(lv_state_t)(LV_PART_MAIN));"
) in main_cpp
assert "LvDropdownType *dyn_dropdown_1_VAR_ = new LvDropdownType();" in main_cpp
assert "lv_dropdown_create(dyn_obj_VAR_)" in main_cpp
assert (
"lvgl::delete_lv_compound_on_delete<LvDropdownType>, LV_EVENT_DELETE, "
"dyn_dropdown_1_VAR_);"
) in main_cpp
def test_add_nested_same_type_child_does_not_shadow_parent(main_cpp: str) -> None:
"""A child of the same widget type as its parent (`obj: {widgets: [{obj:
...}]}`) must get a distinct C++ variable name (or the child's declaration
would shadow its own not-yet-initialized self, parenting it to garbage --
compiling clean but for a -Wuninitialized warning). A grandchild of a third
type proves depth, not just type, drives the disambiguating suffix.
"""
assert "lv_obj_t *dyn_obj_VAR_ = lv_obj_create(test_list);" in main_cpp
assert "lv_obj_t *dyn_obj_1_VAR_ = lv_obj_create(dyn_obj_VAR_);" in main_cpp
assert (
"lv_obj_t *dyn_label_2_VAR_ = lv_label_create(dyn_obj_1_VAR_);\n"
" lv_obj_add_style(dyn_label_2_VAR_, _lv_theme_style_label_main_default, "
"(lv_state_t)(LV_PART_MAIN));\n"
' lv_label_set_text(dyn_label_2_VAR_, "Grandchild");'
) in main_cpp
def test_add_moves_row_to_given_index_before_firing_on_add(main_cpp: str) -> None:
assert (
"lv_obj_move_to_index(dyn_obj_VAR_, 1);\n"
" triggerint_id->trigger(lvgl::lv_list_get_row_index(test_list, dyn_obj_VAR_));"
) in main_cpp
def test_on_add_fires_once_per_entry_via_shared_trigger(main_cpp: str) -> None:
"""A single on_add: automation means a single Trigger instance, reused by
every lvgl.list.add_text/add call site.
"""
assert main_cpp.count("triggerint_id->trigger(lvgl::lv_list_get_row_index(") == 5
def test_remove_guards_against_missing_child_and_fires_before_delete(
main_cpp: str,
) -> None:
"""The index is materialised into a local once (list_index_VAR_) and reused for
both the child lookup and the on_remove trigger, so a templatable index isn't
evaluated twice.
"""
assert (
"int list_index_VAR_ = 0;\n"
" {\n"
" lv_obj_t *list_child_VAR_ = lvgl::lv_list_get_row_for_remove(test_list, list_index_VAR_);\n"
" if (list_child_VAR_) {\n"
" triggerint_id_2->trigger(list_index_VAR_);\n"
" lv_obj_del(list_child_VAR_);"
) in main_cpp
def test_remove_out_of_range_lookup_uses_shared_cpp_helper(main_cpp: str) -> None:
"""The out-of-range lookup (and its log line) live in a single C++ helper --
lvgl::lv_list_get_row_for_remove() in lvgl_esphome.cpp -- rather than being
generated inline at every lvgl.list.remove call site, since a config can
have many of them and duplicating that logic (and its log string) at each
one would waste flash for no benefit.
"""
assert (
"lv_obj_t *list_child_VAR_ = lvgl::lv_list_get_row_for_remove(test_list, list_index_VAR_);"
in main_cpp
)
assert "ESP_LOGV" not in main_cpp
def test_clear_fires_on_remove_for_every_entry_then_cleans(main_cpp: str) -> None:
assert (
"for (int list_index = (int) (lv_obj_get_child_count(test_list)) - 1; "
"list_index >= 0; list_index--) {\n"
" triggerint_id_2->trigger(list_index);\n"
" }\n"
" lv_obj_clean(test_list);"
) in main_cpp
@@ -0,0 +1,47 @@
"""Regression test: on_add:/on_remove: containing an lvgl action must not deadlock.
ListType.to_code() used to build the on_add/on_remove automations directly, during
widget creation. Every lvgl action's to_code awaits wait_for_widgets(), which only
resolves once *all* widgets - including the list itself - have finished being
created. Building an automation containing an lvgl action from inside that same
widget-creation walk therefore could never complete: codegen deadlocked with
"Circular dependency detected!". Fixed by deferring the actual build_automation()
call to finish_list_triggers(), run after set_widgets_completed(True) - and,
critically, before generate_triggers(), which is what processes other widgets'
on_click etc. automations that might reference this list (e.g. via lvgl.list.add),
and which therefore need the list's own on_add/on_remove triggers to already exist.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
from esphome.core import CORE
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
config_path = (
Path(request.fspath).parent / "config" / "list_on_add_lvgl_action_test.yaml"
)
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_on_add_with_lvgl_action_does_not_deadlock(main_cpp: str) -> None:
assert 'lv_label_set_text(later_label, "changed");' in main_cpp
def test_on_remove_with_lvgl_action_does_not_deadlock(main_cpp: str) -> None:
assert 'lv_label_set_text(later_label, "removed");' in main_cpp
@@ -0,0 +1,84 @@
"""Regression test for lvgl.list.add called from outside the lvgl: block.
lv_list.py's list_add_to_code() must call _register_lv_uses() and
_register_dynamic_widget_style_uses() before its first await (get_widgets(),
which can block until the target list is defined) -- for an action referenced
outside the lvgl: block, that wait can outlast lvgl's own to_code, which reads
get_lv_uses()/get_styles_used() and flushes everything they drive (USE_LVGL_*
defines, plus add_lv_use(image)/screen-transparency/A8-draw-support triggered
by style properties) just once, near the end of its run. Every existing
list_test.yaml call site lives inside lvgl: widgets:, so neither ordering
requirement had any coverage.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
from esphome.core import CORE
@dataclass
class GeneratedOutput:
main_cpp: str
define_names: set[str]
lv_define_names: set[str]
@pytest.fixture(scope="module")
def generated(request: pytest.FixtureRequest) -> GeneratedOutput:
config_path = (
Path(request.fspath).parent / "config" / "list_outside_block_test.yaml"
)
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
# Copy out before CORE.reset() below clears these out from under us.
from esphome.components.lvgl import defines as df
return GeneratedOutput(
main_cpp=CORE.cpp_global_section + CORE.cpp_main_section,
define_names={d.name for d in CORE.defines},
lv_define_names=set(df.get_defines()),
)
finally:
CORE.config_path = original_path
CORE.reset()
def test_dynamic_widget_creates_correctly(generated: GeneratedOutput) -> None:
assert (
"lv_obj_t *dyn_switch_VAR_ = lv_switch_create(test_list);" in generated.main_cpp
)
def test_dynamic_widget_type_use_define_is_registered(
generated: GeneratedOutput,
) -> None:
"""The switch type is only ever referenced via the on_boot lvgl.list.add call
(never declared as a static widget), so USE_LVGL_SWITCH can only be present
if _register_lv_uses() ran in time for lvgl's own to_code to flush it.
"""
assert "USE_LVGL_SWITCH" in generated.define_names
assert "USE_LVGL_LIST" in generated.define_names
def test_dynamic_widget_style_use_defines_are_registered(
generated: GeneratedOutput,
) -> None:
"""bg_image_src/transform_rotation/drop_shadow_color are only ever set on
the dynamically-added switch (never on a static widget), so
USE_LVGL_IMAGE/LV_COLOR_SCREEN_TRANSP/LV_DRAW_SW_SUPPORT_A8 can only be
present if _register_dynamic_widget_style_uses() ran in time for lvgl's own
to_code to flush them.
"""
assert "USE_LVGL_IMAGE" in generated.define_names
assert "LV_COLOR_SCREEN_TRANSP" in generated.lv_define_names
assert "LV_DRAW_SW_SUPPORT_A8" in generated.lv_define_names
@@ -1,19 +1,27 @@
"""Schema-level config validation for custom_pdu and the deprecated custom_command alias.
"""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.
Most of these tests cover what the schema itself enforces (the two keys are mutually exclusive, and
custom_pdu takes byte-sized values). The last two reach the final-validate step that a bare-schema
test cannot: a write-coded custom_pdu polled continuously is rejected there.
"""
import pytest
from voluptuous import Invalid, MultipleInvalid
from esphome.components.modbus_controller import ModbusItemBaseSchema
from esphome.components.modbus_controller import (
ModbusItemBaseSchema,
validate_custom_pdu_item,
)
from esphome.components.modbus_controller.const import (
CONF_CUSTOM_COMMAND,
CONF_CUSTOM_PDU,
CONF_MODBUS_CONTROLLER_ID,
)
from esphome.config import Config
from esphome.const import CONF_ADDRESS, CONF_CONTINUOUS, CONF_ID
from esphome.core import ID
import esphome.final_validate as fv
def test_custom_command_accepted_at_schema_level() -> None:
@@ -45,3 +53,48 @@ 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]})
def _controller_full_config(*, continuous: bool) -> Config:
"""A minimal full-config graph with one modbus_controller declaring id 'ctl', enough for the
final-validate to resolve the controller (and its continuous flag) from an item's
modbus_controller_id."""
ctl_id = ID("ctl", is_declaration=True)
config = Config()
config["modbus_controller"] = [
{CONF_ID: ctl_id, CONF_ADDRESS: 1, CONF_CONTINUOUS: continuous}
]
config.declare_ids.append((ctl_id, ["modbus_controller", 0, CONF_ID]))
return config
@pytest.fixture
def reset_full_config():
token = fv.full_config.set(Config())
yield
fv.full_config.reset(token)
def test_continuous_write_custom_pdu_rejected(reset_full_config) -> None:
"""A write-coded custom_pdu (0x17 = read/write-multiple) under a continuous controller is
rejected at final validate: the hub would strip continuous from the mutating code and warn on
every update."""
fv.full_config.set(_controller_full_config(continuous=True))
with pytest.raises(Invalid, match="can't be polled continuously"):
validate_custom_pdu_item(
{
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
CONF_CUSTOM_PDU: [0x17, 0x00, 0x03, 0x00, 0x01],
}
)
def test_continuous_read_custom_pdu_allowed(reset_full_config) -> None:
"""A read-coded custom_pdu (0x03) under a continuous controller is fine - only writes stream."""
fv.full_config.set(_controller_full_config(continuous=True))
validate_custom_pdu_item(
{
CONF_MODBUS_CONTROLLER_ID: ID("ctl"),
CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01],
}
)
@@ -24,11 +24,9 @@ from tests.component_tests.types import SetCoreConfigCallable
@pytest.fixture(autouse=True)
def _clear_core_data():
"""Wipe CORE.data and reset fv.full_config so each test starts clean."""
"""Wipe CORE.data so each test starts clean."""
CORE.data.clear()
token = fv.full_config.set({})
yield
fv.full_config.reset(token)
CORE.data.clear()
@@ -0,0 +1,48 @@
"""non_blocking is family-gated at config validation; the CI build boards never compile
the ISR paths, so this gate is the only CI-reachable coverage for the platform matrix."""
import pytest
from esphome.components.libretiny.const import (
FAMILY_BK7231N,
FAMILY_BK7231T,
FAMILY_BK7238,
FAMILY_RTL8710B,
FAMILY_RTL8720C,
KEY_FAMILY,
KEY_LIBRETINY,
)
from esphome.components.remote_transmitter import _validate_non_blocking_platform
import esphome.config_validation as cv
from esphome.const import PlatformFramework
from esphome.core import CORE
from ..types import SetCoreConfigCallable
@pytest.mark.parametrize(
("platform_framework", "family", "accepted"),
[
(PlatformFramework.ESP32_IDF, None, True),
(PlatformFramework.RTL87XX_ARDUINO, FAMILY_RTL8720C, True),
(PlatformFramework.RTL87XX_ARDUINO, FAMILY_RTL8710B, False),
(PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7231N, True),
(PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7238, True),
(PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7231T, False),
(PlatformFramework.ESP8266_ARDUINO, None, False),
],
)
def test_non_blocking_platform_gate(
set_core_config: SetCoreConfigCallable,
platform_framework: PlatformFramework,
family: str | None,
accepted: bool,
) -> None:
set_core_config(platform_framework)
if family is not None:
CORE.data[KEY_LIBRETINY] = {KEY_FAMILY: family}
if accepted:
assert _validate_non_blocking_platform(True) is True
else:
with pytest.raises(cv.Invalid, match="non_blocking is only supported on"):
_validate_non_blocking_platform(True)
@@ -0,0 +1,45 @@
esphome:
name: rp2-trigger-codegen
on_boot:
then:
- rp2_ble_tracker.start_scan:
continuous: true
# Bare form: restores the configured scan_parameters mode — no
# set_continuous emitted (asserted in the codegen test).
- rp2_ble_tracker.start_scan:
- rp2_ble_tracker.stop_scan
rp2:
board: rpipicow
rp2_ble_tracker:
scan_parameters:
continuous: false
active: false
on_ble_advertise:
- mac_address:
- AC:37:43:77:5F:4C
- 11:22:33:44:55:66
then:
- lambda: 'char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; ESP_LOGD("t", "%s", x.address_str_to(addr));'
on_ble_service_data_advertise:
- service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
mac_address: AC:37:43:77:5F:4C
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
- service_uuid: ABCDABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
on_ble_manufacturer_data_advertise:
- manufacturer_id: ABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
- manufacturer_id: ABCDABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
- manufacturer_id: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
on_scan_end:
- then:
- lambda: 'ESP_LOGD("t", "end");'
@@ -0,0 +1,60 @@
"""Codegen tests for the tracker automations.
The shared trigger classes (ble_device_base/automation.h) are compiled by the
rp2040 compile fixtures, but the codegen accounting — the getattr-built setter
spellings, the single set_continuous pin and the listener-count define — is
only checkable from the generated main, mirroring the bk72xx/ln882h tests."""
from collections.abc import Callable
from pathlib import Path
import re
from esphome.components import ble_device_base
from tests.component_tests.helpers import get_define_value
def test_trigger_codegen(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("test_automations.yaml"))
# on_ble_advertise: multi-mac filter (two addresses in one initializer list)
assert "set_addresses({0xAC3743775F4CULL, 0x112233445566ULL})" in main_cpp
# 128-bit service uuid goes out reversed (BLE wire order); single-mac filter
assert (
"set_service_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB,"
"0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp
)
assert "set_address(0xAC3743775F4CULL)" in main_cpp
# 32-bit middle branch of the width dispatch
assert "set_service_uuid32(0xABCDABCDULL)" in main_cpp
# All three manufacturer widths: getattr() builds these names as strings,
# so a misspelling only ever fails here.
assert "set_manufacturer_uuid16(0xABCDULL)" in main_cpp
assert "set_manufacturer_uuid32(0xABCDABCDULL)" in main_cpp
assert (
"set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB,"
"0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp
)
# scan-control actions: templatable continuous lambda + parented actions.
# Exactly one set_continuous: the bare start_scan emits none, pinning the
# restore-configured-mode divergence from esp32 against a future default=.
assert main_cpp.count("->set_continuous(") == 1
assert "startscanaction_id->set_continuous(" in main_cpp
assert "stopscanaction_id->set_parent(" in main_cpp
# scan_parameters continuous: false reaches the YAML-mode setter, not the
# runtime override.
assert "->set_configured_continuous(false)" in main_cpp
# active: false (non-default) flows through to the setter.
assert "->set_scan_active(false)" in main_cpp
# Constructor call, not just the declaration: the parent argument is what
# registers the trigger as a listener.
assert re.search(
r"new\(\w+\) ble_device_base::BLEEndOfScanTrigger\(\w+\)", main_cpp
)
# Seven triggers register as listeners; an undercount silently drops the
# last trigger at runtime (StaticVector::push_back past capacity), so the
# define is the assertion that matters most.
assert get_define_value(ble_device_base.LISTENER_COUNT_DEFINE) == "7"
@@ -0,0 +1,15 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
time:
- platform: sntp
id: sntp_time
@@ -0,0 +1,21 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
logger:
time:
- platform: sntp
id: sntp_time
on_time:
- seconds: 0
then:
- logger.log: tick
@@ -0,0 +1,20 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
logger:
time:
- platform: sntp
id: sntp_time
on_time_sync:
then:
- logger.log: synced
@@ -0,0 +1,28 @@
"""automation.cpp (CronTrigger and SyncTrigger) is only compiled when an
on_time or on_time_sync automation exists, so the define must follow them."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.core import CORE
@pytest.mark.parametrize(
("fixture", "emits"),
[
("no_triggers.yaml", False),
("on_time.yaml", True),
("on_time_sync.yaml", True),
],
)
def test_triggers_define_follows_automations(
fixture: str,
emits: bool,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
generate_main(component_config_path(fixture))
defines = {define.name for define in CORE.defines}
assert ("USE_TIME_TRIGGERS" in defines) is emits
@@ -0,0 +1,20 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
time:
- platform: sntp
id: sntp_time
sensor:
- platform: uptime
name: Uptime Seconds
type: seconds
@@ -0,0 +1,20 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
time:
- platform: sntp
id: sntp_time
sensor:
- platform: uptime
name: Uptime Timestamp
type: timestamp
@@ -0,0 +1,27 @@
"""The timestamp uptime sensor source is only compiled when that type is used,
so the define must follow the configured sensor type rather than time: alone."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.core import CORE
@pytest.mark.parametrize(
("fixture", "emits"),
[
("seconds.yaml", False),
("timestamp.yaml", True),
],
)
def test_timestamp_define_follows_sensor_type(
fixture: str,
emits: bool,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
generate_main(component_config_path(fixture))
defines = {define.name for define in CORE.defines}
assert ("USE_UPTIME_TIMESTAMP" in defines) is emits
+8
View File
@@ -9,6 +9,14 @@ esphome:
event: esphome.button_pressed
data:
message: Button was pressed
- homeassistant.event:
event: esphome.button_pressed_with_variables
data_template:
message: Button {{ button_name }} ({{ button_index }}) was pressed from {{ button_source }}
variables:
button_name: !lambda 'return std::string("test_button");'
button_index: !lambda 'return 1;'
button_source: static_value
- homeassistant.action:
action: notify.html5
data:
+1
View File
@@ -4,4 +4,5 @@ media_source:
- platform: audio_http
id: audio_http_source
buffer_size: 100000
persistent_ring_buffer: true
task_stack_in_psram: true
+73
View File
@@ -0,0 +1,73 @@
#include <gtest/gtest.h>
#include "esphome/components/climate/climate.h"
namespace esphome::climate::testing {
// Minimal concrete Climate that offers a fixed set of modes, so the restore path can be exercised
// without any hardware or platform component.
class TestClimate : public Climate {
public:
ClimateTraits traits() override {
auto traits = ClimateTraits();
traits.set_supported_modes({CLIMATE_MODE_OFF, CLIMATE_MODE_COOL});
traits.set_supported_fan_modes({CLIMATE_FAN_LOW, CLIMATE_FAN_HIGH});
return traits;
}
protected:
void control(const ClimateCall &call) override {}
};
TEST(ClimateRestoreStateTest, RestoresASupportedMode) {
TestClimate climate;
// Value-initialized: several members (mode, swing_mode, the temperature union) have no default
// member initializer, so leaving the {} off would read indeterminate values.
ClimateDeviceRestoreState state{};
state.mode = CLIMATE_MODE_COOL;
state.apply(&climate);
EXPECT_EQ(climate.mode, CLIMATE_MODE_COOL);
}
TEST(ClimateRestoreStateTest, DoesNotRestoreAnUnsupportedMode) {
TestClimate climate;
ClimateDeviceRestoreState state{};
state.mode = CLIMATE_MODE_HEAT;
state.apply(&climate);
// The device never advertised HEAT, so the mode stays where it was.
EXPECT_EQ(climate.mode, CLIMATE_MODE_OFF);
}
TEST(ClimateRestoreStateTest, LeavesTheCurrentModeAloneRatherThanForcingOff) {
TestClimate climate;
// apply() is public and nothing restricts it to setup(), so the entity is not necessarily off
// when an unsupported mode is dropped. It keeps what it had rather than being forced to OFF.
climate.mode = CLIMATE_MODE_COOL;
ClimateDeviceRestoreState state{};
state.mode = CLIMATE_MODE_HEAT;
state.apply(&climate);
EXPECT_EQ(climate.mode, CLIMATE_MODE_COOL);
}
TEST(ClimateRestoreStateTest, KeepsRestoringTheOtherFieldsWhenTheModeIsDropped) {
TestClimate climate;
ClimateDeviceRestoreState state{};
state.mode = CLIMATE_MODE_HEAT;
state.target_temperature = 21.0f;
state.uses_custom_fan_mode = false;
state.fan_mode = CLIMATE_FAN_HIGH;
state.apply(&climate);
EXPECT_EQ(climate.mode, CLIMATE_MODE_OFF);
EXPECT_FLOAT_EQ(climate.target_temperature, 21.0f);
// Compared as an optional: this asserts both that the fan mode was restored and what it holds.
EXPECT_EQ(climate.fan_mode, CLIMATE_FAN_HIGH);
}
} // namespace esphome::climate::testing
@@ -12,5 +12,8 @@ climate:
- platform: climate_ir_lg
name: LG Climate
transmitter_id: xmitr
header_high: 3300us
header_low: 9840us
advanced_commands_support: true
sensor: climate_ir_lg_temp_sensor
humidity_sensor: humidity_sensor
@@ -57,6 +57,21 @@ sensor:
name: Power Factor 1
emontx_id: test_emontx
# Apparent power sensor (AP pattern): expects state_class=measurement,
# unit=VA, device_class=apparent_power, accuracy_decimals=2
- platform: emontx
tag_name: AP1
name: Apparent Power 1
emontx_id: test_emontx
# Frequency sensor (F, matched exactly, not as a prefix): expects
# state_class=measurement, unit=Hz, device_class=frequency,
# accuracy_decimals=2
- platform: emontx
tag_name: F
name: Frequency
emontx_id: test_emontx
# Unknown tag: no prefix match, falls back to state_class=measurement,
# accuracy_decimals=0
- platform: emontx
+1 -1
View File
@@ -7,7 +7,7 @@ esp32:
enable_lwip_mdns_queries: true
enable_lwip_bridge_interface: true
disable_libc_locks_in_iram: false # Test explicit opt-out of RAM optimization
use_full_certificate_bundle: false # Test CMN bundle (default)
use_full_certificate_bundle: false # Bundle stays off without a component that needs it
include_builtin_idf_components:
- freertos # Test escape hatch (freertos is always included anyway)
enable_full_printf: false
@@ -0,0 +1,17 @@
ethernet:
type: W5500
spi_id: spi_bus
cs_pin: 5
interrupt_pin: 36
reset_pin: 22
clock_speed: 10Mhz
manual_ip:
static_ip: 192.168.178.56
gateway: 192.168.178.1
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"
@@ -0,0 +1,3 @@
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
ethernet: !include common-w5500-spi-id.yaml
@@ -0,0 +1,72 @@
#include <gtest/gtest.h>
#include "esphome/components/hoermann_hcp/button/hoermann_hcp_button.h"
#include "../common.h"
namespace esphome::hoermann_hcp::testing {
// The intermediate positions are named in the second register, which repeats that name on release.
TEST(HoermannHcpButtonTest, VentButtonSendsTheVentCommand) {
TestableHoermannHcp door;
HoermannHcpVentButton vent(&door);
connect_controller(door);
vent.press();
auto [pressed, pressed_2] = poll_command(door);
EXPECT_EQ(pressed, 0x0200);
EXPECT_EQ(pressed_2, 0x4000);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
auto [released, released_2] = poll_command(door);
EXPECT_EQ(released, 0x0100);
EXPECT_EQ(released_2, 0x4000);
}
TEST(HoermannHcpButtonTest, HalfOpenButtonSendsTheHalfOpenCommand) {
TestableHoermannHcp door;
HoermannHcpHalfOpenButton half_open(&door);
connect_controller(door);
half_open.press();
auto [pressed, pressed_2] = poll_command(door);
EXPECT_EQ(pressed, 0x0200);
EXPECT_EQ(pressed_2, 0x0400);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
auto [released, released_2] = poll_command(door);
EXPECT_EQ(released, 0x0100);
EXPECT_EQ(released_2, 0x0400);
}
// The door drives to the vent position on its own, so a position the cover was still travelling to must not
// stop it on the way there.
TEST(HoermannHcpButtonTest, VentAbandonsAnArmedTarget) {
TestableHoermannHcp door; // starts out fully closed
HoermannHcpVentButton vent(&door);
connect_controller(door);
door.set_position(0.5f);
consume_command(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100}));
ASSERT_EQ(door.get_door_state(), DoorState::OPENING);
vent.press();
consume_command(door);
// Position 120/200 = 0.6 is past the abandoned target, which must no longer stop the door.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// A button carries no state, so a refused press is simply dropped rather than fired once the controller
// turns up, which could be much later.
TEST(HoermannHcpButtonTest, PressWithoutABusControllerSendsNothing) {
HoermannHcp door; // never contacted by a bus controller
HoermannHcpVentButton vent(&door);
vent.press();
EXPECT_EQ(poll_command(door).first, 0x0000);
}
} // namespace esphome::hoermann_hcp::testing
@@ -12,6 +12,13 @@ binary_sensor:
is_connected:
name: Garage Connected
button:
- platform: hoermann_hcp
vent:
name: Garage Vent
half_open:
name: Garage Half Open
light:
- platform: hoermann_hcp
name: Garage Light
+2 -2
View File
@@ -12,7 +12,7 @@ esphome:
data_template:
message: The humidity is {{ my_variable }}%.
variables:
my_variable: "return id(ha_hello_world_temperature).state;"
my_variable: !lambda "return id(ha_hello_world_temperature).state;"
- homeassistant.action:
action: notify.html5
data:
@@ -24,7 +24,7 @@ esphome:
data_template:
message: The humidity is {{ my_variable }}%.
variables:
my_variable: "return id(ha_hello_world_temperature).state;"
my_variable: !lambda "return id(ha_hello_world_temperature).state;"
wifi:
ssid: MySSID
+90
View File
@@ -1181,6 +1181,38 @@ lvgl:
- logger.log:
format: "bar value %f"
args: [x]
- table:
id: table_id
align: top_mid
y: 60
columns:
- width: 40%
- width: 80
rows:
- ["Name", "Value"]
- cells:
- text: "Temp"
merge_right: true
- text: "22.5"
text_crop: true
selected_row: 0
on_value:
then:
- logger.log:
format: "table selected row %u col %u"
args: [row, column]
on_click:
then:
- lvgl.table.cell.update:
id: table_id
row: 1
column: 1
text: !lambda return str_sprintf("%.1f", (float) rand() / RAND_MAX * 100);
merge_right: false
- lvgl.table.update:
id: table_id
selected_row: !lambda return (int) ((float) rand() / RAND_MAX * 2);
selected_column: 0
- line:
id: lv_line_id
align: center
@@ -1214,6 +1246,64 @@ lvgl:
id: checkbox_id
text: Checkbox
align: bottom_right
- list:
id: test_list_id
align: top_right
width: 150px
height: 120px
pad_row: 4
on_add:
- logger.log:
format: "list entry added at %d"
args: [list_index]
on_remove:
- logger.log:
format: "list entry removed at %d"
args: [list_index]
on_click:
- lvgl.list.add_text:
id: test_list_id
text: !lambda return "Section";
- lvgl.list.add_text:
id: test_list_id
text: "Pinned section"
index: 0
- lvgl.list.add:
id: test_list_id
button:
text: "Entry"
checkable: true
- lvgl.list.add:
id: test_list_id
index: 1
obj:
widgets:
- label:
text: !lambda return "Dynamic row " + std::to_string(millis());
- button:
widgets:
- label:
text: "Tap"
on_click:
- lambda: |-
ESP_LOGD("lvgl", "dynamic row button clicked, row %d",
lvgl::lv_list_get_row_index(id(test_list_id), static_cast<lv_obj_t *>(lv_event_get_target(event))));
- dropdown:
options:
- "One"
- "Two"
on_value:
- lambda: |-
ESP_LOGD("lvgl", "dynamic row dropdown changed, row %d",
lvgl::lv_list_get_row_index(id(test_list_id), static_cast<lv_obj_t *>(lv_event_get_target(event))));
- lvgl.list.remove:
id: test_list_id
index: 0
- lvgl.list.clear:
id: test_list_id
- lvgl.list.update:
id: test_list_id
pad_row: 8
- slider:
id: slider_id
align: top_mid
+73
View File
@@ -0,0 +1,73 @@
esphome:
name: lvgl-list-validate
host:
logger:
display:
- platform: sdl
id: sdl0
dimensions:
width: 320
height: 240
lvgl:
displays: sdl0
widgets:
# Two independent lists, each with their own on_add/on_remove and, for list_a,
# more than one automation under the same trigger key -- checks that the
# per-list trigger bookkeeping is keyed correctly and doesn't require exactly
# one automation.
- list:
id: validate_list_a
align: center
pad_row: 6
on_add:
- logger.log:
format: "a: added %d"
args: [list_index]
- logger.log:
format: "a: also added %d"
args: [list_index]
on_remove:
- logger.log:
format: "a: removed %d"
args: [list_index]
on_boot:
# lvgl.list.add_text and lvgl.list.add both take an optional, templatable index.
- lvgl.list.add_text:
id: validate_list_a
text: "Header"
index: !lambda return 0;
# any registered widget type is valid as the single lvgl.list.add key.
- lvgl.list.add:
id: validate_list_a
checkbox:
align: center
text: "Option"
- lvgl.list.add:
id: validate_list_a
index: !lambda return 0;
switch:
align: center
- lvgl.list.add:
id: validate_list_a
spinner:
align: center
- lvgl.list.add:
id: validate_list_a
obj:
align: center
- lvgl.list.remove:
id: validate_list_a
index: !lambda return 0;
- lvgl.list.clear:
id: validate_list_a
- list:
id: validate_list_b
align: center
on_remove:
- logger.log:
format: "b: removed %d"
args: [list_index]
@@ -15,11 +15,10 @@ struct MitsubishiCN105ClimateTestContext {
TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpectedValues) {
MitsubishiCN105ClimateTestContext context;
const auto mapping = TemperatureMapping();
for (int temperature = 16; temperature <= 31; ++temperature) {
EXPECT_EQ(mapping.to_mitsubishi(temperature), temperature);
EXPECT_EQ(mapping.from_mitsubishi(temperature), temperature);
EXPECT_EQ(context.component.get_temperature_mapping().to_mitsubishi(temperature), temperature);
EXPECT_EQ(context.component.get_temperature_mapping().from_mitsubishi(temperature), temperature);
}
const auto traits = context.sut.traits();
@@ -32,8 +31,6 @@ TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpecte
TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingAndTraitsMatchExpectedValues) {
MitsubishiCN105ClimateTestContext context;
auto mapping = TemperatureMapping();
mapping.set_use_fahrenheit(true);
context.component.set_use_fahrenheit(true);
const std::array cases{
@@ -46,8 +43,8 @@ TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingAndTraitsMatchExpe
};
for (const auto &[fahrenheit, mitsubishi_celsius] : cases) {
EXPECT_FLOAT_EQ(mapping.to_mitsubishi(fahrenheit), mitsubishi_celsius);
EXPECT_FLOAT_EQ(mapping.from_mitsubishi(mitsubishi_celsius), fahrenheit);
EXPECT_FLOAT_EQ(context.component.get_temperature_mapping().to_mitsubishi(fahrenheit), mitsubishi_celsius);
EXPECT_FLOAT_EQ(context.component.get_temperature_mapping().from_mitsubishi(mitsubishi_celsius), fahrenheit);
}
const auto traits = context.sut.traits();
EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::FAHRENHEIT);
@@ -42,11 +42,17 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
// All bytes from UART should be consumed
EXPECT_TRUE(ctx.uart.rx.empty());
// After successful connect we request status, first settings (0x02)
// Defer the first settings request (0x02) until the next update.
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::DEFERRED_STATUS_REQUEST);
EXPECT_TRUE(ctx.uart.tx.empty());
ctx.sut.set_current_time(201);
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7B));
EXPECT_EQ(ctx.sut.operation_start_ms_, 200);
EXPECT_EQ(ctx.sut.operation_start_ms_, 201);
// Clear TX bytes.
ctx.uart.tx.clear();
@@ -75,15 +81,24 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
EXPECT_EQ(ctx.sut.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_4);
EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::SWING);
// Now fetch telemetry (0x03)
// Defer the telemetry request (0x03) until the next update.
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::DEFERRED_STATUS_REQUEST);
EXPECT_TRUE(ctx.uart.tx.empty());
ctx.sut.set_current_time(301);
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7A));
EXPECT_EQ(ctx.sut.operation_start_ms_, 300);
EXPECT_EQ(ctx.sut.operation_start_ms_, 301);
// Clear TX bytes.
ctx.uart.tx.clear();
// Queue a setting while waiting for telemetry.
ctx.sut.set_power(true);
// Telemetry response
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x0B, 0x00, 0x00,
0xAA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xA5});
@@ -103,6 +118,13 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
EXPECT_EQ(ctx.sut.operation_start_ms_, 400);
// Apply the pending setting on the next update, outside RX processing.
ctx.sut.set_current_time(401);
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
EXPECT_FALSE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.operation_start_ms_, 401);
}
TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) {
@@ -469,6 +491,36 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) {
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
}
TEST(MitsubishiCN105Tests, PendingSettingsTakePriorityOverDueTelemetry) {
MitsubishiCN105TestsContext ctx;
ctx.sut.status_.target_temperature = 24.0f;
ctx.sut.status_.room_temperature = 21.0f;
ASSERT_TRUE(ctx.sut.is_status_initialized());
ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED;
ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE);
ctx.sut.set_current_time(1000);
ASSERT_FALSE(ctx.sut.update());
ASSERT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
ctx.uart.tx.clear();
ctx.sut.set_power(true);
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x08, 0x07,
0x00, 0x04, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3C});
ctx.sut.set_current_time(1001);
ASSERT_TRUE(ctx.sut.update());
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
ctx.sut.set_current_time(1002);
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7B));
}
TEST(MitsubishiCN105Tests, SetAndClearRemoteRoomTemp) {
MitsubishiCN105TestsContext ctx;
@@ -13,7 +13,7 @@ namespace esphome::modbus_controller::testing {
// malformed. Built at its true byte count, the oversize frame is refused by the hub's size check with
// a log instead.
TEST(ModbusCommandPayload, CoilWritePayloadIsExactSizedNotTruncated) {
ModbusController controller;
ModbusController controller(nullptr, 1);
std::vector<bool> coils(modbus::MAX_NUM_OF_COILS_TO_WRITE + 1, true);
auto cmd = ModbusCommandItem::create_write_multiple_coils(&controller, 0x10, coils);
EXPECT_EQ(cmd.payload.size(), modbus::packed_bit_bytes(coils.size()));
@@ -21,7 +21,7 @@ TEST(ModbusCommandPayload, CoilWritePayloadIsExactSizedNotTruncated) {
// LSB-first packing with zeroed pad bits, matching the wire layout the PDU builders produce.
TEST(ModbusCommandPayload, CoilWritePacksLsbFirstWithZeroPad) {
ModbusController controller;
ModbusController controller(nullptr, 1);
const std::vector<bool> coils{true, false, true, true};
auto cmd = ModbusCommandItem::create_write_multiple_coils(&controller, 0x10, coils);
ASSERT_EQ(cmd.payload.size(), 1u);
@@ -2,6 +2,7 @@ modbus_controller:
- id: modbus_controller1
address: 0x2
modbus_id: modbus_bus
continuous: true
on_online:
then:
logger.log: "Module Online"
@@ -2,6 +2,7 @@ remote_transmitter:
id: xmitr
pin: GPIO26
carrier_duty_percent: 50%
# non_blocking is bk7231n/bk7238-only; the CI board is a BK7252
packages:
buttons: !include common-buttons.yaml
@@ -2,6 +2,7 @@ remote_transmitter:
id: xmitr
pin: GPIO12
carrier_duty_percent: 50%
# non_blocking is rtl8720c-only; the CI board is an RTL8710B
packages:
buttons: !include common-buttons.yaml
@@ -0,0 +1,54 @@
esphome:
on_boot:
then:
- rp2_ble_tracker.start_scan
- rp2_ble_tracker.start_scan:
continuous: true
# Lambda arm of the templatable value — different codegen instantiation.
- rp2_ble_tracker.start_scan:
continuous: !lambda return false;
- rp2_ble_tracker.stop_scan
- rp2_ble_tracker.stop_scan: ble_tracker
rp2_ble_tracker:
on_ble_advertise:
- mac_address: AC:37:43:77:5F:4C
then:
- lambda: |-
char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
ESP_LOGD("main", "The device address is %s", x.address_str_to(addr));
- mac_address:
- AC:37:43:77:5F:4C
- AC:37:43:77:5F:4D
then:
- lambda: |-
char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
ESP_LOGD("main", "The device address is %s", x.address_str_to(addr));
on_ble_service_data_advertise:
- service_uuid: ABCD
# mac_address exercises the UUID triggers' set_address() codegen branch.
mac_address: AC:37:43:77:5F:4C
then:
- lambda: |-
ESP_LOGD("main", "Length of service data is %zu", x.size());
- service_uuid: ABCDABCD
then:
- lambda: |-
ESP_LOGD("main", "32-bit service data is %zu", x.size());
- service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
then:
- lambda: |-
ESP_LOGD("main", "128-bit service data is %zu", x.size());
on_ble_manufacturer_data_advertise:
- manufacturer_id: ABCD
then:
- lambda: |-
ESP_LOGD("main", "Length of manufacturer data is %zu", x.size());
- manufacturer_id: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
then:
- lambda: |-
ESP_LOGD("main", "128-bit manufacturer data is %zu", x.size());
on_scan_end:
- then:
- lambda: |-
ESP_LOGD("main", "Scan ended");
@@ -0,0 +1,3 @@
packages:
rp2_ble_tracker: !include common.yaml
automations: !include common-automations.yaml
@@ -0,0 +1,61 @@
#include <gtest/gtest.h>
#include <optional>
#include "esphome/components/runtime_image/runtime_image.h"
namespace esphome::runtime_image::testing {
TEST(RuntimeImageMime, FormatForKnownMimeTypes) {
EXPECT_EQ(get_format_for_mime_type("image/bmp"), BMP);
EXPECT_EQ(get_format_for_mime_type("image/x-ms-bmp"), BMP);
EXPECT_EQ(get_format_for_mime_type("image/x-bmp"), BMP);
EXPECT_EQ(get_format_for_mime_type("image/png"), PNG);
EXPECT_EQ(get_format_for_mime_type("image/x-png"), PNG);
#ifdef USE_RUNTIME_IMAGE_JPEG
EXPECT_EQ(get_format_for_mime_type("image/jpeg"), JPEG);
EXPECT_EQ(get_format_for_mime_type("image/jpg"), JPEG);
#endif // USE_RUNTIME_IMAGE_JPEG
}
TEST(RuntimeImageMime, FormatMatchingIsCaseInsensitive) {
EXPECT_EQ(get_format_for_mime_type("Image/PNG"), PNG);
EXPECT_EQ(get_format_for_mime_type("IMAGE/BMP"), BMP);
}
TEST(RuntimeImageMime, FormatMatchesContentTypeWithParameters) {
// Content-Type headers may carry parameters after the media type
EXPECT_EQ(get_format_for_mime_type("image/png; charset=binary"), PNG);
EXPECT_EQ(get_format_for_mime_type("image/bmp;name=\"a.bmp\""), BMP);
}
TEST(RuntimeImageMime, UnknownMimeTypeHasNoFormat) {
EXPECT_EQ(get_format_for_mime_type("text/html"), std::nullopt);
EXPECT_EQ(get_format_for_mime_type("application/octet-stream"), std::nullopt);
EXPECT_EQ(get_format_for_mime_type("image/*"), std::nullopt);
EXPECT_EQ(get_format_for_mime_type(""), std::nullopt);
EXPECT_EQ(get_format_for_mime_type(nullptr), std::nullopt);
}
TEST(RuntimeImageMime, MimeTypeForFormatRoundTrip) {
EXPECT_STREQ(get_mime_type_for_format(BMP), "image/bmp");
EXPECT_STREQ(get_mime_type_for_format(PNG), "image/png");
#ifdef USE_RUNTIME_IMAGE_JPEG
EXPECT_STREQ(get_mime_type_for_format(JPEG), "image/jpeg");
#endif // USE_RUNTIME_IMAGE_JPEG
// AUTO has no single MIME type and falls back to the wildcard
EXPECT_STREQ(get_mime_type_for_format(AUTO), "image/*");
// Every decodable format must resolve back to itself through its MIME type
for (ImageFormat format : {
BMP,
PNG,
#ifdef USE_RUNTIME_IMAGE_JPEG
JPEG,
#endif // USE_RUNTIME_IMAGE_JPEG
}) {
EXPECT_EQ(get_format_for_mime_type(get_mime_type_for_format(format)), format) << format;
}
}
} // namespace esphome::runtime_image::testing
+12
View File
@@ -0,0 +1,12 @@
sensor:
- platform: sfa40
i2c_id: i2c_bus
wait_for_ready: false
formaldehyde:
name: SFA40 formaldehyde
temperature:
name: SFA40 temperature
humidity:
name: SFA40 humidity
address: 0x5D
update_interval: 30s
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
sfa40: !include common.yaml
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
sfa40: !include common.yaml
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
sfa40: !include common.yaml
@@ -0,0 +1,9 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
sensor:
- platform: sfa40
i2c_id: i2c_bus
wait_for_ready: true
formaldehyde:
name: SFA40 formaldehyde
+1 -1
View File
@@ -4,6 +4,6 @@ from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code(config):
cg.add_build_flag("-DUSE_TIME_TIMEZONE")
cg.add_define("USE_TIME_TIMEZONE")
manifest.to_code = to_code
+3
View File
@@ -41,3 +41,6 @@ number:
min_value: 2
max_value: 100
step: 1
time:
- platform: zigbee
@@ -10,6 +10,3 @@ zigbee:
on_start:
then:
- logger.log: "Started zigbee stack"
time:
- platform: zigbee
@@ -5,3 +5,6 @@ zigbee:
on_join:
then:
- logger.log: "Joined network"
time:
- platform: zigbee
@@ -0,0 +1,115 @@
esphome:
name: uart-mock-modbus-continuous
host:
api:
logger:
level: VERBOSE
# When set, the mock server stops forwarding its replies to the controller, so the controller sees
# timeouts - used by the recovery test to drive a live continuous poll offline and back.
globals:
- id: silence_server
type: bool
initial_value: "false"
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- if:
condition:
lambda: "return !id(silence_server);"
then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
# Short timeout so the recovery test drives the poll offline quickly; when the server answers,
# replies arrive within turnaround_time, so this does not slow the streaming path.
send_wait_time: 100ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
# A long update_interval means that without continuous polling only the boot poll would run in the
# test window. continuous: true re-queues the read after each success, so it streams as fast as the
# bus allows.
update_interval: 30s
continuous: true
# One retry so a silenced device trips offline fast (initial send + 1 retry, each 100ms).
max_cmd_retries: 1
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
# Each read returns the next counter value, so every poll publishes a distinct state the test can
# count (proving the read actually ran, not just that the state changed once).
- address: 0x01
value_type: U_WORD
read_lambda: |-
static uint16_t counter = 0;
return counter++;
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "continuous_reg"
address: 0x01
register_type: holding
value_type: U_WORD
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# Trigger the first poll deterministically. PollingComponent's first update() would otherwise land
# somewhere in the 30s update_interval; once this one read completes, continuous re-queuing takes over.
on_press:
- lambda: "id(modbus_controller_1)->update();"
switch:
# Toggles whether the mock server forwards its replies. On = silence (controller sees timeouts);
# off = answer again. The recovery test uses it to drive a live continuous poll offline and back.
- platform: template
name: "Silence Server"
id: silence_server_switch
optimistic: true
turn_on_action:
- lambda: "id(silence_server) = true;"
turn_off_action:
- lambda: "id(silence_server) = false;"
@@ -0,0 +1,106 @@
esphome:
name: uart-mock-modbus-dep-buffer
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: reg10
type: uint16_t
initial_value: "0"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x10
value_type: U_WORD
read_lambda: return id(reg10);
write_lambda: |-
id(reg10) = x;
return true;
# A number whose write_lambda uses the DEPRECATED buffer parameter (fills `payload` with a legacy raw
# frame as words: device address + function code + data) instead of the new item->write_* API. The write
# must still land with its legacy semantics, and the one-time deprecation warning must fire only once per
# entity no matter how many writes happen.
number:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "buf_number"
id: buf_number
address: 0x10
register_type: holding
value_type: U_WORD
min_value: 0
max_value: 1000
step: 1
write_lambda: |-
// Legacy raw frame as words: [addr 0x01 | fc 0x06], register 0x0010, value.
payload.push_back(0x0106);
payload.push_back(0x0010);
payload.push_back((uint16_t) x);
return {};
# Reports the server-side register so the test can observe that the deprecated buffer write landed.
sensor:
- platform: template
name: "written_value"
id: written_value
update_interval: 0.5s
lambda: "return id(reg10);"
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# The test drives the writes via number_command; the mock is autostart.
@@ -0,0 +1,97 @@
esphome:
name: uart-mock-modbus-lambda-write
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: reg30
type: uint16_t
initial_value: "0"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x30
value_type: U_WORD
read_lambda: return id(reg30);
write_lambda: id(reg30) = x; return true;
# A COIL-type switch (assumed_state, write-only) whose write_lambda ignores its own coil type and instead
# drives a HOLDING-REGISTER write on the mock server through the entity itself: `item` IS the command, so
# item->write_single_register() sends a register write from a coil entity (cross-type). Returning nothing
# (an empty optional) tells the write path the lambda already dispatched the frame - no default coil write.
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "cross_switch"
register_type: coil
address: 0x00
assumed_state: true
write_lambda: |-
item->write_single_register(0x30, x ? 1234 : 0);
return {};
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_30"
address: 0x30
register_type: holding
value_type: U_WORD
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
@@ -0,0 +1,138 @@
esphome:
name: uart-mock-modbus-reg-offset
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
# The actual UART bus used is the uart_mock component below
uart:
baud_rate: 115200
port: /dev/null
uart_mock:
- id: virtual_uart_server
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_controller
data: !lambda return data;
- id: virtual_uart_controller
baud_rate: 9600
auto_start: true
debug:
on_tx:
- then:
- uart_mock.inject_rx:
id: virtual_uart_server
data: !lambda return data;
globals:
- id: reg10
type: uint16_t
initial_value: "100"
- id: reg11
type: uint16_t
initial_value: "200"
- id: reg12
type: uint16_t
initial_value: "300"
- id: reg13
type: uint16_t
initial_value: "0xABCD"
modbus:
- uart_id: virtual_uart_server
id: virtual_modbus_server
role: server
- uart_id: virtual_uart_controller
id: virtual_modbus_controller
role: client
turnaround_time: 10ms
modbus_controller:
- address: 1
modbus_id: virtual_modbus_controller
id: modbus_controller_1
update_interval: 1s
modbus_server:
- address: 1
modbus_id: virtual_modbus_server
id: modbus_server_1
registers:
- address: 0x10
value_type: U_WORD
read_lambda: return id(reg10);
write_lambda: id(reg10) = x; return true;
- address: 0x11
value_type: U_WORD
read_lambda: return id(reg11);
write_lambda: id(reg11) = x; return true;
- address: 0x12
value_type: U_WORD
read_lambda: return id(reg12);
write_lambda: id(reg12) = x; return true;
- address: 0x13
value_type: U_WORD
read_lambda: return id(reg13);
write_lambda: id(reg13) = x; return true;
# A holding-register switch at 0x10 with a 2-BYTE offset. offset is byte-based, so the write must target
# register 0x10 + 2/2 = 0x11. The old (pre-fix) behavior folded offset into the address as a register
# count, hitting 0x12 instead. assumed_state keeps the switch write-only so it does not read any register.
switch:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "offset_switch"
register_type: holding
address: 0x10
offset: 2
assumed_state: true
# A holding-register switch that READS its state. Byte offset 6 -> register 0x10 + 6/2 = 0x13. Post-fix
# the switch itself resolves to 0x13 (whole registers fold into the address, residual byte stays) and
# joins the 0x10..0x13 range, so no separate 0x13 sensor is needed. Pre-fix the whole byte offset folds
# into the address (0x16), where the server answers ILLEGAL_DATA_ADDRESS and the switch never publishes.
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "read_offset_switch"
register_type: holding
address: 0x10
offset: 6
bitmask: 0x1
sensor:
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_10"
address: 0x10
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_11"
address: 0x11
register_type: holding
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller_1
name: "reg_12"
address: 0x12
register_type: holding
value_type: U_WORD
button:
- platform: template
name: "Start Scenario"
id: start_scenario_btn
# This test does not have anything to start (mock is autostart)
+208 -1
View File
@@ -24,7 +24,7 @@ from dataclasses import dataclass
from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo
import pytest
from .state_utils import SensorTracker, find_entity
from .state_utils import SensorTracker, find_entity, wait_for_state
from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -736,6 +736,68 @@ async def test_uart_mock_modbus_custom_pdu(
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.asyncio
async def test_uart_mock_modbus_continuous(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that `continuous: true` polls faster than the update_interval.
The controller's update_interval is 30s, so without continuous polling only the boot poll would
run during the short test window. With continuous the read is re-queued after each success, filling
idle bus time, so many reads arrive. The server returns an incrementing counter, so every read is a
distinct published state the tracker can count. (Bus warnings are not asserted here: continuous
polling deliberately saturates the bus, so the occasional timing hiccup is expected and off-topic;
the other tests cover clean operation at normal poll rates.)
"""
tracker = SensorTracker(["continuous_reg"])
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
# setup_and_start_scenario presses the Start Scenario button, whose on_press triggers the
# controller's first update(). With continuous that one read re-queues and streams; without it
# the next poll would not run until the 30s update_interval elapses.
entities = await tracker.setup_and_start_scenario(client)
# Count reads over a window far shorter than the update_interval. Absent continuous polling we
# would see ~1 (the triggered poll); continuous re-queues, so the bus fills with reads.
await asyncio.sleep(3.0)
reads = len(tracker.sensor_states["continuous_reg"])
assert reads >= 5, (
"expected many continuous reads within the window (update_interval is 30s, so absent "
f"continuous polling we would see ~1), got {reads}"
)
# Recovery path: a live continuous poll that starts failing goes offline, and the next update()
# re-arms it once the device answers again. Silence the server so the poll's reads time out; with
# max_cmd_retries=1 and send_wait_time=100ms the device trips offline quickly and streaming stops.
silence = find_entity(entities, "silence_server", SwitchInfo)
assert silence is not None, "Silence Server switch not found"
start = find_entity(entities, "start_scenario", ButtonInfo)
assert start is not None, "Start Scenario button not found"
client.switch_command(silence.key, True)
await asyncio.sleep(1.0) # let the poll fail and the device trip offline
plateau = len(tracker.sensor_states["continuous_reg"])
await asyncio.sleep(1.0) # offline: no polls should land
assert len(tracker.sensor_states["continuous_reg"]) == plateau, (
"reads kept arriving after the server was silenced - the failed continuous poll did not stop"
)
# Answer again and trigger update(): the offline probe recovers the device and the continuous
# poll re-arms, so streaming resumes.
client.switch_command(silence.key, False)
client.button_command(start.key)
await asyncio.sleep(3.0)
resumed = len(tracker.sensor_states["continuous_reg"]) - plateau
assert resumed >= 5, (
f"continuous polling did not resume after the device recovered (got {resumed} new reads)"
)
@pytest.mark.asyncio
async def test_uart_mock_modbus_offline(
yaml_config: str,
@@ -903,3 +965,148 @@ async def test_uart_mock_modbus_client_read_write(
await tracker.setup_and_start_scenario(client)
await tracker.await_all(futures)
_assert_no_modbus_errors(error_log_lines, warning_log_lines)
@pytest.mark.xfail(
strict=True,
reason="Byte-accurate register-offset writes land in the follow-up offset fix; "
"until then the byte offset is folded into the address (writes 0x12 instead of "
"0x11). The write and read assertions both flip via the same switch-constructor "
"fold. Remove this marker when that change merges.",
)
@pytest.mark.asyncio
async def test_uart_mock_modbus_register_offset(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that a byte offset on a holding-register write is byte-accurate.
`offset` is a byte offset, so a holding-register write at address 0x10 with offset: 2 must target
register 0x10 + 2/2 = 0x11. The pre-fix behavior folded the byte offset into the address as a register
count (0x10 + 2 = 0x12). The switch is assumed_state (write-only), so reg_11 turning 0xFFFF pins the
fix; had the write landed on 0x12 the wait would time out and reg_12 would change instead.
"""
tracker = SensorTracker(["reg_10", "reg_11", "reg_12"])
initial = tracker.expect_all({"reg_10": 100, "reg_11": 200, "reg_12": 300})
wrote_11 = tracker.expect("reg_11", 65535)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities = await tracker.setup_and_start_scenario(client)
await tracker.await_all(initial, timeout=4.0)
switch = find_entity(entities, "offset_switch", SwitchInfo)
assert switch is not None, "offset_switch not found"
client.switch_command(switch.key, True)
# reg_11 (0x10 + offset 2/2) must receive the write; if the write went to 0x12 this times out.
await tracker.await_change(wrote_11, "reg_11", timeout=4.0)
# And 0x12 (the pre-fix register-offset target) must be untouched.
assert tracker.sensor_states["reg_12"][-1] == 300, (
"reg_12 (0x12) should be untouched - offset is byte-based, so the write targets 0x11; "
f"got {tracker.sensor_states['reg_12']}"
)
# Read path: read_offset_switch has byte offset 6. Post-fix the switch folds the whole registers
# into its address (0x10 + 6/2 = 0x13, residual byte 0) and joins the 0x10..0x13 range, so the
# read lands in-bounds on 0xABCD (bit 0 set) -> ON. Pre-fix the whole byte offset folded into the
# address (0x16); the server answers ILLEGAL_DATA_ADDRESS there and the switch never publishes.
read_switch = find_entity(entities, "read_offset_switch", SwitchInfo)
assert read_switch is not None, "read_offset_switch not found"
# The ON transition happened at the first poll and switch states are deduped, so this relies on
# wait_for_state's fresh subscribe_states re-dumping every entity's current state.
await wait_for_state(
client,
lambda s: (
getattr(s, "key", None) == read_switch.key
and getattr(s, "state", None) is True
),
timeout=6.0,
)
@pytest.mark.asyncio
async def test_uart_mock_modbus_lambda_write(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test a write_lambda that drives the write through the entity itself (item is the command).
`cross_switch` is a coil-type switch whose write_lambda ignores its own type and calls
item->write_single_register(0x30, ...) - a register write issued from a coil entity. The lambda
returns an empty optional, so the write path detects the lambda already dispatched a frame and does
not fall back to the default coil write. Success is reg_30 reading back the value the lambda wrote,
which proves both the new item->write_* path and cross-type flexibility.
"""
tracker = SensorTracker(["reg_30"])
initial = tracker.expect("reg_30", 0)
wrote_30 = tracker.expect("reg_30", 1234)
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
entities = await tracker.setup_and_start_scenario(client)
await tracker.await_change(initial, "reg_30", timeout=4.0)
switch = find_entity(entities, "cross_switch", SwitchInfo)
assert switch is not None, "cross_switch not found"
client.switch_command(switch.key, True)
# The coil switch's lambda wrote register 0x30 via item->write_single_register(); reg_30 must
# read back 1234. If the entity-as-command dispatch were broken, no register write would go out
# and this would time out.
await tracker.await_change(wrote_30, "reg_30", timeout=4.0)
@pytest.mark.asyncio
async def test_uart_mock_modbus_deprecated_write_buffer(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test the deprecated write_lambda buffer path still works, and warns once per entity.
buf_number's write_lambda fills the old `payload` buffer with a legacy raw frame as words (device
address + function code + data) and returns {} instead of calling item->write_*. Both writes must
land - a filled buffer is sent, as the docs have always described - and the one-time deprecation
warning must fire exactly once per entity regardless of how many writes happen.
"""
warn_count = 0
def line_callback(line: str) -> None:
nonlocal warn_count
if "write_lambda buffer" in line:
warn_count += 1
tracker = SensorTracker(["written_value"])
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
entities = await tracker.setup_and_start_scenario(client)
number = find_entity(entities, "buf_number", NumberInfo)
assert number is not None, "buf_number not found"
# First write via the deprecated buffer path.
client.number_command(number.key, 111)
await tracker.await_change(
tracker.expect("written_value", 111), "written_value", timeout=4.0
)
# Second write: lands too, but must not warn again (warn-once per entity).
client.number_command(number.key, 222)
await tracker.await_change(
tracker.expect("written_value", 222), "written_value", timeout=4.0
)
assert warn_count == 1, (
f"deprecation warning should fire exactly once per entity, got {warn_count}"
)
+50
View File
@@ -1214,6 +1214,56 @@ def test_count_changed_cpp_files_with_branch() -> None:
mock_changed.assert_called_once_with("release")
@pytest.mark.parametrize(
("changed_files", "expected"),
[
# Core C++ change runs everything
(["esphome/core/helpers.cpp"], (True, [])),
# Core Python change runs everything too
(["esphome/core/config.py"], (True, [])),
# Component C++ change: component plus dependents with C++ tests
(["esphome/components/time/posix_tz.cpp"], (False, ["sntp", "time"])),
# Component Python change shapes the host build (defines, source
# filters), so it must trigger the same tests as a C++ change
(["esphome/components/time/__init__.py"], (False, ["sntp", "time"])),
# Nothing to build when no selected component has C++ tests
(["esphome/components/homeassistant/__init__.py"], (False, [])),
# Test manifest override changes only that component
(["tests/components/time/__init__.py"], (False, ["time"])),
# Test source change only that component
(["tests/components/time/posix_tz.cpp"], (False, ["time"])),
# pytest files and YAML build tests do not affect the test binary
(["tests/components/socket/conftest.py"], (False, [])),
(["tests/components/time/test.esp32-idf.yaml"], (False, [])),
(["README.md", "script/helpers.py"], (False, [])),
([], (False, [])),
],
)
def test_determine_cpp_unit_tests(
changed_files: list[str],
expected: tuple[bool, list[str]],
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test which C++ unit tests a set of changed files selects."""
tests_dir = tmp_path / "tests" / "components"
for component in ("time", "sntp"):
(tests_dir / component).mkdir(parents=True)
(tests_dir / component / f"{component}.cpp").write_text("")
(tests_dir / "homeassistant").mkdir()
(tests_dir / "socket").mkdir()
monkeypatch.setattr(helpers, "root_path", str(tmp_path))
with (
patch.object(determine_jobs, "changed_files", return_value=changed_files),
patch.object(
helpers,
"create_components_graph",
return_value={"time": ["homeassistant", "sntp"]},
),
):
assert determine_jobs.determine_cpp_unit_tests() == expected
def test_main_filters_components_without_tests(
mock_determine_integration_tests: Mock,
mock_should_run_clang_tidy: Mock,
+89
View File
@@ -2031,3 +2031,92 @@ def test_get_changed_files_from_command_gh_failure_keeps_stderr() -> None:
pytest.raises(Exception, match="maximum number of changed files"),
):
_get_changed_files_from_command(["gh", "pr", "diff", "123", "--name-only"])
@pytest.mark.parametrize(
("file_path", "expected"),
[
("esphome/components/time/posix_tz.cpp", True),
("esphome/components/time/posix_tz.h", True),
("esphome/components/time/__init__.py", True),
("esphome/components/sntp/time.py", True),
("tests/components/time/posix_tz.cpp", True),
("tests/components/time/__init__.py", True),
# Platform override: tests/components/<component>/<domain>/__init__.py
("tests/components/template/sensor/__init__.py", True),
# pytest-only files do not shape the C++ test binary
("tests/components/socket/conftest.py", False),
("tests/components/socket/test_socket.py", False),
("tests/components/time/test.esp32-idf.yaml", False),
("esphome/core/time.cpp", False),
("esphome/config.py", False),
("script/helpers.py", False),
("README.md", False),
],
)
def test_filter_cpp_unit_test_files(file_path: str, expected: bool) -> None:
"""Test which changed files can affect a component's C++ unit test build."""
assert helpers.filter_cpp_unit_test_files(file_path) is expected
@pytest.fixture
def cpp_unit_test_tree(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Fake repo root where time, sntp and api have C++ unit tests.
homeassistant depends on time but has no C++ tests, so it must be
dropped from the selection; socket has only pytest files.
"""
tests_dir = tmp_path / "tests" / "components"
for component in ("time", "sntp", "api"):
(tests_dir / component).mkdir(parents=True)
(tests_dir / component / f"{component}.cpp").write_text("")
(tests_dir / "homeassistant").mkdir()
(tests_dir / "homeassistant" / "__init__.py").write_text("")
(tests_dir / "socket").mkdir()
(tests_dir / "socket" / "conftest.py").write_text("")
monkeypatch.setattr(helpers, "root_path", str(tmp_path))
monkeypatch.setattr(
helpers,
"create_components_graph",
lambda: {"time": ["homeassistant", "sntp"]},
)
return tmp_path
@pytest.mark.parametrize(
("files", "expected"),
[
# Component changes expand to dependents with C++ tests
(["esphome/components/time/posix_tz.cpp"], ["sntp", "time"]),
(["esphome/components/time/__init__.py"], ["sntp", "time"]),
# Dependent without C++ tests is dropped
(["esphome/components/homeassistant/__init__.py"], []),
# Test changes select only that component
(["tests/components/time/posix_tz.cpp"], ["time"]),
(["tests/components/time/__init__.py"], ["time"]),
(["tests/components/homeassistant/__init__.py"], []),
(["tests/components/socket/conftest.py"], []),
(["tests/components/time/test.esp32-idf.yaml"], []),
(
["esphome/components/time/__init__.py", "tests/components/api/api.cpp"],
["api", "sntp", "time"],
),
([], []),
],
)
@pytest.mark.usefixtures("cpp_unit_test_tree")
def test_get_cpp_changed_components(files: list[str], expected: list[str]) -> None:
"""Test that C++ and Python component changes select the right unit tests."""
assert helpers.get_cpp_changed_components(files) == expected
def test_get_cpp_changed_components_independent_of_cwd(
cpp_unit_test_tree: Path,
tmp_path_factory: pytest.TempPathFactory,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test directories resolve against root_path, not the current directory."""
monkeypatch.chdir(tmp_path_factory.mktemp("elsewhere"))
assert helpers.get_cpp_changed_components(
["tests/components/time/__init__.py"]
) == ["time"]
+85 -6
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
import logging
from pathlib import Path
from unittest.mock import patch
@@ -35,22 +36,25 @@ def _reset_core(tmp_path: Path) -> None:
}
def _write_project_description(tmp_path: Path, components: dict[str, str]) -> None:
def _write_project_description(
tmp_path: Path, components: dict[str, str], idf_path: str = "/idf"
) -> None:
"""Stub a project_description.json with the given component_name -> dir map."""
build_dir = tmp_path / "build"
build_dir.mkdir(exist_ok=True)
(build_dir / "project_description.json").write_text(
json.dumps(
{
"idf_path": idf_path,
"build_component_info": {
name: {"dir": dir_} for name, dir_ in components.items()
}
},
}
)
)
def _render(minimal: bool = False) -> str:
def _render(minimal: bool = False, builtin_components: list[str] | None = None) -> str:
"""Render the top-level CMakeLists with the standard variant/name patches."""
with (
patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"),
@@ -58,7 +62,9 @@ def _render(minimal: bool = False) -> str:
):
from esphome.build_gen.espidf import get_project_cmakelists
return get_project_cmakelists(minimal=minimal)
return get_project_cmakelists(
minimal=minimal, builtin_components=builtin_components
)
def test_get_available_components_returns_none_without_build_path() -> None:
@@ -77,8 +83,11 @@ def test_get_available_components_returns_none_without_project_description(
assert get_available_components() is None
def test_get_available_components_filters_src_managed_and_pio(tmp_path: Path) -> None:
"""Built-ins are returned; src/, managed_components/, pio_components/ skipped."""
def test_get_available_components_keeps_only_idf_tree_components(
tmp_path: Path,
) -> None:
"""Only components under idf_path/components are built-ins: src, managed,
converted PIO libs and Arduino component_stubs are all left out."""
_write_project_description(
tmp_path,
{
@@ -86,6 +95,7 @@ def test_get_available_components_filters_src_managed_and_pio(tmp_path: Path) ->
"esp_lcd": "/idf/components/esp_lcd",
"espressif__arduino-esp32": f"{tmp_path}/managed_components/arduino",
"JPEGDEC": f"{tmp_path}/pio_components/arduino/abc/bitbank2/JPEGDEC",
"cbor": f"{tmp_path}/component_stubs/cbor",
"freertos": "/idf/components/freertos",
},
)
@@ -94,6 +104,75 @@ def test_get_available_components_filters_src_managed_and_pio(tmp_path: Path) ->
assert sorted(get_available_components()) == ["esp_lcd", "freertos"]
def test_codegen_and_configure_writes_render_the_same_cmakelists(
tmp_path: Path,
) -> None:
"""write_project() at codegen time (no list) and the configure-time write
(discovered list) must agree, or ninja re-runs cmake on every build."""
_write_project_description(
tmp_path,
{
"lwip": "/idf/components/lwip",
"cbor": f"{tmp_path}/component_stubs/cbor",
},
)
from esphome.build_gen.espidf import get_available_components
assert _render() == _render(builtin_components=get_available_components())
assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS cbor" not in _render()
def test_get_available_components_warns_when_nothing_is_under_idf_path(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
_write_project_description(tmp_path, {"cbor": f"{tmp_path}/component_stubs/cbor"})
from esphome.build_gen.espidf import (
get_available_components,
has_discovered_components,
)
assert get_available_components() == []
assert "No ESP-IDF components found under" in caplog.text
# An empty discovery must not count as configured, or it would be latched in.
assert not has_discovered_components()
def test_get_available_components_ignores_corrupt_or_unexpected_file(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
build_dir = tmp_path / "build"
build_dir.mkdir()
from esphome.build_gen.espidf import (
get_available_components,
has_discovered_components,
)
(build_dir / "project_description.json").write_text("{not json")
assert get_available_components() is None
assert not has_discovered_components()
(build_dir / "project_description.json").write_text('{"build_component_info": {}}')
with caplog.at_level(logging.DEBUG, logger="esphome.build_gen.espidf"):
assert get_available_components() is None
assert "Could not read" in caplog.text
def test_has_discovered_components_after_configure(tmp_path: Path) -> None:
_write_project_description(tmp_path, {"lwip": "/idf/components/lwip"})
from esphome.build_gen.espidf import has_discovered_components
assert has_discovered_components()
def test_get_project_cmakelists_uses_supplied_builtin_components() -> None:
"""A cached list replaces project_description.json and is still filtered
by EXCLUDE_COMPONENTS."""
with patch.dict(CORE.cmake_args, {"EXCLUDE_COMPONENTS": "fatfs;unity"}):
content = _render(builtin_components=["lwip", "fatfs", "esp_timer"])
assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS esp_timer APPEND" in content
assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS lwip APPEND" in content
assert "ESPHOME_PROJECT_BUILTIN_COMPONENTS fatfs APPEND" not in content
def test_get_project_cmakelists_minimal_omits_builtin_components_property(
tmp_path: Path,
) -> None:
@@ -0,0 +1,122 @@
"""Tests for the shared ccache policy in esphome.build_helpers.ccache."""
from __future__ import annotations
import os
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from esphome.build_helpers import ccache
def test_resolve_opt_out() -> None:
with patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "0"}):
assert ccache.resolve_ccache_path() is None
def test_resolve_no_binary(caplog: pytest.LogCaptureFixture) -> None:
with (
patch.dict(os.environ, {}, clear=True),
patch("shutil.which", return_value=None),
):
assert ccache.resolve_ccache_path() is None
assert "no ccache binary" not in caplog.text
def test_resolve_probe_failure() -> None:
with (
patch.dict(os.environ, {}, clear=True),
patch("shutil.which", return_value="/usr/bin/ccache"),
patch("esphome.framework_helpers.subprocess.run", side_effect=OSError("boom")),
):
assert ccache.resolve_ccache_path() is None
def test_resolve_explicit_skips_probe_and_warns_missing(
caplog: pytest.LogCaptureFixture,
) -> None:
with (
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True),
patch("shutil.which", return_value="/usr/bin/ccache"),
patch.object(ccache, "_ccache_runs", side_effect=AssertionError),
):
assert ccache.resolve_ccache_path() == "/usr/bin/ccache"
with (
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True),
patch("shutil.which", return_value=None),
):
assert ccache.resolve_ccache_path() is None
assert "no ccache binary is on PATH" in caplog.text
def test_probe_spawns_with_close_fds_false() -> None:
with patch("esphome.framework_helpers.subprocess.run") as mock_run:
assert ccache._ccache_runs("/usr/bin/ccache") is True
assert mock_run.call_args.kwargs["close_fds"] is False
def test_defaults_env(tmp_path: Path) -> None:
with (
patch("esphome.core.CORE", SimpleNamespace(build_path=tmp_path / "b")),
patch.dict(os.environ, {"CCACHE_NOHASHDIR": "false"}, clear=True),
):
env = ccache.ccache_defaults_env(tmp_path / "cache")
assert env["CCACHE_DIR"] == str(tmp_path / "cache")
assert env["CCACHE_DEPEND"] == "1"
assert "CCACHE_NOHASHDIR" not in env # user value respected
def test_defaults_env_requires_build_path() -> None:
with (
patch("esphome.core.CORE", SimpleNamespace(build_path=None)),
pytest.raises(ValueError, match="build_path"),
):
ccache.ccache_defaults_env(Path("/x"))
@pytest.mark.parametrize("value", ["no", "off", "false", "0"])
def test_resolve_opt_out_synonyms(value: str) -> None:
"""Every recognized falsy spelling disables ccache."""
with patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": value}):
assert ccache.resolve_ccache_path() is None
def test_resolve_unrecognized_value_warns_and_probes(
caplog: pytest.LogCaptureFixture,
) -> None:
"""An unparsable ESPHOME_CCACHE_ENABLE is treated as unset: it must not
silently enable ccache or skip the runnability probe."""
with (
patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "enabled"}),
patch("shutil.which", return_value="/usr/bin/ccache"),
patch.object(ccache, "_ccache_runs", return_value=False) as mock_probe,
):
assert ccache.resolve_ccache_path() is None
mock_probe.assert_called_once()
assert "unrecognized ESPHOME_CCACHE_ENABLE" in caplog.text
@pytest.mark.parametrize(
("raw", "expected"),
[
("1", True),
("enable", True),
("ON", True),
("0", False),
("disable", False),
("Off", False),
("maybe", None),
# ENV KNOB= (Docker/CI) has always read as a disable
("", False),
(" ", False),
],
)
def test_parse_enable_env_spelling_tables(
monkeypatch: pytest.MonkeyPatch, raw: str, expected: bool | None
) -> None:
"""cv.boolean's spelling tables plus the 1/0 env convention."""
monkeypatch.setenv("ESPHOME_CCACHE_ENABLE", raw)
assert ccache.parse_enable_env("ESPHOME_CCACHE_ENABLE") is expected
@@ -0,0 +1,143 @@
"""Tests for esphome.build_helpers.ninja."""
from __future__ import annotations
import os
from pathlib import Path
import subprocess
import sys
from unittest.mock import MagicMock, patch
import pytest
from esphome.build_helpers import ninja as ninja_helper
from esphome.core import EsphomeError
def test_find_ninja_prefers_path(tmp_path: Path) -> None:
with (
patch("shutil.which", return_value=str(tmp_path / "ninja")),
patch.object(ninja_helper, "_ninja_runs", return_value=True),
):
assert ninja_helper.find_ninja() == tmp_path / "ninja"
def test_find_ninja_falls_back_to_wheel(tmp_path: Path) -> None:
"""Without a PATH entry, the ninja PyPI wheel's binary is used."""
binary_name = "ninja.exe" if os.name == "nt" else "ninja"
(tmp_path / binary_name).touch()
wheel = MagicMock(BIN_DIR=str(tmp_path))
with (
patch("shutil.which", return_value=None),
patch.dict(sys.modules, {"ninja": wheel}),
):
assert ninja_helper.find_ninja() == tmp_path / binary_name
def test_find_ninja_package_not_installed() -> None:
"""A missing ninja package raises the actionable message, not ImportError."""
with (
patch("shutil.which", return_value=None),
patch.dict(sys.modules, {"ninja": None}),
pytest.raises(EsphomeError, match="ninja not found"),
):
ninja_helper.find_ninja()
def test_find_ninja_missing_everywhere(tmp_path: Path) -> None:
wheel = MagicMock(BIN_DIR=str(tmp_path))
with (
patch("shutil.which", return_value=None),
patch.dict(sys.modules, {"ninja": wheel}),
pytest.raises(EsphomeError, match="ninja not found"),
):
ninja_helper.find_ninja()
def test_escape_ninja_specials() -> None:
assert ninja_helper.escape("a b:c$d") == "a$ b$:c$$d"
def _q(tok: str) -> str:
"""The platform's shell_token quote wrapper (argv rule on Windows)."""
return f'"{tok}"' if os.name == "nt" else f"'{tok}'"
def test_quote_arg_windows_argv_rule() -> None:
# Backslash runs double only before a quote (subprocess.list2cmdline rule)
assert ninja_helper.quote_arg('-DX=a\\"b c') == '"-DX=a\\\\\\"b c"'
assert ninja_helper.quote_arg("a b\\") == '"a b\\\\"'
def test_shell_token_quotes_only_when_needed() -> None:
assert ninja_helper.shell_token("-Os") == "-Os"
assert ninja_helper.shell_token("-DP=C:\\x y") == _q("-DP=C:\\x y")
assert ninja_helper.shell_token("plain", force=True) == _q("plain")
def test_shell_token_quotes_shell_metacharacters() -> None:
"""Tokens like -DMASK=(1<<3) must not reach /bin/sh -c bare."""
assert ninja_helper.shell_token("-DMASK=(1<<3)") == _q("-DMASK=(1<<3)")
assert ninja_helper.shell_token("-DX=a;b") == _q("-DX=a;b")
assert ninja_helper.shell_token("-DX=$HOME") == _q("-DX=$$HOME")
def test_shell_token_posix_roundtrips_through_sh() -> None:
"""Backslash runs, $, backticks, and quotes must reach the compiler
exactly as lexed once ninja un-doubles $$ and /bin/sh strips quotes."""
if sys.platform == "win32":
pytest.skip("POSIX sh quoting")
for tok in ("-DP=a\\\\b", "-DX=$VAR", "-DY=`date`", "-DZ=it's", '-DC="q"'):
quoted = ninja_helper.shell_token(tok).replace("$$", "$")
out = subprocess.run(
["/bin/sh", "-c", f'printf "%s" {quoted}'],
capture_output=True,
text=True,
check=True,
)
assert out.stdout == tok
def test_quote_path_force_quotes() -> None:
assert ninja_helper.quote_path(Path("a b")) == _q("a b")
assert ninja_helper.quote_path("simple") == _q("simple")
def test_shell_token_empty_token_is_quoted() -> None:
"""An empty argv element must survive as an explicit pair of quotes."""
assert ninja_helper.shell_token("") == _q("")
def test_find_ninja_probes_path_hit(tmp_path: Path) -> None:
"""A broken PATH shim falls back to the wheel instead of failing every
build later."""
binary_name = "ninja.exe" if os.name == "nt" else "ninja"
(tmp_path / binary_name).touch()
wheel = MagicMock(BIN_DIR=str(tmp_path))
with (
patch("shutil.which", return_value="/broken/ninja"),
patch.object(ninja_helper, "_ninja_runs", return_value=False),
patch.dict(sys.modules, {"ninja": wheel}),
):
assert ninja_helper.find_ninja() == tmp_path / binary_name
def test_ninja_probe_failure_warns(caplog: pytest.LogCaptureFixture) -> None:
with patch("esphome.framework_helpers.subprocess.run", side_effect=OSError("boom")):
assert ninja_helper._ninja_runs("/broken/ninja") is False
assert "failed to run" in caplog.text
def test_ninja_probe_success() -> None:
with patch("esphome.framework_helpers.subprocess.run") as mock_run:
assert ninja_helper._ninja_runs("/usr/bin/ninja") is True
assert mock_run.call_args.kwargs["close_fds"] is False
def test_shell_token_windows_branch_uses_argv_rule() -> None:
"""The nt branch quotes with the CreateProcess argv rule (the ubuntu
coverage run never takes it naturally)."""
with patch.object(os, "name", "nt"):
assert ninja_helper.shell_token("a b") == '"a b"'
assert ninja_helper.shell_token("", force=True) == '""'
@@ -263,6 +263,17 @@ def test_device_capabilities_response_has_id_150() -> None:
)
def test_z_wave_proxy_request_response_has_id_151() -> None:
body = _extract_proto_message(PROTO_TEXT, "ZWaveProxyRequestResponse")
match = re.search(r"option \(id\) = (\d+);", body)
assert match is not None, "ZWaveProxyRequestResponse is missing `option (id)`"
assert int(match.group(1)) == 151, (
f"ZWaveProxyRequestResponse has id {match.group(1)}, expected 151. "
"Message ids are part of the wire protocol and must not change once "
"assigned."
)
def test_superseded_fields_are_not_marked_deprecated_in_proto() -> None:
"""The six superseded fields must not carry `[deprecated = true]` in
api.proto, or the generator drops them and old clients stop receiving
@@ -15,7 +15,12 @@ import pytest
sys.path.insert(0, str(Path(__file__).parents[4] / "script" / "api_protobuf"))
from api_protobuf import _make_ifdef_line, get_varint64_ifdef # noqa: E402
from api_protobuf import ( # noqa: E402
MAX_MESSAGE_ID,
_make_ifdef_line,
get_varint64_ifdef,
validate_message_id,
)
from google.protobuf import descriptor_pb2 # noqa: E402
@@ -91,3 +96,14 @@ def test_make_ifdef_line_conjunction_and_negation() -> None:
assert (
_make_ifdef_line("USE_X && !USE_Y") == "#if defined(USE_X) && !defined(USE_Y)"
)
def test_message_id_at_maximum_is_accepted() -> None:
# 16383 is the largest ID whose plaintext type varint fits the 2 bytes
# budgeted in HEADER_PADDING.
validate_message_id(MAX_MESSAGE_ID, "MaxMessage")
def test_message_id_above_maximum_is_rejected() -> None:
with pytest.raises(ValueError, match="exceeds the plaintext"):
validate_message_id(MAX_MESSAGE_ID + 1, "TooBigMessage")
@@ -0,0 +1,33 @@
"""Tests for the per-board linker-script rule."""
import pytest
from esphome.components.esp8266 import _choose_ld_script
from esphome.components.esp8266.boards import BOARDS, board_ld_script
import esphome.config_validation as cv
from esphome.core import EsphomeError
def test_d1_wroom_02_keeps_its_shipped_layout() -> None:
"""The override must survive a BOARDS regeneration or key typo: the
2m.ld default moves _FS_end and the preferences sector on deployed
devices."""
assert board_ld_script(BOARDS["d1_wroom_02"]) == "eagle.flash.2m64.ld"
def test_default_boards_use_the_flash_size_layout() -> None:
assert board_ld_script(BOARDS["d1_mini"]) == "eagle.flash.4m.ld"
assert board_ld_script(BOARDS["esp01_1m"]) == "eagle.flash.1m.ld"
def test_choose_ld_script_paths() -> None:
"""Old cores get the size default, overriding boards hard-error there
(a substituted layout would wipe flash-backed state), modern cores
honor the override."""
assert _choose_ld_script("nodemcuv2", cv.Version(2, 3, 0)) is None
assert _choose_ld_script("nodemcuv2", cv.Version(2, 4, 2)) == "eagle.flash.4m.ld"
assert _choose_ld_script("d1_wroom_02", cv.Version(2, 7, 4)) == (
"eagle.flash.2m64.ld"
)
with pytest.raises(EsphomeError, match="cannot honor"):
_choose_ld_script("d1_wroom_02", cv.Version(2, 4, 2))
@@ -0,0 +1,145 @@
"""Tests for the linker-script surgery shared with the native toolchain."""
from __future__ import annotations
import importlib.util
from pathlib import Path
import sys
import pytest
from esphome.components.esp8266 import build_surgery
from esphome.components.esp8266.boards import BOARDS, ESP8266_BOARD_BUILD
from esphome.components.esp8266.build_surgery import (
RATETABLE_RULE,
apply_testing_memory_patches,
relocate_ratetable,
segment_length,
)
_COMMON_LD_SNIPPET = """\
.dport0.data : ALIGN(4)
{
_dport0_data_start = ABSOLUTE(.);
} >dport0_0_seg :dport0_0_phdr
.data : ALIGN(4)
{
_data_start = ABSOLUTE(.);
*(.data)
} >dram0_0_seg :dram0_0_phdr
"""
# Shaped like the real SDK flash ld scripts: no iram1_0_seg (that lives in
# the generated common ld only)
_FLASH_LD_SNIPPET = """\
MEMORY
{
dport0_0_seg : org = 0x3FF00000, len = 0x10
dram0_0_seg : org = 0x3FFE8000, len = 0x14000
irom0_0_seg : org = 0x40201010, len = 0xfeff0
}
"""
# Shaped like the preprocessed common ld: MMU_IRAM_SIZE expands with a ul
# suffix the patcher must leave in place
_COMMON_LD_MEMORY_SNIPPET = """\
MEMORY
{
iram1_0_seg : org = 0x40100000, len = 0x8000ul
}
"""
def test_relocate_ratetable_inserts_after_data_start() -> None:
patched = relocate_ratetable(_COMMON_LD_SNIPPET)
assert RATETABLE_RULE in patched
# Inserted after the .data section's anchor, not the .dport0.data one
# (whose closing brace bounds the decoy block)
assert RATETABLE_RULE not in patched[: patched.index("} >dport0_0_seg")]
assert patched.index(RATETABLE_RULE) < patched.index("*(.data)")
# Idempotent on an already-patched script
assert relocate_ratetable(patched) == patched
def test_relocate_ratetable_requires_anchor() -> None:
with pytest.raises(RuntimeError, match="_data_start"):
relocate_ratetable("SECTIONS { }")
def test_testing_memory_patches_enlarge_segments() -> None:
patched = apply_testing_memory_patches(
_FLASH_LD_SNIPPET, ("dram0_0_seg", "irom0_0_seg")
)
assert segment_length(patched, "dram0_0_seg") == 0x200000
assert segment_length(patched, "irom0_0_seg") == 0x2000000
# Untouched segments keep their sizes
assert segment_length(patched, "dport0_0_seg") == 0x10
def test_testing_memory_patches_keep_ul_suffix() -> None:
"""The common ld's preprocessed sizes carry a ul suffix; the patch must
replace only the hex digits, as testing_mode.py.script does."""
patched = apply_testing_memory_patches(_COMMON_LD_MEMORY_SNIPPET, ("iram1_0_seg",))
assert "len = 0x200000ul" in patched
assert segment_length(patched, "iram1_0_seg") == 0x200000
def test_segment_length_requires_whole_name() -> None:
"""A name must match its own line, never inside a longer segment name."""
assert segment_length(_FLASH_LD_SNIPPET, "ram0_0_seg") is None
def test_testing_memory_patches_unknown_segment_raises() -> None:
with pytest.raises(RuntimeError, match="Unknown testing-mode segment"):
apply_testing_memory_patches("MEMORY { }", ("bogus_seg",))
def test_segment_length() -> None:
assert segment_length(_FLASH_LD_SNIPPET, "irom0_0_seg") == 0xFEFF0
assert segment_length(_FLASH_LD_SNIPPET, "missing_seg") is None
def test_testing_memory_patches_missing_segment_raises() -> None:
"""A named segment the patch could not find raises instead of silently
keeping the real memory limits."""
with pytest.raises(RuntimeError, match="dram0_0_seg"):
apply_testing_memory_patches("MEMORY { }", ("dram0_0_seg",))
def test_board_build_covers_every_board() -> None:
"""Every supported board has native build metadata (the table may carry
extras that BOARDS does not expose)."""
assert set(BOARDS) <= set(ESP8266_BOARD_BUILD)
def test_surgery_fingerprint_is_stable_and_sensitive(tmp_path) -> None:
"""The properties the linker-script cache depends on: the fingerprint is
stable across calls and changes when the module's source changes."""
first = build_surgery.surgery_fingerprint()
assert first == build_surgery.surgery_fingerprint()
assert len(first) == 64
int(first, 16) # sha256 hex digest
# A modified copy of the module must fingerprint differently
copy = tmp_path / "build_surgery_variant.py"
copy.write_text(
Path(build_surgery.__file__).read_text(encoding="utf-8")
+ "\nEXTRA_BEHAVIORAL_INPUT = 1\n",
encoding="utf-8",
)
spec = importlib.util.spec_from_file_location("build_surgery_variant", copy)
variant = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = variant
try:
spec.loader.exec_module(variant)
assert variant.surgery_fingerprint() != first
finally:
del sys.modules[spec.name]
def test_testing_memory_patches_present_but_unselected_raises() -> None:
"""A known segment left off the caller's list must fail, not silently
keep its real memory limit."""
with pytest.raises(RuntimeError, match="not selected"):
apply_testing_memory_patches(_FLASH_LD_SNIPPET, ("dram0_0_seg",))
@@ -0,0 +1,206 @@
"""Tests for the LVGL table widget's C++ code generation."""
from __future__ import annotations
import pytest
from esphome.automation import ACTION_REGISTRY
from esphome.components.lvgl.defines import set_widgets_completed
from esphome.components.lvgl.lvcode import LvContext
from esphome.components.lvgl.schemas import container_schema
from esphome.components.lvgl.trigger import generate_triggers
from esphome.components.lvgl.widgets import Widget, widget_to_code
from esphome.components.lvgl.widgets.table import table_spec
from esphome.const import (
CONF_AUTOMATION_ID,
CONF_ON_VALUE,
CONF_THEN,
CONF_TRIGGER_ID,
CONF_TYPE_ID,
)
from esphome.core import CORE, ID
from esphome.cpp_generator import MockObj, TemplateArguments
from esphome.yaml_util import make_data_base
async def _create_table(raw_config: dict) -> Widget:
"""Validate `raw_config` as a table widget and generate its creation code."""
config = container_schema(table_spec)(raw_config)
parent = MockObj("parent_obj")
async with LvContext():
return await widget_to_code(config, table_spec, parent)
def _statements() -> list[str]:
return [str(s) for s in CORE.main_statements]
@pytest.mark.asyncio
async def test_create_table_sets_row_and_column_count(setup_core) -> None:
await _create_table(
{"id": "table_counts", "rows": [["Name", "Value"], ["Temp", "22.5"]]}
)
statements = _statements()
assert any("lv_table_set_row_count(table_counts->obj, 2)" in s for s in statements)
assert any(
"lv_table_set_column_count(table_counts->obj, 2)" in s for s in statements
)
@pytest.mark.asyncio
async def test_create_table_writes_cell_values(setup_core) -> None:
await _create_table({"id": "table_cells", "rows": [["Name", "Value"]]})
statements = _statements()
assert any(
'lv_table_set_cell_value(table_cells->obj, 0, 0, "Name")' in s
for s in statements
)
assert any(
'lv_table_set_cell_value(table_cells->obj, 0, 1, "Value")' in s
for s in statements
)
@pytest.mark.asyncio
async def test_create_table_sets_cell_control_flags(setup_core) -> None:
await _create_table(
{
"id": "table_ctrl",
"rows": [
{
"cells": [
{"text": "wide", "merge_right": True},
{"text": "cropped", "text_crop": True},
]
}
],
}
)
statements = _statements()
assert any(
"lv_table_set_cell_ctrl(table_ctrl->obj, 0, 0, LV_TABLE_CELL_CTRL_MERGE_RIGHT)"
in s
for s in statements
)
assert any(
"lv_table_set_cell_ctrl(table_ctrl->obj, 0, 1, LV_TABLE_CELL_CTRL_TEXT_CROP)"
in s
for s in statements
)
# text_crop omitted for cell 0: no clear_cell_ctrl() should be emitted.
assert not any(
"table_ctrl->obj, 0, 0, LV_TABLE_CELL_CTRL_TEXT_CROP" in s for s in statements
)
@pytest.mark.asyncio
async def test_pixel_column_width_calls_lvgl_directly(setup_core) -> None:
await _create_table({"id": "table_px", "columns": [{"width": 96}]})
statements = _statements()
assert any(
"lv_table_set_column_width(table_px->obj, 0, 96)" in s for s in statements
)
@pytest.mark.asyncio
async def test_percent_column_width_uses_the_dynamic_helper(setup_core) -> None:
"""Regression test: lv_table_set_column_width() only accepts a literal
pixel count, so a percentage width must not be passed to it directly -
it has to go through the LvTableType helper that recomputes it at
runtime from the table's actual content width.
"""
await _create_table({"id": "table_pct", "columns": [{"width": "40%"}]})
statements = _statements()
assert any("table_pct->init_column_pct(1)" in s for s in statements)
assert any("table_pct->add_column_width_pct(0, 40)" in s for s in statements)
assert not any(
"lv_table_set_column_width(table_pct->obj, 0" in s for s in statements
)
@pytest.mark.asyncio
async def test_selected_cell_with_both_indices(setup_core) -> None:
await _create_table(
{"id": "table_sel_both", "selected_row": 1, "selected_column": 2}
)
statements = _statements()
assert any(
"lv_table_set_selected_cell(table_sel_both->obj, 1, 2)" in s for s in statements
)
@pytest.mark.asyncio
async def test_selected_cell_with_only_row_selects_whole_row(setup_core) -> None:
await _create_table({"id": "table_sel_row", "selected_row": 1})
statements = _statements()
assert any(
"lv_table_set_selected_cell(table_sel_row->obj, 1, LV_TABLE_CELL_NONE)" in s
for s in statements
)
@pytest.mark.asyncio
async def test_selected_cell_omitted_entirely_when_not_configured(
setup_core,
) -> None:
await _create_table({"id": "table_no_selection", "rows": [["a"]]})
statements = _statements()
assert not any("lv_table_set_selected_cell" in s for s in statements)
@pytest.mark.asyncio
async def test_cell_update_action_writes_only_the_given_fields(setup_core) -> None:
await _create_table({"id": "table_update", "rows": [["a", "b"], ["c", "d"]]})
set_widgets_completed(True)
# Only inspect statements emitted by the action below, not by creation.
before = len(_statements())
entry = ACTION_REGISTRY["lvgl.table.cell.update"]
config = entry.schema(
{"id": "table_update", "row": 1, "column": 1, "text": "new value"}
)
action_id = ID("test_cell_update_action", is_declaration=True, type=entry.type_id)
await entry.coroutine_fun(config, action_id, TemplateArguments(), [])
statements = _statements()[before:]
assert any(
'lv_table_set_cell_value(table_update->obj, 1, 1, "new value")' in s
for s in statements
)
# Neither control flag was specified, so neither call should be emitted.
assert not any("LV_TABLE_CELL_CTRL" in s for s in statements)
@pytest.mark.asyncio
async def test_on_value_registers_a_value_changed_event_callback(setup_core) -> None:
config = container_schema(table_spec)(
{
"id": "table_on_value",
"rows": [["a"]],
"on_value": [
{"lambda": make_data_base("id(table_on_value).get_selected_row();")}
],
}
)
# Auto-generated IDs (trigger/automation/action) are normally resolved to
# unique names by esphome's full config pass before code generation; do
# that by hand here since this test only exercises the widget/trigger
# codegen slice in isolation.
automation_conf = config[CONF_ON_VALUE][0]
automation_conf[CONF_TRIGGER_ID].resolve([])
automation_conf[CONF_AUTOMATION_ID].resolve([])
automation_conf[CONF_THEN][0][CONF_TYPE_ID].resolve([])
parent = MockObj("parent_obj")
async with LvContext():
await widget_to_code(config, table_spec, parent)
set_widgets_completed(True)
await generate_triggers()
statements = _statements()
assert any(
"table_on_value->obj" in s
and "add_event_cb" in s
and "LV_EVENT_VALUE_CHANGED" in s
for s in statements
)
@@ -0,0 +1,142 @@
"""Tests for the LVGL table widget's configuration validation."""
from __future__ import annotations
import pytest
from esphome import config_validation as cv
from esphome.automation import ACTION_REGISTRY
from esphome.components.lvgl.widgets.table import (
CONF_MERGE_RIGHT,
CONF_TEXT_CROP,
TABLE_SCHEMA,
)
def test_minimal_config_is_valid() -> None:
assert TABLE_SCHEMA({}) == {}
def test_row_shorthand_expands_to_plain_cells() -> None:
config = TABLE_SCHEMA({"rows": [["Name", "Value"]]})
[row] = config["rows"]
assert row["cells"] == [{"text": "Name"}, {"text": "Value"}]
def test_row_dict_form_with_cell_overrides() -> None:
config = TABLE_SCHEMA(
{
"rows": [
{
"cells": [
"Temp",
{"text": "22.5", "text_crop": True, "merge_right": True},
]
}
]
}
)
[row] = config["rows"]
assert row["cells"][0] == {"text": "Temp"}
assert row["cells"][1] == {
"text": "22.5",
"merge_right": True,
"text_crop": True,
}
def test_row_count_defaults_are_not_injected_by_the_schema() -> None:
# Inference of row/column counts from `rows` happens at code generation
# time, not during validation - the schema should leave them unset.
config = TABLE_SCHEMA({"rows": [["a", "b"], ["c"]]})
assert "row_count" not in config
assert "column_count" not in config
def test_explicit_row_and_column_count_are_kept() -> None:
config = TABLE_SCHEMA({"row_count": 5, "column_count": 3})
assert config["row_count"] == 5
assert config["column_count"] == 3
def test_row_count_too_small_for_given_rows_raises() -> None:
with pytest.raises(cv.Invalid, match="row_count"):
TABLE_SCHEMA({"rows": [["a"], ["b"], ["c"]], "row_count": 2})
def test_column_count_too_small_for_given_cells_raises() -> None:
with pytest.raises(cv.Invalid, match="column_count"):
TABLE_SCHEMA({"rows": [["a", "b", "c"]], "column_count": 2})
def test_columns_list_longer_than_column_count_raises() -> None:
with pytest.raises(cv.Invalid, match="columns"):
TABLE_SCHEMA(
{
"column_count": 1,
"columns": [{"width": 10}, {"width": 20}],
}
)
def test_columns_list_matching_inferred_column_count_is_valid() -> None:
config = TABLE_SCHEMA(
{
"rows": [["a", "b"]],
"columns": [{"width": 10}, {"width": 20}],
}
)
assert [c["width"] for c in config["columns"]] == [10, 20]
@pytest.mark.parametrize(
("width", "expected"),
[
(100, 100),
("50%", 0.5),
("32px", 32),
],
)
def test_column_width_accepts_pixels_and_percent(width, expected) -> None:
config = TABLE_SCHEMA({"columns": [{"width": width}]})
assert config["columns"][0]["width"] == expected
def test_columns_percent_widths_summing_over_100_percent_raises() -> None:
with pytest.raises(cv.Invalid, match="columns"):
TABLE_SCHEMA({"columns": [{"width": "60%"}, {"width": "50%"}]})
def test_columns_percent_widths_summing_to_100_percent_is_valid() -> None:
config = TABLE_SCHEMA({"columns": [{"width": "60%"}, {"width": "40%"}]})
assert [c["width"] for c in config["columns"]] == [0.6, 0.4]
def test_columns_mixed_pixel_and_percent_widths_ignore_pixels_in_the_total() -> None:
# Pixel widths aren't part of the percentage budget, so they shouldn't
# count towards the 100% limit.
config = TABLE_SCHEMA(
{"columns": [{"width": 200}, {"width": "80%"}, {"width": "20%"}]}
)
assert [c["width"] for c in config["columns"]] == [200, 0.8, 0.2]
def test_selected_row_and_selected_column_are_independently_optional() -> None:
config = TABLE_SCHEMA({"selected_row": 1})
assert config["selected_row"] == 1
assert "selected_column" not in config
def test_cell_update_action_requires_at_least_one_field() -> None:
entry = ACTION_REGISTRY["lvgl.table.cell.update"]
with pytest.raises(cv.Invalid):
entry.schema({"id": "some_table", "row": 0, "column": 0})
def test_cell_update_action_accepts_a_single_field() -> None:
entry = ACTION_REGISTRY["lvgl.table.cell.update"]
config = entry.schema(
{"id": "some_table", "row": 0, "column": 0, "merge_right": True}
)
assert config[CONF_MERGE_RIGHT] is True
assert CONF_TEXT_CROP not in config
+49
View File
@@ -1285,6 +1285,7 @@ async def test_add_platformio_options_native_idf(
await config._add_platformio_options(
{
"build_flags": "-DSINGLE_FLAG", # string and list forms both valid
"build_unflags": ["-Os"],
"lib_deps": ["bblanchon/ArduinoJson@7.4.2"],
"lib_ignore": "libsodium",
"upload_speed": "115200",
@@ -1294,6 +1295,7 @@ async def test_add_platformio_options_native_idf(
assert "-DSINGLE_FLAG" in CORE.build_flags
assert "ArduinoJson" in CORE.platformio_libraries
assert "-Os" in CORE.build_unflags
# lib_ignore is stored (listified) for generate_idf_components to read;
# nothing else lands in platformio_options on the native toolchain.
assert CORE.platformio_options == {"lib_ignore": ["libsodium"]}
@@ -1389,3 +1391,50 @@ def test_esphome_build_internals_are_yaml_only() -> None:
assert markers[field].visibility is cv.Visibility.ADVANCED, field
# A regular device-config field stays on the main form.
assert markers[CONF_NAME_ADD_MAC_SUFFIX].visibility is None
@pytest.mark.asyncio
async def test_add_platformio_options_native_arduino(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The native ESP8266 Arduino toolchain honors board_build.f_cpu (a
real-world overclock knob) and warns about the rest like native IDF."""
CORE.toolchain = Toolchain.ARDUINO
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: "esp8266",
KEY_TARGET_FRAMEWORK: "arduino",
}
await config._add_platformio_options(
{
"board_build.f_cpu": "160000000L",
# The schema also permits the list form; the last value wins
# and reaches the generator as a scalar
"board_build.ldscript": ["eagle.flash.2m.ld", "eagle.flash.4m2m.ld"],
"board_build.filesystem": "littlefs",
"upload_speed": "115200",
}
)
assert CORE.platformio_options["board_build.f_cpu"] == "160000000L"
assert CORE.platformio_options["board_build.ldscript"] == "eagle.flash.4m2m.ld"
assert "board_build.f_cpu is ignored" not in caplog.text
assert "board_build.ldscript is ignored" not in caplog.text
assert (
"esphome->platformio_options->board_build.filesystem is ignored" in caplog.text
)
# An empty list for an honored key is not a scalar; it falls through
# to the ignored-option warning instead of an IndexError
await config._add_platformio_options({"board_build.ldscript": []})
assert "board_build.ldscript is ignored" in caplog.text
assert "'arduino' toolchain" in caplog.text
assert "upload_speed" not in caplog.text
def test_esp8266_rejects_unsupported_cli_toolchain() -> None:
"""Until the native backend lands, ESP8266 serves only PlatformIO."""
from esphome.components.esp8266 import CONFIG_SCHEMA
CORE.toolchain = Toolchain.ARDUINO
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"):
CONFIG_SCHEMA({"board": "nodemcuv2"})

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