mirror of
https://github.com/esphome/esphome.git
synced 2026-10-02 01:10:22 +00:00
Merge remote-tracking branch 'upstream/dev' into neutral-ble-client
This commit is contained in:
@@ -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:
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user