Merge remote-tracking branch 'origin/dev' into web-server-offline-hint

# Conflicts:
#	esphome/components/web_server/__init__.py
#	esphome/components/web_server_base/__init__.py
This commit is contained in:
J. Nick Koston
2026-08-25 12:41:34 -05:00
1361 changed files with 21421 additions and 7015 deletions
+3 -2
View File
@@ -3,8 +3,9 @@ from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# api must run its to_code to define USE_API, USE_API_PLAINTEXT,
# and add the noise-c library dependency.
# api must run its to_code to define USE_API and USE_API_NOISE. The
# AUTO_LOADed noise component runs its own to_code via the override in
# tests/benchmarks/components/noise/__init__.py.
manifest.enable_codegen()
original_to_code = manifest.to_code
@@ -0,0 +1,7 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# to_code must run: it defines USE_NOISE and adds the noise-c library
# the api benchmark sources need.
manifest.enable_codegen()
+1 -1
View File
@@ -4,6 +4,6 @@ from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code(config):
cg.add_build_flag("-DUSE_TIME_TIMEZONE")
cg.add_define("USE_TIME_TIMEZONE")
manifest.to_code = to_code
@@ -13,17 +13,18 @@ namespace api {
class APIConnection;
} // namespace api
namespace uart {
enum class UARTFlushResult : uint8_t {
UART_FLUSH_RESULT_SUCCESS,
UART_FLUSH_RESULT_ASSUMED_SUCCESS,
UART_FLUSH_RESULT_TIMEOUT,
UART_FLUSH_RESULT_FAILED,
};
} // namespace uart
namespace serial_proxy {
enum class SerialProxyResult : uint8_t {
SERIAL_PROXY_RESULT_OK,
SERIAL_PROXY_RESULT_ASSUMED_SUCCESS,
SERIAL_PROXY_RESULT_PORT_IN_USE,
SERIAL_PROXY_RESULT_INVALID_ARGUMENT,
SERIAL_PROXY_RESULT_ERROR,
SERIAL_PROXY_RESULT_TIMEOUT,
SERIAL_PROXY_RESULT_NOT_SUPPORTED,
};
class SerialProxy {
public:
void set_instance_index(uint32_t index) { this->instance_index_ = index; }
@@ -31,13 +32,20 @@ class SerialProxy {
const char *get_name() const { return ""; }
api::enums::SerialProxyPortType get_port_type() const { return {}; }
api::APIConnection *get_api_connection() { return nullptr; }
void serial_proxy_request(api::APIConnection *conn, api::enums::SerialProxyRequestType type) {}
void configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity,
uint32_t stop_bits, uint32_t data_size) {}
SerialProxyResult serial_proxy_request(api::APIConnection *conn, api::enums::SerialProxyRequestType type) {
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
SerialProxyResult configure(api::APIConnection *api_connection, uint32_t baudrate, bool flow_control, uint8_t parity,
uint8_t stop_bits, uint8_t data_size) {
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
void write_from_client(api::APIConnection *api_connection, const uint8_t *data, size_t len) {}
void set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {}
SerialProxyResult set_modem_pins(api::APIConnection *api_connection, uint32_t line_states) {
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
uint32_t get_modem_pins() const { return 0; }
uart::UARTFlushResult flush_port() { return uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS; }
uint32_t get_configured_modem_pins() const { return 0; }
SerialProxyResult flush_port(api::APIConnection *api_connection) { return SerialProxyResult::SERIAL_PROXY_RESULT_OK; }
protected:
uint32_t instance_index_{0};
@@ -15,7 +15,9 @@ namespace zwave_proxy {
class ZWaveProxy {
public:
api::APIConnection *get_api_connection() { return nullptr; }
void zwave_proxy_request(api::APIConnection *conn, api::enums::ZWaveProxyRequestType type) {}
api::enums::ZWaveProxyStatus zwave_proxy_request(api::APIConnection *conn, api::enums::ZWaveProxyRequestType type) {
return api::enums::ZWAVE_PROXY_STATUS_OK;
}
void send_frame(api::APIConnection *api_connection, const uint8_t *data, size_t length) {}
void api_connection_authenticated(api::APIConnection *conn) {}
uint32_t get_feature_flags() const { return 0; }
@@ -0,0 +1,7 @@
esphome:
name: bk-family-gate-7238
bk72xx:
board: generic-bk7238
bk72xx_ble:
@@ -16,6 +16,7 @@ from esphome.core import EsphomeError
("test_bk7231t.yaml", "BK7231T.*BLE 4.2"),
("test_bk7252.yaml", "BK7251.*BLE 4.2"),
("test_bk7231q.yaml", "BK7231Q.*no BLE"),
("test_bk7238.yaml", "BK7238.*bootloader"),
],
)
def test_unsupported_family_rejected(
+8
View File
@@ -57,6 +57,14 @@ def reset_core() -> Generator[None]:
CORE.reset()
@pytest.fixture(autouse=True)
def reset_full_config() -> Generator[None]:
"""Give each test a clean final-validate config and restore it after."""
token = final_validate.full_config.set({})
yield
final_validate.full_config.reset(token)
@pytest.fixture
def set_core_config() -> Generator[SetCoreConfigCallable]:
"""Fixture to set up the core configuration for tests."""
@@ -1,5 +1,13 @@
"""Tests for the deep sleep component."""
import pytest
from esphome import config_validation as cv
from esphome.components import deep_sleep
from esphome.const import CONF_WAKEUP_PIN, PlatformFramework
from ..types import SetCoreConfigCallable
def test_deep_sleep_setup(generate_main):
"""
@@ -83,3 +91,35 @@ def test_deep_sleep_run_duration_dictionary(generate_main):
" .gpio_cause = 30000,\n"
"});"
) in main_cpp
def test_deep_sleep_bk72xx_wakeup_pin_mode_at_both_levels_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""On BK72xx, wakeup_pin_mode at the top level and under the pin entry is an error."""
set_core_config(PlatformFramework.BK72XX_ARDUINO)
config = {
CONF_WAKEUP_PIN: [
{"pin": "GPIO12", deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"}
],
deep_sleep.CONF_WAKEUP_PIN_MODE: "INVERT_WAKEUP",
}
with pytest.raises(cv.Invalid, match="not both"):
deep_sleep.validate_config(config)
def test_deep_sleep_bk72xx_top_level_wakeup_pin_mode_moved_onto_single_pin(
set_core_config: SetCoreConfigCallable,
) -> None:
"""On BK72xx, a top-level wakeup_pin_mode is moved onto the only pin entry."""
set_core_config(PlatformFramework.BK72XX_ARDUINO)
config = {
CONF_WAKEUP_PIN: [{"pin": "GPIO12"}],
deep_sleep.CONF_WAKEUP_PIN_MODE: "INVERT_WAKEUP",
}
result = deep_sleep.validate_config(config)
assert deep_sleep.CONF_WAKEUP_PIN_MODE not in result
assert (
result[CONF_WAKEUP_PIN][0][deep_sleep.CONF_WAKEUP_PIN_MODE] == "INVERT_WAKEUP"
)
@@ -0,0 +1,209 @@
"""Tests for emontx sensor tag defaults."""
import pytest
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,
)
def _resolve_via_config_schema(tag: str) -> dict:
"""Run a minimal config through the real CONFIG_SCHEMA pipeline, the
same path a user's YAML goes through."""
return CONFIG_SCHEMA(
{"tag_name": tag, "emontx_id": "my_emontx", "name": f"{tag} sensor"}
)
def test_config_schema_applies_tag_default_state_class():
"""If sensor_schema(state_class=...) is reintroduced, the schema-level
default wins over apply_tag_defaults' per-prefix value, and E1 would
resolve to measurement instead of total_increasing. Driving the real
CONFIG_SCHEMA (not just apply_tag_defaults) catches that, since
sensor_schema() runs before apply_tag_defaults in the cv.All() chain.
"""
result = _resolve_via_config_schema("E1")
assert result[CONF_STATE_CLASS] == sensor.validate_state_class(
STATE_CLASS_TOTAL_INCREASING
)
def test_config_schema_applies_tag_default_accuracy_decimals():
"""Same root cause as the state_class regression: reintroducing
sensor_schema(accuracy_decimals=...) would make V1 resolve to the
schema-level default instead of the prefix-specific value of 2.
"""
result = _resolve_via_config_schema("V1")
assert result[CONF_ACCURACY_DECIMALS] == 2
def _make_config(tag: str) -> dict:
"""Minimal config dict with only tag_name set — no overrides."""
return {"tag_name": tag}
@pytest.mark.parametrize(
("tag", "expected_state_class", "expected_decimals"),
[
# Known numeric-index prefixes
("E1", STATE_CLASS_TOTAL_INCREASING, 0),
("E12", STATE_CLASS_TOTAL_INCREASING, 0),
("P1", STATE_CLASS_MEASUREMENT, 0),
("V1", STATE_CLASS_MEASUREMENT, 2),
("I1", STATE_CLASS_MEASUREMENT, 2),
("T1", STATE_CLASS_MEASUREMENT, 2),
# Known patterns
("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):
"""apply_tag_defaults must inject the correct state_class and accuracy_decimals
for each tag type when no user overrides are present."""
config = _make_config(tag)
result = apply_tag_defaults(config)
assert result[CONF_STATE_CLASS] == sensor.validate_state_class(expected_state_class)
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"),
[
# User overrides must not be clobbered by defaults
("E1", STATE_CLASS_MEASUREMENT, 3),
("PULSE1", STATE_CLASS_MEASUREMENT, 1),
("V1", STATE_CLASS_TOTAL_INCREASING, 0),
("CUSTOM1", STATE_CLASS_TOTAL_INCREASING, 4),
],
)
def test_apply_tag_defaults_respects_user_overrides(
tag, user_state_class, user_decimals
):
"""apply_tag_defaults must not overwrite values already set by the user."""
config = _make_config(tag)
config[CONF_STATE_CLASS] = sensor.validate_state_class(user_state_class)
config[CONF_ACCURACY_DECIMALS] = user_decimals
result = apply_tag_defaults(config)
assert result[CONF_STATE_CLASS] == sensor.validate_state_class(user_state_class)
assert result[CONF_ACCURACY_DECIMALS] == 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,20 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
i2c:
sda: 21
scl: 22
output:
- platform: ledc
id: ledc_out
pin: 25
- platform: ac_dimmer
id: dimmer_out
gate_pin: 26
zero_cross_pin: 27
@@ -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,14 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
http_request:
verify_ssl: false
@@ -0,0 +1,14 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
mqtt:
broker: "10.0.0.1"
@@ -0,0 +1,20 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
uart:
tx_pin: 17
rx_pin: 16
baud_rate: 115200
display:
- platform: nextion
tft_url: "http://10.0.0.1/display.tft"
@@ -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,14 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
web_server:
version: 3
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
sdkconfig_options:
CONFIG_NVS_ENCRYPTION: n
+142
View File
@@ -17,6 +17,7 @@ from esphome.components.esp32 import (
VARIANT_ESP32,
VARIANTS,
NetworkSdkconfigData,
RawSdkconfigValue,
_ota_downgrade_protection_errors,
_reconcile_network_sdkconfig,
_reconcile_vfs_fatfs_sdkconfig,
@@ -236,6 +237,147 @@ def test_esp32_configuration_errors(
FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config))
@pytest.mark.parametrize(
("config_file", "reincluded"),
[
pytest.param(
"exclusion_reincludes.yaml",
("esp_driver_i2c", "esp_driver_ledc", "esp_driver_gptimer"),
id="i2c_ledc_ac_dimmer",
),
# esp-tls has three owners; a per-owner config makes a dropped
# re-include from any single one fail the test.
pytest.param(
"exclusion_reincludes_http_request.yaml",
("esp-tls", "esp_http_client"),
id="http_request",
),
pytest.param(
# "mqtt" itself is deliberately not asserted: on IDF >= 6.0 it
# is a managed component and never leaves the exclusion set.
"exclusion_reincludes_mqtt.yaml",
("esp-tls",),
id="mqtt",
),
pytest.param(
"exclusion_reincludes_web_server.yaml",
("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"),
id="nextion",
),
],
)
def test_default_exclusions_reincluded_by_owning_components(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
reincluded: tuple[str, ...],
) -> None:
"""Components whose IDF driver is excluded by default must re-include it
during codegen; a dropped include_builtin_idf_component() call would only
surface as a missing-header failure in a full compile job."""
from esphome.components.esp32.const import KEY_EXCLUDE_COMPONENTS
generate_main(component_config_path(config_file))
excluded = CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
for name in reincluded:
assert name not in excluded, f"{name} should have been re-included"
# 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(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
@@ -0,0 +1,19 @@
esphome:
name: scan-window-explicit
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: MySSID
esp32_ble_tracker:
scan_parameters:
window: 30ms
bluetooth_proxy:
active: true
api:
@@ -0,0 +1,17 @@
esphome:
name: scan-window-raised
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: MySSID
esp32_ble_tracker:
bluetooth_proxy:
active: true
api:
@@ -0,0 +1,12 @@
esphome:
name: scan-window-scan-only
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: MySSID
esp32_ble_tracker:
@@ -0,0 +1,14 @@
esphome:
name: scan-window-user-scan-only
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: MySSID
esp32_ble_tracker:
scan_parameters:
connection_scan_window: 20ms
@@ -12,11 +12,12 @@ arbiter a full-duty scan would starve wifi, so the 30 ms default is kept.
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome import config_validation as cv
from esphome.components.ble_device_base import to_ble_units
from esphome.components.ble_device_base import CONF_CONNECTION_SCAN_WINDOW, to_ble_units
from esphome.components.const import CONF_SCAN_PARAMETERS, CONF_WINDOW
from esphome.components.esp32 import KEY_IDF_VERSION
from esphome.components.esp32_ble_tracker import (
@@ -120,3 +121,103 @@ def test_short_interval_without_window_still_rejected(
stage_esp32("5.5.5", wifi=True)
with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"):
_scan_params({"scan_parameters": {"interval": "20ms"}})
# The connection-time fallback window: while a GATT connection is active the
# scanner drops from a raised full-duty window back to this value so the
# connection gets guaranteed airtime.
def test_raise_arms_connection_scan_window_default(
stage_esp32: Callable[..., None],
) -> None:
stage_esp32("5.5.5", wifi=True)
params = _scan_params({})
assert params[CONF_WINDOW] == params[CONF_INTERVAL]
assert to_ble_units(params[CONF_CONNECTION_SCAN_WINDOW]) == 48
def test_user_connection_scan_window_survives_raise(
stage_esp32: Callable[..., None],
) -> None:
stage_esp32("5.5.5", wifi=True)
params = _scan_params({"scan_parameters": {"connection_scan_window": "60ms"}})
assert params[CONF_WINDOW] == params[CONF_INTERVAL]
assert to_ble_units(params[CONF_CONNECTION_SCAN_WINDOW]) == 96
def test_unraised_window_gets_no_connection_scan_window_default(
stage_esp32: Callable[..., None],
) -> None:
stage_esp32("5.5.4", wifi=True)
assert CONF_CONNECTION_SCAN_WINDOW not in _scan_params({})
def test_connection_scan_window_above_interval_rejected(
stage_esp32: Callable[..., None],
) -> None:
stage_esp32("5.5.5", wifi=True)
with pytest.raises(
cv.Invalid, match="connection_scan_window .* needs to be smaller"
):
_scan_params({"scan_parameters": {"connection_scan_window": "400ms"}})
def test_connection_scan_window_above_window_rejected(
stage_esp32: Callable[..., None],
) -> None:
"""A connection window above the (post-raise) window would widen the scan
during connections; the reject runs after the raise so a fallback below a
raised window still validates (covered by the survives-raise test)."""
stage_esp32("5.5.5", wifi=True)
with pytest.raises(
cv.Invalid, match="connection_scan_window .* needs to be smaller"
):
_scan_params(
{"scan_parameters": {"window": "30ms", "connection_scan_window": "300ms"}}
)
def test_connection_scan_window_truncation_collapse_rejected(
stage_esp32: Callable[..., None],
) -> None:
"""A connection window that truncates into the interval's 0.625 ms unit
would silently program a full-duty scan during connections."""
stage_esp32("5.5.5", wifi=True)
with pytest.raises(cv.Invalid, match="connection_scan_window .* both truncate"):
_scan_params(
{
"scan_parameters": {
"interval": "320.5ms",
"connection_scan_window": "320.2ms",
}
}
)
@pytest.mark.parametrize(
("config_file", "window_call", "connection_call", "warns"),
[
# Raised window with GATT clients: the injected fallback is emitted.
("scan_window_raised.yaml", "set_scan_window(512)", True, False),
# Explicit window: nothing injected.
("scan_window_explicit.yaml", "set_scan_window(48)", False, False),
# Scan-only build compiles the path out: the injected default is
# dropped silently, a user-set value warns.
("scan_window_scan_only.yaml", "set_scan_window(512)", False, False),
("scan_window_user_set_scan_only.yaml", "set_scan_window(512)", False, True),
],
)
def test_connection_scan_window_codegen(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
caplog: pytest.LogCaptureFixture,
config_file: str,
window_call: str,
connection_call: bool,
warns: bool,
) -> None:
main_cpp = generate_main(component_config_path(config_file))
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
@@ -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
@@ -3,10 +3,15 @@
from __future__ import annotations
from collections.abc import Callable
import logging
from pathlib import Path
import pytest
from esphome.core import CORE
INTERRUPT_DEFINE = "USE_GPIO_BINARY_SENSOR_INTERRUPT"
def test_gpio_binary_sensor_basic_setup(
generate_main: Callable[[str | Path], str],
@@ -69,3 +74,62 @@ def test_gpio_binary_sensor_explicit_polling_mode(
)
assert "bs_polling->set_use_interrupt(false);" in main_cpp
def test_gpio_binary_sensor_interrupt_emits_define(
generate_main: Callable[[str | Path], str],
) -> None:
"""
An interrupt-mode sensor must emit the define that compiles the ISR code,
since the platform ISR pin implementation is only built when needed
"""
generate_main("tests/component_tests/gpio/test_gpio_binary_sensor.yaml")
assert INTERRUPT_DEFINE in {d.name for d in CORE.defines}
def test_gpio_binary_sensor_polling_omits_define(
generate_main: Callable[[str | Path], str],
) -> None:
"""
A polling-only config must not emit the interrupt define, so the ISR code
(and its reference to ISRInternalGPIOPin) is compiled out
"""
generate_main("tests/component_tests/gpio/test_gpio_binary_sensor_polling.yaml")
assert INTERRUPT_DEFINE not in {d.name for d in CORE.defines}
def test_gpio_binary_sensor_mixed_modes_emit_define(
generate_main: Callable[[str | Path], str],
) -> None:
"""
With one interrupt and one polling sensor, the define is emitted and the
polling instance still opts out via its setter
"""
main_cpp = generate_main(
"tests/component_tests/gpio/test_gpio_binary_sensor_mixed.yaml"
)
assert INTERRUPT_DEFINE in {d.name for d in CORE.defines}
assert "bs_polling->set_use_interrupt(false);" in main_cpp
assert "bs_interrupt->set_use_interrupt" not in main_cpp
def test_gpio_binary_sensor_expander_pin_omits_define(
generate_main: Callable[[str | Path], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""
An expander pin can't use interrupts: final validation falls back to
polling and the interrupt define must not be emitted. This is the config
that fails to link if the ISR code is compiled without an internal pin
"""
with caplog.at_level(logging.INFO):
main_cpp = generate_main(
"tests/component_tests/gpio/test_gpio_binary_sensor_expander.yaml"
)
assert "bs_expander->set_use_interrupt(false);" in main_cpp
assert INTERRUPT_DEFINE not in {d.name for d in CORE.defines}
assert "falling back to polling mode" in caplog.text
@@ -0,0 +1,21 @@
esphome:
name: test
esp32:
board: esp32dev
i2c:
scl: 16
sda: 17
ch422g:
- id: ch422g_hub
binary_sensor:
- platform: gpio
name: "Expander Sensor"
id: bs_expander
pin:
ch422g: ch422g_hub
number: 1
mode: INPUT
@@ -0,0 +1,17 @@
esphome:
name: test
esp32:
board: esp32dev
binary_sensor:
- platform: gpio
pin: 5
name: "Interrupt Sensor"
id: bs_interrupt
- platform: gpio
pin: 4
name: "Polling Sensor"
id: bs_polling
use_interrupt: false
@@ -0,0 +1,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
+403
View File
@@ -0,0 +1,403 @@
"""Tests for the LVGL ``list`` widget: schema validation for its actions
(``lvgl.list.add_text``/``add``/``remove``/``clear``) and the code they generate.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.components.lvgl.widgets.lv_list import (
LIST_CREATE_SCHEMA,
LIST_REMOVE_SCHEMA,
LIST_SCHEMA,
list_add_schema,
)
from esphome.config import read_config
import esphome.config_validation as cv
from esphome.core import CORE
# ---------------------------------------------------------------------------
# lvgl.list.add schema: id + optional index + exactly one widget-type key
# ---------------------------------------------------------------------------
class TestListAddSchema:
def test_valid_single_widget(self) -> None:
result = list_add_schema({"id": "my_list", "label": {"text": "hi"}})
assert result["id"].id == "my_list"
assert "widget" in result
def test_index_optional_and_templatable(self) -> None:
result = list_add_schema({"id": "my_list", "index": 2, "label": {"text": "hi"}})
assert result["index"] == 2
def test_index_omitted_when_not_given(self) -> None:
result = list_add_schema({"id": "my_list", "label": {"text": "hi"}})
assert "index" not in result
def test_missing_id_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="required key 'id' not provided"):
list_add_schema({"label": {"text": "hi"}})
def test_no_widget_key_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="exactly one widget definition"):
list_add_schema({"id": "my_list"})
def test_two_widget_keys_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="exactly one widget definition"):
list_add_schema(
{
"id": "my_list",
"label": {"text": "a"},
"button": {"text": "b"},
}
)
def test_non_mapping_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="Expected a mapping"):
list_add_schema("not_a_mapping")
def test_any_registered_widget_type_accepted(self) -> None:
for widget_key, widget_conf in (
("checkbox", {"text": "Option"}),
("switch", {}),
("spinner", {}),
("obj", {}),
("dropdown", {"options": ["a", "b"]}),
):
result = list_add_schema({"id": "my_list", widget_key: widget_conf})
assert widget_key in result["widget"][0]
@pytest.mark.parametrize(
("widget_key", "widget_conf"),
[
("buttonmatrix", {"rows": [{"buttons": [{"text": "A"}]}]}),
("tabview", {"tabs": [{"name": "Tab1"}]}),
("tileview", {"tiles": [{"row": 0, "column": 0}]}),
("meter", {"scales": [{"range_from": 0, "range_to": 100}]}),
("canvas", {"width": 20, "height": 20}),
],
)
def test_dynamic_widget_unsupported_rejected(
self, widget_key: str, widget_conf: dict
) -> None:
"""buttonmatrix/tabview/tileview all register their own child widgets into
the global widget map from inside their to_code - fine for a widget built
once at boot, but broken if lvgl.list.add re-enters that on every call.
meter/canvas are rejected for a related but distinct reason: they declare a
Pvariable (meter's scale/indicator objects; canvas's draw buffer) with
cg.Pvariable()/cg.new_Pvariable(), which emits its assignment wherever code
is currently being generated -- fine at the top level of a boot-time
to_code, but lvgl.list.add's do_add runs inside a lambda. meter's assignment
would then end up outside the very lambda that declares the local object it
refers to (doesn't compile); canvas's Pvariable is declared once per config
site rather than per call, so every call overwrites its one draw buffer
(compiles, but leaks the old buffer and shares one buffer across every row).
"""
with pytest.raises(cv.Invalid, match="cannot be used with lvgl.list.add"):
list_add_schema({"id": "my_list", widget_key: widget_conf})
def test_dynamic_widget_unsupported_rejected_when_nested(self) -> None:
"""The check must recurse into `widgets:` so a tabview hidden a few levels
deep inside another widget is caught too, not just at the top level.
"""
with pytest.raises(cv.Invalid, match="cannot be used with lvgl.list.add"):
list_add_schema(
{
"id": "my_list",
"obj": {"widgets": [{"tabview": {"tabs": [{"name": "Tab1"}]}}]},
}
)
def test_explicit_id_rejected(self) -> None:
"""A dynamically-added widget is LocalVariable-scoped and rebuilt fresh
on every call, never registered anywhere an id could be looked up by --
an explicit id: would otherwise validate fine and then fail confusingly
(an uncaught traceback, not a clean config error) the moment anything
tries to reference it.
"""
with pytest.raises(cv.Invalid, match="'id' is not allowed"):
list_add_schema(
{"id": "my_list", "label": {"id": "dyn_label", "text": "hi"}}
)
def test_explicit_id_rejected_when_nested(self) -> None:
with pytest.raises(cv.Invalid, match="'id' is not allowed"):
list_add_schema(
{
"id": "my_list",
"obj": {"widgets": [{"label": {"id": "dyn_label", "text": "hi"}}]},
}
)
def test_no_explicit_id_still_valid(self) -> None:
"""An id is auto-generated (and simply unused) when none is given --
only an explicit one is rejected."""
result = list_add_schema({"id": "my_list", "label": {"text": "hi"}})
assert "id" in result["widget"][0]["label"]
@pytest.mark.parametrize(
("key", "conf"),
[
("on_swipe_left", [{"logger.log": "swiped"}]),
("on_swipe_right", [{"logger.log": "swiped"}]),
("on_swipe_up", [{"logger.log": "swiped"}]),
("on_swipe_down", [{"logger.log": "swiped"}]),
("on_boot", [{"logger.log": "booted"}]),
("align_to", {"id": "some_other_widget", "align": "OUT_LEFT_TOP"}),
],
)
def test_unsupported_trigger_rejected(self, key: str, conf: list) -> None:
"""_wire_dynamic_triggers only wires LV_EVENT_TRIGGERS/on_value/on_update --
on_swipe_*/on_boot would otherwise validate fine and then silently generate
nothing at all for a widget added via lvgl.list.add. align_to is in the same
bucket: it's only ever consumed by generate_triggers() reading
get_widget_map(), which a widget built via lvgl.list.add never enters.
"""
with pytest.raises(cv.Invalid, match="is not supported"):
list_add_schema({"id": "my_list", "obj": {key: conf}})
def test_unsupported_trigger_rejected_when_nested(self) -> None:
with pytest.raises(cv.Invalid, match="is not supported"):
list_add_schema(
{
"id": "my_list",
"obj": {
"widgets": [
{
"label": {
"text": "hi",
"on_swipe_left": [{"logger.log": "swiped"}],
}
}
]
},
}
)
# ---------------------------------------------------------------------------
# lvgl.list.remove: index must be non-negative -- LVGL treats a negative index as
# counting back from the end, which would silently delete the wrong row while
# reporting a list_index that matches nothing real to on_remove.
# ---------------------------------------------------------------------------
class TestListRemoveSchema:
def test_negative_index_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="at least 0"):
LIST_REMOVE_SCHEMA({"id": "my_list", "index": -1})
def test_zero_index_accepted(self) -> None:
result = LIST_REMOVE_SCHEMA({"id": "my_list", "index": 0})
assert result["index"] == 0
# ---------------------------------------------------------------------------
# The list widget's own schema: pad_row is shared between create/update, but
# on_add/on_remove only make sense at creation time.
# ---------------------------------------------------------------------------
class TestListCreateVsModifySchema:
def test_create_schema_has_pad_row_and_triggers(self) -> None:
keys = {str(k) for k in LIST_CREATE_SCHEMA.schema}
assert "pad_row" in keys
assert "on_add" in keys
assert "on_remove" in keys
def test_modify_schema_has_pad_row_but_not_triggers(self) -> None:
"""``lvgl.list.update`` can change pad_row but can't (re-)declare triggers."""
keys = {str(k) for k in LIST_SCHEMA.schema}
assert "pad_row" in keys
assert "on_add" not in keys
assert "on_remove" not in keys
def test_on_add_single_automation_with_multiple_actions(self) -> None:
"""A bare action list under on_add: is one automation with a multi-step
`then:`, not multiple independent automations.
"""
config = LIST_CREATE_SCHEMA({"on_add": [{"delay": "10ms"}, {"delay": "20ms"}]})
assert len(config["on_add"]) == 1
assert len(config["on_add"][0]["then"]) == 2
def test_on_add_accepts_multiple_independent_automations(self) -> None:
"""Each explicit `then:` entry gets its own Trigger, so on_add can fire
more than one independent automation.
"""
config = LIST_CREATE_SCHEMA(
{
"on_add": [
{"then": [{"delay": "10ms"}]},
{"then": [{"delay": "20ms"}]},
]
}
)
assert len(config["on_add"]) == 2
# ---------------------------------------------------------------------------
# Code generation
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
"""Generate the C++ output for the shared list-widget YAML config once per
module -- see test_widget_state.py for why this is module-scoped and
inlines the generate_main fixture logic rather than depending on it.
"""
config_path = Path(request.fspath).parent / "config" / "list_test.yaml"
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_global_section + CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_pad_row_set_at_creation(main_cpp: str) -> None:
assert "lv_obj_set_style_pad_row(test_list, 4, LV_PART_MAIN);" in main_cpp
def test_pad_row_updated_via_update_action(main_cpp: str) -> None:
assert "lv_obj_set_style_pad_row(test_list, 8, LV_PART_MAIN);" in main_cpp
def test_add_text_appends(main_cpp: str) -> None:
assert 'lv_list_add_text(test_list, "Header");' in main_cpp
def test_add_text_with_index_moves_before_firing_on_add(main_cpp: str) -> None:
"""The index move must happen before on_add fires, so the reported
list_index reflects the entry's final position, not where it was appended.
"""
assert (
'lv_obj_t *list_entry_VAR_ = lv_list_add_text(test_list, "Pinned");\n'
" lv_obj_move_to_index(list_entry_VAR_, 0);\n"
" triggerint_id->trigger(lvgl::lv_list_get_row_index(test_list, list_entry_VAR_));"
) in main_cpp
def test_add_button_with_checkable_flag(main_cpp: str) -> None:
assert "lv_obj_t *dyn_button_VAR_ = lv_btn_create(test_list);" in main_cpp
assert (
"lv_obj_add_flag(dyn_button_VAR_, (lv_obj_flag_t)(LV_OBJ_FLAG_CHECKABLE));"
in main_cpp
)
assert (
'lv_label_set_text(lv_obj_get_child(dyn_button_VAR_, 0), "Entry");' in main_cpp
)
def test_add_nested_hierarchy_with_compound_child(main_cpp: str) -> None:
"""`obj: {widgets: [label, dropdown]}` builds a plain label child and a
heap-allocated (compound) dropdown child, both parented to the new row.
The child variable names carry a `_1` (depth) suffix, distinguishing them
from the row's own top-level variable -- necessary so that a child of the
*same* widget type as its parent (e.g. `obj: {widgets: [{obj: {...}}]}`)
doesn't declare a C++ variable that shadows its own not-yet-initialized
self, silently parenting the child to garbage.
"""
assert "lv_obj_t *dyn_obj_VAR_ = lv_obj_create(test_list);" in main_cpp
assert (
"lv_obj_t *dyn_label_1_VAR_ = lv_label_create(dyn_obj_VAR_);\n"
" lv_obj_add_style(dyn_label_1_VAR_, _lv_theme_style_label_main_default, "
"(lv_state_t)(LV_PART_MAIN));\n"
' lv_label_set_text(dyn_label_1_VAR_, "Nested");'
) in main_cpp
def test_add_applies_theme_styles_to_dynamic_widget(main_cpp: str) -> None:
"""A widget added via lvgl.list.add must pick up the same `theme:` styling a
statically-declared widget of the same type gets, not render unthemed.
"""
assert (
"lv_obj_add_style(dyn_label_1_VAR_, _lv_theme_style_label_main_default, "
"(lv_state_t)(LV_PART_MAIN));"
) in main_cpp
assert "LvDropdownType *dyn_dropdown_1_VAR_ = new LvDropdownType();" in main_cpp
assert "lv_dropdown_create(dyn_obj_VAR_)" in main_cpp
assert (
"lvgl::delete_lv_compound_on_delete<LvDropdownType>, LV_EVENT_DELETE, "
"dyn_dropdown_1_VAR_);"
) in main_cpp
def test_add_nested_same_type_child_does_not_shadow_parent(main_cpp: str) -> None:
"""A child of the same widget type as its parent (`obj: {widgets: [{obj:
...}]}`) must get a distinct C++ variable name (or the child's declaration
would shadow its own not-yet-initialized self, parenting it to garbage --
compiling clean but for a -Wuninitialized warning). A grandchild of a third
type proves depth, not just type, drives the disambiguating suffix.
"""
assert "lv_obj_t *dyn_obj_VAR_ = lv_obj_create(test_list);" in main_cpp
assert "lv_obj_t *dyn_obj_1_VAR_ = lv_obj_create(dyn_obj_VAR_);" in main_cpp
assert (
"lv_obj_t *dyn_label_2_VAR_ = lv_label_create(dyn_obj_1_VAR_);\n"
" lv_obj_add_style(dyn_label_2_VAR_, _lv_theme_style_label_main_default, "
"(lv_state_t)(LV_PART_MAIN));\n"
' lv_label_set_text(dyn_label_2_VAR_, "Grandchild");'
) in main_cpp
def test_add_moves_row_to_given_index_before_firing_on_add(main_cpp: str) -> None:
assert (
"lv_obj_move_to_index(dyn_obj_VAR_, 1);\n"
" triggerint_id->trigger(lvgl::lv_list_get_row_index(test_list, dyn_obj_VAR_));"
) in main_cpp
def test_on_add_fires_once_per_entry_via_shared_trigger(main_cpp: str) -> None:
"""A single on_add: automation means a single Trigger instance, reused by
every lvgl.list.add_text/add call site.
"""
assert main_cpp.count("triggerint_id->trigger(lvgl::lv_list_get_row_index(") == 5
def test_remove_guards_against_missing_child_and_fires_before_delete(
main_cpp: str,
) -> None:
"""The index is materialised into a local once (list_index_VAR_) and reused for
both the child lookup and the on_remove trigger, so a templatable index isn't
evaluated twice.
"""
assert (
"int list_index_VAR_ = 0;\n"
" {\n"
" lv_obj_t *list_child_VAR_ = lvgl::lv_list_get_row_for_remove(test_list, list_index_VAR_);\n"
" if (list_child_VAR_) {\n"
" triggerint_id_2->trigger(list_index_VAR_);\n"
" lv_obj_del(list_child_VAR_);"
) in main_cpp
def test_remove_out_of_range_lookup_uses_shared_cpp_helper(main_cpp: str) -> None:
"""The out-of-range lookup (and its log line) live in a single C++ helper --
lvgl::lv_list_get_row_for_remove() in lvgl_esphome.cpp -- rather than being
generated inline at every lvgl.list.remove call site, since a config can
have many of them and duplicating that logic (and its log string) at each
one would waste flash for no benefit.
"""
assert (
"lv_obj_t *list_child_VAR_ = lvgl::lv_list_get_row_for_remove(test_list, list_index_VAR_);"
in main_cpp
)
assert "ESP_LOGV" not in main_cpp
def test_clear_fires_on_remove_for_every_entry_then_cleans(main_cpp: str) -> None:
assert (
"for (int list_index = (int) (lv_obj_get_child_count(test_list)) - 1; "
"list_index >= 0; list_index--) {\n"
" triggerint_id_2->trigger(list_index);\n"
" }\n"
" lv_obj_clean(test_list);"
) in main_cpp
@@ -0,0 +1,47 @@
"""Regression test: on_add:/on_remove: containing an lvgl action must not deadlock.
ListType.to_code() used to build the on_add/on_remove automations directly, during
widget creation. Every lvgl action's to_code awaits wait_for_widgets(), which only
resolves once *all* widgets - including the list itself - have finished being
created. Building an automation containing an lvgl action from inside that same
widget-creation walk therefore could never complete: codegen deadlocked with
"Circular dependency detected!". Fixed by deferring the actual build_automation()
call to finish_list_triggers(), run after set_widgets_completed(True) - and,
critically, before generate_triggers(), which is what processes other widgets'
on_click etc. automations that might reference this list (e.g. via lvgl.list.add),
and which therefore need the list's own on_add/on_remove triggers to already exist.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
from esphome.core import CORE
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
config_path = (
Path(request.fspath).parent / "config" / "list_on_add_lvgl_action_test.yaml"
)
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_on_add_with_lvgl_action_does_not_deadlock(main_cpp: str) -> None:
assert 'lv_label_set_text(later_label, "changed");' in main_cpp
def test_on_remove_with_lvgl_action_does_not_deadlock(main_cpp: str) -> None:
assert 'lv_label_set_text(later_label, "removed");' in main_cpp
@@ -0,0 +1,84 @@
"""Regression test for lvgl.list.add called from outside the lvgl: block.
lv_list.py's list_add_to_code() must call _register_lv_uses() and
_register_dynamic_widget_style_uses() before its first await (get_widgets(),
which can block until the target list is defined) -- for an action referenced
outside the lvgl: block, that wait can outlast lvgl's own to_code, which reads
get_lv_uses()/get_styles_used() and flushes everything they drive (USE_LVGL_*
defines, plus add_lv_use(image)/screen-transparency/A8-draw-support triggered
by style properties) just once, near the end of its run. Every existing
list_test.yaml call site lives inside lvgl: widgets:, so neither ordering
requirement had any coverage.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
from esphome.core import CORE
@dataclass
class GeneratedOutput:
main_cpp: str
define_names: set[str]
lv_define_names: set[str]
@pytest.fixture(scope="module")
def generated(request: pytest.FixtureRequest) -> GeneratedOutput:
config_path = (
Path(request.fspath).parent / "config" / "list_outside_block_test.yaml"
)
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
# Copy out before CORE.reset() below clears these out from under us.
from esphome.components.lvgl import defines as df
return GeneratedOutput(
main_cpp=CORE.cpp_global_section + CORE.cpp_main_section,
define_names={d.name for d in CORE.defines},
lv_define_names=set(df.get_defines()),
)
finally:
CORE.config_path = original_path
CORE.reset()
def test_dynamic_widget_creates_correctly(generated: GeneratedOutput) -> None:
assert (
"lv_obj_t *dyn_switch_VAR_ = lv_switch_create(test_list);" in generated.main_cpp
)
def test_dynamic_widget_type_use_define_is_registered(
generated: GeneratedOutput,
) -> None:
"""The switch type is only ever referenced via the on_boot lvgl.list.add call
(never declared as a static widget), so USE_LVGL_SWITCH can only be present
if _register_lv_uses() ran in time for lvgl's own to_code to flush it.
"""
assert "USE_LVGL_SWITCH" in generated.define_names
assert "USE_LVGL_LIST" in generated.define_names
def test_dynamic_widget_style_use_defines_are_registered(
generated: GeneratedOutput,
) -> None:
"""bg_image_src/transform_rotation/drop_shadow_color are only ever set on
the dynamically-added switch (never on a static widget), so
USE_LVGL_IMAGE/LV_COLOR_SCREEN_TRANSP/LV_DRAW_SW_SUPPORT_A8 can only be
present if _register_dynamic_widget_style_uses() ran in time for lvgl's own
to_code to flush them.
"""
assert "USE_LVGL_IMAGE" in generated.define_names
assert "LV_COLOR_SCREEN_TRANSP" in generated.lv_define_names
assert "LV_DRAW_SW_SUPPORT_A8" in generated.lv_define_names
@@ -16,7 +16,13 @@ from esphome.components.modbus_client import (
CONFIG_SCHEMA,
MODBUS_CLIENT_SEND_SCHEMA,
)
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_ON_ERROR, CONF_ON_RESPONSE
from esphome.const import (
CONF_ADDRESS,
CONF_CONTINUOUS,
CONF_ID,
CONF_ON_ERROR,
CONF_ON_RESPONSE,
)
from esphome.core import Lambda
from esphome.types import ConfigType
@@ -118,6 +124,29 @@ def test_on_no_response_retry_lambda_accepted() -> None:
)
def test_continuous_on_write_pdu_rejected() -> None:
"""A literal write-code PDU with continuous: true is rejected at config time (reads only)."""
with pytest.raises(cv.Invalid, match="does not apply to a write PDU"):
MODBUS_CLIENT_SEND_SCHEMA(
{
CONF_ADDRESS: 0x01,
CONF_PDU: [0x06, 0x00, 0x01, 0x00, 0x0A],
CONF_CONTINUOUS: True,
}
)
def test_continuous_on_read_pdu_accepted() -> None:
"""A literal read-code PDU with continuous: true is fine - continuous polling applies to reads."""
MODBUS_CLIENT_SEND_SCHEMA(
{
CONF_ADDRESS: 0x01,
CONF_PDU: [0x03, 0x00, 0x10, 0x00, 0x01],
CONF_CONTINUOUS: True,
}
)
# The standalone component block. The compile fixtures cover the accepted shapes end to end; these pin
# the parts a fixture cannot express - a rejection, and a module flag whose absence breaks other
# components rather than this one.
@@ -0,0 +1,100 @@
"""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.
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,
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:
"""custom_command validates at the schema level; migration/rejection happens in final validate."""
config = ModbusItemBaseSchema(
{CONF_CUSTOM_COMMAND: [0x01, 0x03, 0x00, 0x2A, 0x00, 0x01]}
)
assert config[CONF_CUSTOM_COMMAND] == [0x01, 0x03, 0x00, 0x2A, 0x00, 0x01]
def test_custom_pdu_and_custom_command_mutually_exclusive() -> None:
"""Only one custom source may be given; supplying both is a schema error."""
with pytest.raises((Invalid, MultipleInvalid)):
ModbusItemBaseSchema(
{
CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01],
CONF_CUSTOM_COMMAND: [0x01, 0x03, 0x00, 0x2A, 0x00, 0x01],
}
)
def test_custom_pdu_accepted() -> None:
"""The new key takes PDU bytes (function code + data, no address byte)."""
config = ModbusItemBaseSchema({CONF_CUSTOM_PDU: [0x03, 0x00, 0x2A, 0x00, 0x01]})
assert config[CONF_CUSTOM_PDU] == [0x03, 0x00, 0x2A, 0x00, 0x01]
def test_custom_pdu_rejects_non_byte_values() -> None:
"""PDU entries are bytes; a word-sized value is a sign the old raw format is being used."""
with pytest.raises((Invalid, MultipleInvalid)):
ModbusItemBaseSchema({CONF_CUSTOM_PDU: [0x0103, 0x002A]})
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],
}
)
@@ -0,0 +1,14 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
network:
tcp_send_buffer: 32kB
@@ -0,0 +1,15 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
password: "test_password"
network:
enable_high_performance: true
tcp_send_buffer: 16384
@@ -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,85 @@
"""Tests for the ``network: tcp_send_buffer:`` option.
The option sets lwIP's per-socket TCP send buffer
(CONFIG_LWIP_TCP_SND_BUF_DEFAULT) on ESP-IDF. The stock default (5744 bytes)
stalls bursty senders such as a Bluetooth proxy streaming GATT notifications;
until now the only way to raise it was the all-or-nothing
``enable_high_performance`` bundle.
"""
from collections.abc import Callable
from pathlib import Path
import pytest
from voluptuous import Invalid
from esphome import config_validation as cv
from esphome.components.esp32.const import (
KEY_SDKCONFIG_OPTIONS,
KEY_VARIANT,
VARIANT_ESP32,
)
from esphome.components.network import (
CONF_TCP_SEND_BUFFER,
CONFIG_SCHEMA,
TCP_SEND_BUFFER_MAX,
TCP_SEND_BUFFER_MIN,
)
from esphome.const import KEY_ESP32, KEY_FRAMEWORK_VERSION, PlatformFramework
from esphome.core import CORE
from tests.component_tests.types import SetCoreConfigCallable
def _sdkconfig_option(name: str) -> int | None:
return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get(name)
def test_tcp_send_buffer_sets_sdkconfig(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
generate_main(component_config_path("tcp_send_buffer.yaml"))
assert _sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT") == 32000
def test_tcp_send_buffer_overrides_high_performance(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""An explicit size wins over the high performance bundle's 65534."""
generate_main(component_config_path("tcp_send_buffer_high_perf.yaml"))
assert _sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT") == 16384
@pytest.mark.parametrize("value", [TCP_SEND_BUFFER_MIN, TCP_SEND_BUFFER_MAX])
def test_boundary_values_accepted(
set_core_config: SetCoreConfigCallable, value: int
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
core_data={KEY_FRAMEWORK_VERSION: cv.Version(5, 5, 5)},
platform_data={KEY_VARIANT: VARIANT_ESP32},
)
assert CONFIG_SCHEMA({"tcp_send_buffer": value})[CONF_TCP_SEND_BUFFER] == value
@pytest.mark.parametrize("value", ["1kB", "128kB"])
def test_out_of_range_rejected(
set_core_config: SetCoreConfigCallable, value: str
) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
core_data={KEY_FRAMEWORK_VERSION: cv.Version(5, 5, 5)},
platform_data={KEY_VARIANT: VARIANT_ESP32},
)
with pytest.raises(Invalid):
CONFIG_SCHEMA({"tcp_send_buffer": value})
def test_rejected_on_esp8266(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(
PlatformFramework.ESP8266_ARDUINO,
core_data={KEY_FRAMEWORK_VERSION: cv.Version(3, 1, 2)},
)
with pytest.raises(Invalid, match="esp32"):
CONFIG_SCHEMA({"tcp_send_buffer": "32kB"})
@@ -0,0 +1,37 @@
"""Tests for the shared noise encryption key helpers."""
from __future__ import annotations
import pytest
from esphome import config_validation as cv
from esphome.components.noise import decode_encryption_key, validate_encryption_key
KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
def test_validate_encryption_key_roundtrips() -> None:
assert validate_encryption_key(KEY) == KEY
@pytest.mark.parametrize("value", ["not-base64!!!", "AAECAw=="])
def test_validate_encryption_key_rejects_bad_input(value: str) -> None:
with pytest.raises(cv.Invalid):
validate_encryption_key(value)
def test_decode_encryption_key_returns_32_bytes() -> None:
assert decode_encryption_key(KEY) == bytes(range(32))
def test_decode_encryption_key_rejects_invalid_base64() -> None:
"""The shared helper raises cv.Invalid, not binascii.Error."""
with pytest.raises(cv.Invalid, match="base64"):
decode_encryption_key("A")
def test_decode_encryption_key_rejects_short_decode() -> None:
"""a2b_base64 stops at embedded padding; a short decode must not become
a zero padded PSK on the device."""
with pytest.raises(cv.Invalid, match="32 bytes"):
decode_encryption_key("AAECAw==")
@@ -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
+1
View File
@@ -4,4 +4,5 @@ media_source:
- platform: audio_http
id: audio_http_source
buffer_size: 100000
persistent_ring_buffer: true
task_stack_in_psram: true
@@ -136,3 +136,19 @@ binary_sensor:
invalid_cooldown: 2s
then:
- logger.log: "Click with custom cooldown"
# Test on_click and on_double_click (compiles match_interval via
# USE_BINARY_SENSOR_CLICK_TRIGGER)
- platform: template
id: click_triggers
name: "Click Triggers"
on_click:
min_length: 50ms
max_length: 350ms
then:
- logger.log: "Clicked"
on_double_click:
min_length: 50ms
max_length: 350ms
then:
- logger.log: "Double clicked"
+11
View File
@@ -0,0 +1,11 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# No host camera platform exists to emit USE_CAMERA; define it here so
# the iterator CAMERA state compiles into the test binary.
async def to_code_testing(config):
cg.add_define("USE_CAMERA")
manifest.to_code = to_code_testing
@@ -0,0 +1,79 @@
#include <gtest/gtest.h>
#include "esphome/core/component_iterator.h"
#ifdef USE_CAMERA
#include "esphome/components/camera/camera.h"
namespace esphome::testing {
class StubCamera : public camera::Camera {
public:
void add_listener(camera::CameraListener *listener) override {}
camera::CameraImageReader *create_image_reader() override { return nullptr; }
void request_image(camera::CameraRequester requester) override {}
void start_stream(camera::CameraRequester requester) override {}
void stop_stream(camera::CameraRequester requester) override {}
};
// Iterator that accepts everything except the camera, which can refuse a
// configurable number of times. The CAMERA state is a singleton path
// distinct from process_platform_item_; this pins the same contract:
// a refused camera is re-offered, never skipped.
class CameraRefusingIterator : public ComponentIterator {
public:
// NOLINTBEGIN(bugprone-macro-parentheses)
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
bool on_##singular(type *obj) override { return true; }
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
ENTITY_TYPE_(type, singular, plural, count, upper)
#include "esphome/core/entity_types.h"
#undef ENTITY_TYPE_
#undef ENTITY_CONTROLLER_TYPE_
// NOLINTEND(bugprone-macro-parentheses)
bool on_camera(camera::Camera *obj) override {
this->camera_calls++;
if (this->camera_refusals > 0) {
this->camera_refusals--;
return false;
}
return true;
}
int camera_calls{0};
int camera_refusals{0};
};
// Far above the fixed number of iterator states
static constexpr size_t BIG_BUDGET = 1000;
class ComponentIteratorCameraTest : public ::testing::Test {
protected:
void SetUp() override {
// Constructing a Camera installs the process-wide singleton
static StubCamera stub_camera;
ASSERT_EQ(camera::Camera::instance(), &stub_camera);
}
};
TEST_F(ComponentIteratorCameraTest, RefusedCameraIsReofferedNotSkipped) {
CameraRefusingIterator it;
it.camera_refusals = 2;
it.begin();
// Runs until the camera refuses, which stops the pass
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.camera_calls, 1);
EXPECT_FALSE(it.completed());
// The camera is re-offered once per call, not skipped
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.camera_calls, 2);
EXPECT_FALSE(it.completed());
// Once accepted, the iteration completes
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_EQ(it.camera_calls, 3);
}
} // namespace esphome::testing
#endif // USE_CAMERA
+11
View File
@@ -0,0 +1,11 @@
# Pulls in sensor so entity iteration paths compile (USE_SENSOR);
# tests register their own instances. Plain yaml.safe_load, no ESPHome tags.
# An alphabetically-earlier component's sensor: block shadows this one in
# combined builds; the tests' sensor-count ASSERT catches a capacity drop.
sensor:
- platform: template
id: bench_sensor_a
name: "Bench A"
- platform: template
id: bench_sensor_b
name: "Bench B"
+46
View File
@@ -83,4 +83,50 @@ TEST(StaticVectorTest, ConvertingConstructorSameSize) {
EXPECT_EQ(dst[2], 3);
}
TEST(StringContainsIgnoreCaseTest, NullPointerAlwaysFalse) {
const char *haystack = nullptr;
const char *needle = nullptr;
EXPECT_FALSE(str_contains_ignore_case(haystack, needle));
EXPECT_FALSE(str_contains_ignore_case("Hello World", needle));
EXPECT_FALSE(str_contains_ignore_case(haystack, "anything"));
}
TEST(StringContainsIgnoreCaseTest, EmptySearchMatches) {
const char *haystack = "Hello World";
EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, ""));
}
TEST(StringContainsIgnoreCaseTest, MiscCaseMatches) {
const char *haystack = "Hello World";
EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "Hello"));
EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "hello"));
EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "HELLO"));
EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "hELLO"));
}
TEST(StringContainsIgnoreCaseTest, MiscNotMatching) {
const char *haystack = "Hello World";
// Expected to match
EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "Hell"));
// Expected not to match
EXPECT_FALSE(str_contains_ignore_case_fallback(haystack, "Heaven"));
EXPECT_FALSE(str_contains_ignore_case_fallback(haystack, "Hello!"));
EXPECT_FALSE(str_contains_ignore_case_fallback(haystack, "world!"));
}
TEST(StringContainsIgnoreCaseTest, FallbackMatchesLibc) {
const char *haystack = "Hello World";
for (const char *needle : {"", "Hello", "hELLO", "HELLO", "Hell", "world", "World", "Heaven", "Hello!", "d"}) {
EXPECT_EQ(str_contains_ignore_case_fallback(haystack, needle), str_contains_ignore_case(haystack, needle))
<< "needle: " << needle;
}
EXPECT_EQ(str_contains_ignore_case_fallback("", ""), str_contains_ignore_case("", ""));
EXPECT_EQ(str_contains_ignore_case_fallback("ab", "abc"), str_contains_ignore_case("ab", "abc"));
}
} // namespace esphome
@@ -0,0 +1,195 @@
#include <gtest/gtest.h>
#include "esphome/core/component_iterator.h"
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/application.h"
#endif
namespace esphome::testing {
// Iterator whose begin/end callbacks can refuse a configurable number of
// times; all entity callbacks accept (any registered entities are accepted).
class RefusingIterator : public ComponentIterator {
public:
// NOLINTBEGIN(bugprone-macro-parentheses)
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
bool on_##singular(type *obj) override { return true; }
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
ENTITY_TYPE_(type, singular, plural, count, upper)
#include "esphome/core/entity_types.h"
#undef ENTITY_TYPE_
#undef ENTITY_CONTROLLER_TYPE_
// NOLINTEND(bugprone-macro-parentheses)
bool on_begin() override { return step(this->begin_calls, this->begin_refusals); }
bool on_end() override { return step(this->end_calls, this->end_refusals); }
int begin_calls{0};
int end_calls{0};
int begin_refusals{0};
int end_refusals{0};
protected:
static bool step(int &calls, int &refusals) {
calls++;
if (refusals > 0) {
refusals--;
return false;
}
return true;
}
};
// Far above the fixed number of iterator states
static constexpr size_t BIG_BUDGET = 1000;
TEST(ComponentIterator, NotRunningMakesNoProgress) {
RefusingIterator it;
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_EQ(it.begin_calls, 0);
EXPECT_EQ(it.end_calls, 0);
}
TEST(ComponentIterator, CompletesInOneCallWithoutRefusals) {
RefusingIterator it;
it.begin();
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_EQ(it.begin_calls, 1);
EXPECT_EQ(it.end_calls, 1);
}
TEST(ComponentIterator, StepBudgetIsHonored) {
RefusingIterator it;
it.begin();
it.try_advance(1);
EXPECT_EQ(it.begin_calls, 1);
EXPECT_EQ(it.end_calls, 0);
EXPECT_FALSE(it.completed());
}
TEST(ComponentIterator, RefusedStepStopsBatchAndRetriesSameStep) {
RefusingIterator it;
it.end_refusals = 3;
it.begin();
// First call runs until the refused end step, which stops the pass
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.end_calls, 1);
EXPECT_FALSE(it.completed());
// The refused step is retried once per call, not skipped
it.try_advance(BIG_BUDGET);
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.end_calls, 3);
EXPECT_FALSE(it.completed());
// Once accepted, the iteration completes
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_EQ(it.end_calls, 4);
}
TEST(ComponentIterator, RefusedBeginStopsBatchAndRetries) {
RefusingIterator it;
it.begin_refusals = 2;
it.begin();
it.try_advance(BIG_BUDGET);
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.begin_calls, 2);
EXPECT_FALSE(it.completed());
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_EQ(it.begin_calls, 3);
}
// The deprecated advance() wrapper must keep the legacy once-per-loop
// pattern working during the deprecation window.
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
TEST(ComponentIterator, DeprecatedAdvanceKeepsLegacyPatternWorking) {
RefusingIterator it;
it.end_refusals = 2;
it.begin();
size_t guard = 0;
while (!it.completed() && guard++ < BIG_BUDGET) {
it.advance();
}
EXPECT_TRUE(it.completed());
// Two refused end steps were retried, then accepted
EXPECT_EQ(it.end_calls, 3);
}
#pragma GCC diagnostic pop
#ifdef USE_SENSOR
// Iterator whose sensor callback can refuse or yield; pins the per-item
// contract: a refused item is re-offered with at_ unchanged, never skipped.
class ItemRefusingIterator : public RefusingIterator {
public:
bool on_sensor(sensor::Sensor *obj) override {
this->last_sensor = obj;
if (!step(this->sensor_calls, this->sensor_refusals))
return false;
if (this->yield_on_sensor)
this->yield_after_step_();
return true;
}
sensor::Sensor *last_sensor{nullptr};
int sensor_calls{0};
int sensor_refusals{0};
bool yield_on_sensor{false};
};
class ComponentIteratorSensorTest : public ::testing::Test {
protected:
void SetUp() override {
static sensor::Sensor sensor_a;
static sensor::Sensor sensor_b;
static bool registered = false;
if (!registered) {
App.register_sensor(&sensor_a);
App.register_sensor(&sensor_b);
registered = true;
}
// StaticVector drops silently when full; fail the fixture, not the contract
ASSERT_EQ(App.get_sensors().size(), 2u) << "benchmark.yaml sensor count too small";
}
};
TEST_F(ComponentIteratorSensorTest, RefusedItemIsReofferedNotSkipped) {
ItemRefusingIterator it;
it.sensor_refusals = 2;
it.begin();
// Runs until the first sensor refuses
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.sensor_calls, 1);
EXPECT_FALSE(it.completed());
// The refused item is re-offered, not skipped
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.sensor_calls, 2);
sensor::Sensor *refused = it.last_sensor;
// Once accepted, iteration continues through the second sensor to the end
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_NE(it.last_sensor, refused);
EXPECT_EQ(it.sensor_calls, 4);
}
TEST_F(ComponentIteratorSensorTest, YieldAfterStepEndsPassAndResumes) {
ItemRefusingIterator it;
it.yield_on_sensor = true;
it.begin();
// The pass ends right after the first sensor despite a big budget
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.sensor_calls, 1);
EXPECT_FALSE(it.completed());
// The next pass ends after the second sensor
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.sensor_calls, 2);
// Remaining states then run to completion in one pass
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
}
#endif // USE_SENSOR
} // namespace esphome::testing
+1 -2
View File
@@ -1,4 +1,3 @@
packages:
uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
<<: !include common.yaml
emontx: !include common.yaml
@@ -1,4 +1,3 @@
packages:
uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml
<<: !include common.yaml
emontx: !include common.yaml
+1 -2
View File
@@ -1,4 +1,3 @@
packages:
uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml
<<: !include common.yaml
emontx: !include common.yaml
@@ -0,0 +1,88 @@
packages:
uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
emontx: !include common.yaml
# Validate that each sensor type gets the correct default state_class,
# unit_of_measurement, device_class, and accuracy_decimals when NO overrides
# are provided. The values are intentionally omitted so apply_tag_defaults is
# exercised, not the user-override path.
sensor:
# Energy sensor (E prefix): expects state_class=total_increasing, unit=Wh,
# device_class=energy, accuracy_decimals=0
- platform: emontx
tag_name: E1
name: Energy 1
emontx_id: test_emontx
# Power sensor (P prefix): expects state_class=measurement, unit=W,
# device_class=power, accuracy_decimals=0
- platform: emontx
tag_name: P1
name: Power 1
emontx_id: test_emontx
# Voltage sensor (V prefix): expects state_class=measurement, unit=V,
# device_class=voltage, accuracy_decimals=2
- platform: emontx
tag_name: V1
name: Voltage 1
emontx_id: test_emontx
# Current sensor (I prefix): expects state_class=measurement, unit=A,
# device_class=current, accuracy_decimals=2
- platform: emontx
tag_name: I1
name: Current 1
emontx_id: test_emontx
# Temperature sensor (T prefix): expects state_class=measurement, unit=°C,
# device_class=temperature, accuracy_decimals=2
- platform: emontx
tag_name: T1
name: Temperature 1
emontx_id: test_emontx
# Pulse sensor (PULSE pattern): expects state_class=total_increasing,
# unit=pulses, device_class=energy, accuracy_decimals=0
- platform: emontx
tag_name: PULSE1
name: Pulse 1
emontx_id: test_emontx
# Power factor sensor (PF pattern): expects state_class=measurement,
# device_class=power_factor, accuracy_decimals=2
- platform: emontx
tag_name: PF1
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
tag_name: CUSTOM1
name: Custom sensor
emontx_id: test_emontx
# User override: verify that explicit values are respected and not clobbered
- platform: emontx
tag_name: E2
name: Energy 2 (user override)
emontx_id: test_emontx
state_class: measurement
accuracy_decimals: 3
+1 -1
View File
@@ -7,7 +7,7 @@ esp32:
enable_lwip_mdns_queries: true
enable_lwip_bridge_interface: true
disable_libc_locks_in_iram: false # Test explicit opt-out of RAM optimization
use_full_certificate_bundle: false # Test CMN bundle (default)
use_full_certificate_bundle: false # Bundle stays off without a component that needs it
include_builtin_idf_components:
- freertos # Test escape hatch (freertos is always included anyway)
enable_full_printf: false
@@ -6,3 +6,6 @@ update:
type: embedded
path: $component_dir/test_firmware.bin
sha256: de2f256064a0af797747c2b97505dc0b9f3df0de4f489eac731c23ae9ca9cc31
on_update_available:
then:
- logger.log: "Coprocessor update available"
@@ -8,3 +8,6 @@ update:
type: http
source: https://esphome.github.io/esp-hosted-firmware/manifest/esp32c6.json
update_interval: 6h
on_update_available:
then:
- logger.log: "Coprocessor update available"
@@ -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
+58
View File
@@ -1214,6 +1214,64 @@ lvgl:
id: checkbox_id
text: Checkbox
align: bottom_right
- list:
id: test_list_id
align: top_right
width: 150px
height: 120px
pad_row: 4
on_add:
- logger.log:
format: "list entry added at %d"
args: [list_index]
on_remove:
- logger.log:
format: "list entry removed at %d"
args: [list_index]
on_click:
- lvgl.list.add_text:
id: test_list_id
text: !lambda return "Section";
- lvgl.list.add_text:
id: test_list_id
text: "Pinned section"
index: 0
- lvgl.list.add:
id: test_list_id
button:
text: "Entry"
checkable: true
- lvgl.list.add:
id: test_list_id
index: 1
obj:
widgets:
- label:
text: !lambda return "Dynamic row " + std::to_string(millis());
- button:
widgets:
- label:
text: "Tap"
on_click:
- lambda: |-
ESP_LOGD("lvgl", "dynamic row button clicked, row %d",
lvgl::lv_list_get_row_index(id(test_list_id), static_cast<lv_obj_t *>(lv_event_get_target(event))));
- dropdown:
options:
- "One"
- "Two"
on_value:
- lambda: |-
ESP_LOGD("lvgl", "dynamic row dropdown changed, row %d",
lvgl::lv_list_get_row_index(id(test_list_id), static_cast<lv_obj_t *>(lv_event_get_target(event))));
- lvgl.list.remove:
id: test_list_id
index: 0
- lvgl.list.clear:
id: test_list_id
- lvgl.list.update:
id: test_list_id
pad_row: 8
- slider:
id: slider_id
align: top_mid
+73
View File
@@ -0,0 +1,73 @@
esphome:
name: lvgl-list-validate
host:
logger:
display:
- platform: sdl
id: sdl0
dimensions:
width: 320
height: 240
lvgl:
displays: sdl0
widgets:
# Two independent lists, each with their own on_add/on_remove and, for list_a,
# more than one automation under the same trigger key -- checks that the
# per-list trigger bookkeeping is keyed correctly and doesn't require exactly
# one automation.
- list:
id: validate_list_a
align: center
pad_row: 6
on_add:
- logger.log:
format: "a: added %d"
args: [list_index]
- logger.log:
format: "a: also added %d"
args: [list_index]
on_remove:
- logger.log:
format: "a: removed %d"
args: [list_index]
on_boot:
# lvgl.list.add_text and lvgl.list.add both take an optional, templatable index.
- lvgl.list.add_text:
id: validate_list_a
text: "Header"
index: !lambda return 0;
# any registered widget type is valid as the single lvgl.list.add key.
- lvgl.list.add:
id: validate_list_a
checkbox:
align: center
text: "Option"
- lvgl.list.add:
id: validate_list_a
index: !lambda return 0;
switch:
align: center
- lvgl.list.add:
id: validate_list_a
spinner:
align: center
- lvgl.list.add:
id: validate_list_a
obj:
align: center
- lvgl.list.remove:
id: validate_list_a
index: !lambda return 0;
- lvgl.list.clear:
id: validate_list_a
- list:
id: validate_list_b
align: center
on_remove:
- logger.log:
format: "b: removed %d"
args: [list_index]
@@ -1,165 +1,112 @@
#include <array>
#include <utility>
#include "../common.h"
#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h"
namespace esphome::mitsubishi_cn105::testing {
struct MitsubishiCN105ClimateTestContext {
MitsubishiCN105Component component;
MitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext() { this->sut.set_parent(&this->component); }
};
TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpectedValues) {
MitsubishiCN105ClimateTestContext context;
for (int temperature = 16; temperature <= 31; ++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();
EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::CELSIUS);
EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 16.0f);
EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 31.0f);
EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 1.0f);
EXPECT_FLOAT_EQ(traits.get_visual_current_temperature_step(), 0.5f);
}
TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingAndTraitsMatchExpectedValues) {
MitsubishiCN105ClimateTestContext context;
context.component.set_use_fahrenheit(true);
const std::array cases{
std::pair{61, 16.0f}, std::pair{62, 16.5f}, std::pair{63, 17.0f}, std::pair{64, 17.5f}, std::pair{65, 18.0f},
std::pair{66, 18.5f}, std::pair{67, 19.0f}, std::pair{68, 20.0f}, std::pair{69, 21.0f}, std::pair{70, 21.5f},
std::pair{71, 22.0f}, std::pair{72, 22.5f}, std::pair{73, 23.0f}, std::pair{74, 23.5f}, std::pair{75, 24.0f},
std::pair{76, 24.5f}, std::pair{77, 25.0f}, std::pair{78, 25.5f}, std::pair{79, 26.0f}, std::pair{80, 26.5f},
std::pair{81, 27.0f}, std::pair{82, 27.5f}, std::pair{83, 28.0f}, std::pair{84, 28.5f}, std::pair{85, 29.0f},
std::pair{86, 29.5f}, std::pair{87, 30.0f}, std::pair{88, 30.5f},
};
for (const auto &[fahrenheit, mitsubishi_celsius] : cases) {
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);
EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 61.0f);
EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 88.0f);
EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 1.0f);
EXPECT_FLOAT_EQ(traits.get_visual_current_temperature_step(), 1.0f);
}
TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingUsesLinearConversionOutsideSetpointRange) {
auto mapping = TemperatureMapping();
mapping.set_use_fahrenheit(true);
const std::array cases{
std::pair{0.0f, 32.0f}, std::pair{10.0f, 50.0f}, std::pair{15.5f, 59.9f},
std::pair{31.0f, 87.8f}, std::pair{35.0f, 95.0f}, std::pair{40.0f, 104.0f},
};
for (const auto &[celsius, fahrenheit] : cases) {
EXPECT_FLOAT_EQ(mapping.from_mitsubishi(celsius), fahrenheit);
}
}
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeOffLeavesTraitsEmpty) {
TestableMitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext context;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_OFF);
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_OFF);
EXPECT_FALSE(sut.traits().get_supports_swing_modes());
EXPECT_FALSE(context.sut.traits().get_supports_swing_modes());
}
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeVerticalExposesOffAndVertical) {
TestableMitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext context;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
}
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeHorizontalExposesOffAndHorizontal) {
TestableMitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext context;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
}
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeBothExposesAllExpectedModes) {
TestableMitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext context;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsVerticalSwingWhenSupported) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_VERTICAL);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsHorizontalSwingWhenSupported) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
sut.status().vane_mode = MitsubishiCN105::VaneMode::AUTO;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_HORIZONTAL);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsBothSwingWhenSupported) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_BOTH);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsSwingOffWhenNoSwingActive) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
sut.status().vane_mode = MitsubishiCN105::VaneMode::POSITION_3;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesRemembersLastNonSwingPositions) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
sut.status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::RIGHT;
sut.apply_values_();
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_4);
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::RIGHT);
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
sut.apply_values_();
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_4);
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::RIGHT);
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_BOTH);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesDoesNotOverwriteRememberedPositionWithUnknownValues) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
sut.last_non_swing_vane_mode_ = MitsubishiCN105::VaneMode::POSITION_2;
sut.last_non_swing_wide_vane_mode_ = MitsubishiCN105::WideVaneMode::LEFT;
sut.status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::UNKNOWN;
sut.apply_values_();
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_2);
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::LEFT);
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesIgnoresUnsupportedVerticalSwingState) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesIgnoresUnsupportedHorizontalSwingState) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
sut.status().vane_mode = MitsubishiCN105::VaneMode::AUTO;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
}
} // namespace esphome::mitsubishi_cn105::testing
@@ -0,0 +1,99 @@
#include "../common.h"
#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_swing_mode_manager.h"
namespace esphome::mitsubishi_cn105::testing {
static SwingModeManager make_swing_mode_manager(std::initializer_list<climate::ClimateSwingMode> supported_modes) {
SwingModeManager manager;
climate::ClimateSwingModeMask supported_swing_modes;
for (const auto mode : supported_modes)
supported_swing_modes.insert(mode);
manager.set_supported_swing_modes(supported_swing_modes);
return manager;
}
TEST(SwingModeManagerTests, StatusMapsVerticalSwingWhenSupported) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL});
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::CENTER),
std::optional{climate::CLIMATE_SWING_VERTICAL});
}
TEST(SwingModeManagerTests, StatusMapsHorizontalSwingWhenSupported) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL});
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::WideVaneMode::SWING),
std::optional{climate::CLIMATE_SWING_HORIZONTAL});
}
TEST(SwingModeManagerTests, StatusMapsBothSwingWhenSupported) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::SWING),
std::optional{climate::CLIMATE_SWING_BOTH});
}
TEST(SwingModeManagerTests, StatusMapsSwingOffWhenNoSwingActive) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
EXPECT_EQ(
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::POSITION_3, MitsubishiCN105::WideVaneMode::CENTER),
std::optional{climate::CLIMATE_SWING_OFF});
}
TEST(SwingModeManagerTests, RemembersLastNonSwingPositions) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::WideVaneMode::RIGHT);
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::SWING);
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::VaneMode::POSITION_4});
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::WideVaneMode::RIGHT});
}
TEST(SwingModeManagerTests, UnknownValuesDoNotOverwriteRememberedPositions) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::POSITION_2, MitsubishiCN105::WideVaneMode::LEFT);
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::UNKNOWN, MitsubishiCN105::WideVaneMode::UNKNOWN);
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::VaneMode::POSITION_2});
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::WideVaneMode::LEFT});
}
TEST(SwingModeManagerTests, UnsupportedVerticalSwingStateIsIgnored) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL});
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::CENTER),
std::optional{climate::CLIMATE_SWING_OFF});
}
TEST(SwingModeManagerTests, UnsupportedHorizontalSwingStateIsIgnored) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL});
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::WideVaneMode::SWING),
std::optional{climate::CLIMATE_SWING_OFF});
}
TEST(SwingModeManagerTests, SwingModeFromReturnsNulloptWhenNoSwingModesSupported) {
auto manager = make_swing_mode_manager({});
EXPECT_FALSE(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::SWING)
.has_value());
}
TEST(SwingModeManagerTests, VaneFromSwingModeReturnsNulloptWhenVerticalUnsupported) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL});
EXPECT_FALSE(manager.vane_from(climate::CLIMATE_SWING_VERTICAL).has_value());
}
TEST(SwingModeManagerTests, WideVaneFromSwingModeReturnsNulloptWhenHorizontalUnsupported) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL});
EXPECT_FALSE(manager.wide_vane_from(climate::CLIMATE_SWING_HORIZONTAL).has_value());
}
TEST(SwingModeManagerTests, VaneAndWideVaneFromSwingModeMapSwingModes) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_VERTICAL), std::optional{MitsubishiCN105::VaneMode::SWING});
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_BOTH), std::optional{MitsubishiCN105::VaneMode::SWING});
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_HORIZONTAL),
std::optional{MitsubishiCN105::WideVaneMode::SWING});
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_BOTH), std::optional{MitsubishiCN105::WideVaneMode::SWING});
}
} // namespace esphome::mitsubishi_cn105::testing
@@ -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;
@@ -64,25 +64,4 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 {
void set_current_time(uint32_t ms) { test_loop_time_ms = ms; }
};
class TestableMitsubishiCN105Climate : public MitsubishiCN105Climate {
public:
TestableMitsubishiCN105Climate() { this->set_parent(&this->component_); }
using MitsubishiCN105Climate::apply_values_;
using MitsubishiCN105Climate::last_non_swing_vane_mode_;
using MitsubishiCN105Climate::last_non_swing_wide_vane_mode_;
MitsubishiCN105::Status &status() { return const_cast<MitsubishiCN105::Status &>(this->component_.status()); }
protected:
MitsubishiCN105Component component_;
};
class TestableMitsubishiCN105Component : public MitsubishiCN105Component {
public:
MitsubishiCN105::Status &mutable_status() { return const_cast<MitsubishiCN105::Status &>(this->status()); }
void notify_status() { this->status_callback_.call(); }
};
} // namespace esphome::mitsubishi_cn105::testing
@@ -3,6 +3,7 @@ mitsubishi_cn105:
uart_id: uart_bus
update_interval: 30s
telemetry_request_min_interval: 120s
use_fahrenheit: true
vane:
on_state:
- logger.log:
@@ -3,7 +3,7 @@
namespace esphome::mitsubishi_cn105::testing {
TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) {
TestableMitsubishiCN105Component hub;
MitsubishiCN105Component hub;
size_t callback_count = 0;
std::optional<VerticalVaneMode> callback_direction;
hub.add_on_vane_state_callback([&](const VaneState &state) {
@@ -11,8 +11,9 @@ TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) {
callback_direction = state.vertical.direction;
});
hub.mutable_status().room_temperature = 20.0f;
hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4;
hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
hub.set_target_temperature(20.0f);
hub.set_vane_mode(MitsubishiCN105::VaneMode::POSITION_4);
hub.publish_status();
EXPECT_EQ(callback_count, 1);
@@ -25,7 +26,7 @@ TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) {
}
TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) {
TestableMitsubishiCN105Component hub;
MitsubishiCN105Component hub;
size_t status_callback_count = 0;
size_t vane_callback_count = 0;
std::optional<VerticalVaneMode> callback_direction;
@@ -35,15 +36,16 @@ TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) {
callback_direction = state.vertical.direction;
});
hub.mutable_status().room_temperature = 20.0f;
hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN;
hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
hub.set_target_temperature(20.0f);
ASSERT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::UNKNOWN);
hub.publish_status();
EXPECT_EQ(status_callback_count, 1);
EXPECT_EQ(vane_callback_count, 1);
EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_UNKNOWN});
hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4;
hub.set_vane_mode(MitsubishiCN105::VaneMode::POSITION_4);
hub.publish_status();
EXPECT_EQ(status_callback_count, 2);
@@ -52,7 +54,7 @@ TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) {
}
TEST(MitsubishiCN105ComponentTests, VaneCallAppliesVerticalDirection) {
TestableMitsubishiCN105Component hub;
MitsubishiCN105Component hub;
auto call = hub.make_vane_call();
call.vertical.set_direction(VERTICAL_VANE_MODE_POSITION_5);
@@ -62,12 +64,11 @@ TEST(MitsubishiCN105ComponentTests, VaneCallAppliesVerticalDirection) {
}
TEST(MitsubishiCN105ComponentTests, VaneControlActionAppliesConfiguredFields) {
TestableMitsubishiCN105Component hub;
MitsubishiCN105Component hub;
VaneControlAction<> action(&hub, [](VaneCall &call) { call.vertical.set_direction(VERTICAL_VANE_MODE_SWING); });
action.play();
EXPECT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::SWING);
}
} // namespace esphome::mitsubishi_cn105::testing
@@ -3,14 +3,9 @@
namespace esphome::mitsubishi_cn105::testing {
class TestableMitsubishiCN105VerticalVaneDirectionSelect : public MitsubishiCN105VerticalVaneDirectionSelect {
public:
using MitsubishiCN105VerticalVaneDirectionSelect::control;
};
struct VerticalVaneDirectionSelectTestContext {
TestableMitsubishiCN105Component hub;
TestableMitsubishiCN105VerticalVaneDirectionSelect select;
MitsubishiCN105Component hub;
MitsubishiCN105VerticalVaneDirectionSelect select;
VerticalVaneDirectionSelectTestContext() {
this->select.traits.set_options({"Auto", "1", "2", "3", "4", "5", "Swing"});
@@ -31,13 +26,15 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, MapsIndexesToVaneModes) {
for (size_t i = 0; i < expected_modes.size(); ++i) {
SCOPED_TRACE(i);
ctx.select.control(i);
ctx.select.make_call().set_index(i).perform();
EXPECT_EQ(ctx.hub.status().vane_mode, expected_modes[i]);
}
}
TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, PublishesIncomingVaneModes) {
VerticalVaneDirectionSelectTestContext ctx;
ctx.hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
ctx.hub.set_target_temperature(20.0f);
constexpr std::array modes{
MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1,
@@ -48,13 +45,12 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, PublishesIncomingVaneModes
for (size_t i = 0; i < modes.size(); ++i) {
SCOPED_TRACE(i);
ctx.hub.mutable_status().vane_mode = modes[i];
ctx.hub.notify_status();
ctx.hub.set_vane_mode(modes[i]);
ctx.hub.publish_status();
EXPECT_EQ(ctx.select.active_index(), std::optional{i});
}
ctx.hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN;
ctx.hub.notify_status();
ctx.select.publish_vane_state(MitsubishiCN105::VaneMode::UNKNOWN);
EXPECT_EQ(ctx.select.active_index(), std::optional{modes.size() - 1});
}
@@ -64,14 +60,15 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ControlPublishesSelectAndC
climate_entity.set_parent(&ctx.hub);
climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
ctx.hub.mutable_status().room_temperature = 20.0f;
ctx.hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
ctx.hub.set_target_temperature(20.0f);
climate_entity.setup();
ctx.select.control(6);
ctx.select.make_call().set_index(6).perform();
EXPECT_EQ(ctx.select.active_index(), std::optional<size_t>{6});
EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_VERTICAL);
ctx.select.control(3);
ctx.select.make_call().set_index(3).perform();
EXPECT_EQ(ctx.select.active_index(), std::optional<size_t>{3});
EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_OFF);
}
@@ -82,7 +79,8 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ClimateControlPublishesSel
climate_entity.set_parent(&ctx.hub);
climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
ctx.hub.mutable_status().room_temperature = 20.0f;
ctx.hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
ctx.hub.set_target_temperature(20.0f);
climate_entity.setup();
climate_entity.make_call().set_swing_mode(climate::CLIMATE_SWING_VERTICAL).perform();
@@ -95,10 +93,9 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ClimateControlPublishesSel
TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, BeforeInitializationDoesNotPublishSelectState) {
VerticalVaneDirectionSelectTestContext ctx;
ctx.select.control(3);
ctx.select.make_call().set_index(3).perform();
EXPECT_EQ(ctx.hub.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_3);
EXPECT_FALSE(ctx.select.has_state());
}
} // namespace esphome::mitsubishi_cn105::testing
@@ -322,14 +322,14 @@ TEST(ModbusClientHubPriority, ContinuousReadRequeuesOnSuccessOnly) {
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
EXPECT_TRUE(hub.queued(0).options.continuous);
hub.force_send_next();
// A matching successful response cycles the continuous entry back to READY.
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
EXPECT_TRUE(hub.queued(0).options.continuous);
// An exception response ends the poll.
hub.force_send_next();
@@ -346,13 +346,13 @@ TEST(ModbusClientHubPriority, RetriedContinuousReadStaysContinuous) {
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_TRUE(hub.queued(0).continuous);
ASSERT_TRUE(hub.queued(0).options.continuous);
hub.force_send_next();
hub.timeout_waiting(); // no response -> device requests retry
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous); // the retried poll stays continuous
EXPECT_TRUE(hub.queued(0).options.continuous); // the retried poll stays continuous
}
// A one-shot duplicate downgrades a continuous poll to a one-shot (the mirror of a continuous
@@ -363,16 +363,16 @@ TEST(ModbusClientHubPriority, DuplicateSendDowngradesContinuous) {
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_TRUE(hub.queued(0).continuous);
ASSERT_TRUE(hub.queued(0).options.continuous);
device.read_holding_registers(0x100, 2); // one-shot duplicate downgrades the poll
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_FALSE(hub.queued(0).continuous);
EXPECT_FALSE(hub.queued(0).options.continuous);
EXPECT_EQ(hub.queued(0).pending, 1u);
// It runs one more cycle to serve the request, then stops - not re-queued as a poll.
hub.force_send_next();
EXPECT_FALSE(hub.waiting_command().continuous);
EXPECT_FALSE(hub.waiting_command().options.continuous);
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response);
EXPECT_EQ(hub.queued_frames(), 0u);
@@ -407,16 +407,16 @@ TEST(ModbusClientHubPriority, DowngradeAfterTerminalKeepsRequestAlive) {
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_TRUE(hub.queued(0).continuous);
ASSERT_TRUE(hub.queued(0).options.continuous);
hub.force_send_next();
const uint8_t exception_response[] = {0x83, 0x02};
hub.receive_frame_for_test(0x02, exception_response); // exception ends the poll; on_error re-sends
EXPECT_EQ(device.error_count_, 1); // one terminal delivered so far
ASSERT_EQ(hub.queued_frames(), 1u); // the re-send survived the sweep instead of being erased
EXPECT_FALSE(hub.queued(0).continuous); // downgraded to a one-shot
EXPECT_EQ(hub.queued(0).pending, 1u); // debt restored so the request runs
EXPECT_EQ(device.error_count_, 1); // one terminal delivered so far
ASSERT_EQ(hub.queued_frames(), 1u); // the re-send survived the sweep instead of being erased
EXPECT_FALSE(hub.queued(0).options.continuous); // downgraded to a one-shot
EXPECT_EQ(hub.queued(0).pending, 1u); // debt restored so the request runs
// And it runs to its own terminal - a good response this time - then the entry is gone.
hub.force_send_next();
@@ -434,18 +434,18 @@ TEST(ModbusClientHubPriority, ContinuousRequestUpgradesQueuedDuplicate) {
device.read_holding_registers(0x100, 2);
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_FALSE(hub.queued(0).continuous);
ASSERT_FALSE(hub.queued(0).options.continuous);
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
EXPECT_TRUE(hub.queued(0).options.continuous);
// And it behaves as a poll from here: success cycles it back to READY.
hub.force_send_next();
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
EXPECT_TRUE(hub.queued(0).options.continuous);
}
// The transmit order is one key with three levels: writes, then one-shot reads, then continuous
@@ -473,7 +473,7 @@ TEST(ModbusClientHubPriority, WritesThenOneShotReadsThenContinuousPolls) {
EXPECT_EQ(hub.waiting_command().frame.pdu()[1], 0x02); // then the one-shot read
hub.timeout_waiting();
hub.force_send_next();
EXPECT_TRUE(hub.waiting_command().continuous); // and the poll takes what is left
EXPECT_TRUE(hub.waiting_command().options.continuous); // and the poll takes what is left
}
// continuous is ignored for writes: the frame still sends at WRITE priority, once.
@@ -485,7 +485,7 @@ TEST(ModbusClientHubPriority, ContinuousIgnoredForWrites) {
device.queue_pdu(write_pdu, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE);
EXPECT_FALSE(hub.queued(0).continuous);
EXPECT_FALSE(hub.queued(0).options.continuous);
}
// A queued continuous poll does not count against immediate-send readiness: it ranks below every
@@ -496,7 +496,7 @@ TEST(ModbusClientHubPriority, ContinuousPollDoesNotBlockImmediateSend) {
EXPECT_TRUE(hub.tx_buffer_empty()); // nothing queued
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_TRUE(hub.queued(0).continuous);
ASSERT_TRUE(hub.queued(0).options.continuous);
EXPECT_TRUE(hub.tx_buffer_empty()); // a READY continuous poll still leaves room to send now
device.read_holding_registers(0x200, 2); // a one-shot does count
@@ -1878,8 +1878,8 @@ TEST(ModbusClientHubPriority, ResendFromOnResponseAbsorbsIntoCompletingCommand)
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response); // handler re-sends the identical frame mid-completion
ASSERT_EQ(hub.queued_frames(), 1u); // absorbed into the same entry, not a fresh twin
EXPECT_FALSE(hub.queued(0).continuous); // the one-shot re-send downgraded the poll
ASSERT_EQ(hub.queued_frames(), 1u); // absorbed into the same entry, not a fresh twin
EXPECT_FALSE(hub.queued(0).options.continuous); // the one-shot re-send downgraded the poll
}
// An exception-flagged function code is never silently re-sendable, even though the read check
@@ -51,6 +51,7 @@ button:
# A pdu lambda can hand-assemble bytes or return a modbus::helpers::create_*_pdu() builder result.
- modbus_client.send:
address: 0x01
continuous: true
pdu: !lambda "return modbus::helpers::create_read_pdu(modbus::FunctionCode::READ_HOLDING_REGISTERS, 0x0010, 1);"
- modbus_client.send:
address: !lambda "return 1;"
@@ -91,6 +92,7 @@ button:
address: !lambda "return 1;"
start_address: 0x10
count: 2
continuous: true
on_response:
then:
- lambda: 'ESP_LOGI("modbus_client.test", "first=%u n=%u", values[0], (unsigned) values.size());'
@@ -98,6 +100,7 @@ button:
then:
- logger.log: "typed read timeout"
- modbus_client.read_input_registers:
continuous: !lambda "return false;"
address: 0x01
start_address: 0x20
on_custom_response:
@@ -113,12 +116,14 @@ button:
address: 0x01
start_address: 0x03
count: 16
continuous: true
on_response:
then:
- lambda: 'ESP_LOGI("modbus_client.test", "coil0=%d n=%u", bits[0], (unsigned) bits.size());'
- modbus_client.read_discrete_inputs:
address: 0x01
start_address: 0x00
continuous: true
on_error:
then:
- lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);'
@@ -2,6 +2,7 @@ modbus_controller:
- id: modbus_controller1
address: 0x2
modbus_id: modbus_bus
continuous: true
on_online:
then:
logger.log: "Module Online"
@@ -108,6 +109,22 @@ select:
return value;
sensor:
# custom_pdu polls a ready-made PDU (function code + data - no device address byte, no CRC); covers
# the set_custom_pdu codegen path and the custom-range polling constructor.
- platform: modbus_controller
modbus_controller_id: modbus_controller1
id: modbus_sensor_custom_pdu
name: Test Custom PDU Sensor
custom_pdu: [0x03, 0x00, 0x2A, 0x00, 0x01]
value_type: U_WORD
# Deprecated custom_command (leading byte 0x02 == modbus_controller1's address) drives the
# migrate_custom_command final-validate auto-migration path in CI.
- platform: modbus_controller
modbus_controller_id: modbus_controller1
id: modbus_sensor_custom_command
name: Test Custom Command Sensor
custom_command: [0x02, 0x03, 0x00, 0x2B, 0x00, 0x01]
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller1
id: modbus_sensor1
@@ -2,3 +2,4 @@
network:
enable_high_performance: true
tcp_send_buffer: 32kB
+7
View File
@@ -0,0 +1,7 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# to_code must run: it defines USE_NOISE and adds the noise-c library
# the component sources under test need.
manifest.enable_codegen()
+1
View File
@@ -0,0 +1 @@
noise:
@@ -0,0 +1,2 @@
packages:
noise: !include common.yaml
@@ -0,0 +1,2 @@
packages:
noise: !include common.yaml
+2
View File
@@ -0,0 +1,2 @@
packages:
noise: !include common.yaml
@@ -0,0 +1,2 @@
packages:
noise: !include common.yaml
@@ -0,0 +1,199 @@
#include <gtest/gtest.h>
#include <cstring>
#include <noise/protocol.h>
#include "esphome/components/noise/noise.h"
#include "esphome/components/noise/noise_handshake.h"
namespace esphome::noise::testing {
using Action = NoiseResponderHandshake::Action;
// A raw noise-c initiator driving the same Noise_NNpsk0_25519_ChaChaPoly_SHA256
// pattern the responder class implements, so the tests exercise a real
// two-message handshake rather than mirrored calls into the class under test.
class Initiator {
public:
Initiator(const psk_t &psk, const uint8_t *prologue, size_t prologue_len) {
const NoiseProtocolId nid = {
.prefix_id = NOISE_PREFIX_STANDARD,
.pattern_id = NOISE_PATTERN_NN,
.modifier_ids = {NOISE_MODIFIER_PSK0},
.dh_id = NOISE_DH_CURVE25519,
.cipher_id = NOISE_CIPHER_CHACHAPOLY,
.hash_id = NOISE_HASH_SHA256,
.hybrid_id = NOISE_DH_NONE,
};
EXPECT_EQ(noise_handshakestate_new_by_id(&this->state_, &nid, NOISE_ROLE_INITIATOR), 0);
EXPECT_EQ(noise_handshakestate_set_pre_shared_key(this->state_, psk.data(), psk.size()), 0);
EXPECT_EQ(noise_handshakestate_set_prologue(this->state_, prologue, prologue_len), 0);
EXPECT_EQ(noise_handshakestate_start(this->state_), 0);
}
~Initiator() {
if (this->state_ != nullptr)
noise_handshakestate_free(this->state_);
if (this->send_ != nullptr)
noise_cipherstate_free(this->send_);
if (this->recv_ != nullptr)
noise_cipherstate_free(this->recv_);
}
Initiator(const Initiator &) = delete;
Initiator &operator=(const Initiator &) = delete;
size_t write_message(uint8_t *out, size_t capacity) {
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_output(mbuf, out, capacity);
EXPECT_EQ(noise_handshakestate_write_message(this->state_, &mbuf, nullptr), 0);
return mbuf.size;
}
int read_message(uint8_t *data, size_t len) {
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_input(mbuf, data, len);
return noise_handshakestate_read_message(this->state_, &mbuf, nullptr);
}
void split() { EXPECT_EQ(noise_handshakestate_split(this->state_, &this->send_, &this->recv_), 0); }
NoiseCipherState *send_{nullptr};
NoiseCipherState *recv_{nullptr};
private:
NoiseHandshakeState *state_{nullptr};
};
static const uint8_t PROLOGUE[] = {'t', 'e', 's', 't', 'p', 'r', 'o', 'l', 'o', 'g', 'u', 'e'};
static psk_t make_psk(uint8_t seed) {
psk_t psk;
for (size_t i = 0; i < psk.size(); i++) {
psk[i] = static_cast<uint8_t>(seed + i);
}
return psk;
}
TEST(NoiseResponderHandshakeTest, ActionFailedBeforeInit) {
NoiseResponderHandshake handshake;
EXPECT_EQ(handshake.action(), Action::ACTION_FAILED);
}
TEST(NoiseResponderHandshakeTest, MessageMethodsErrorBeforeInit) {
// The class doc promises a noise-c error, not a crash, when the message
// methods run outside their action() step; pin the library's null check
NoiseResponderHandshake handshake;
uint8_t buf[MAX_HANDSHAKE_SIZE] = {};
size_t out_len = 0;
EXPECT_NE(handshake.read_message(buf, sizeof(buf)), 0);
EXPECT_NE(handshake.write_message(buf, sizeof(buf), out_len), 0);
// Deliberately non-null: split() documents a nullptr postcondition on
// error, so a caller's uninitialized locals never hold garbage to free
auto *sentinel = reinterpret_cast<NoiseCipherState *>(0x1);
NoiseCipherState *send_cipher = sentinel;
NoiseCipherState *recv_cipher = sentinel;
EXPECT_NE(handshake.split(send_cipher, recv_cipher), 0);
EXPECT_EQ(send_cipher, nullptr);
EXPECT_EQ(recv_cipher, nullptr);
}
TEST(NoiseResponderHandshakeTest, FullHandshakeAndTransportRoundTrip) {
const psk_t psk = make_psk(7);
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0);
EXPECT_EQ(responder.action(), Action::ACTION_READ);
Initiator initiator(psk, PROLOGUE, sizeof(PROLOGUE));
uint8_t msg[MAX_HANDSHAKE_SIZE];
size_t msg_len = initiator.write_message(msg, sizeof(msg));
ASSERT_GT(msg_len, 0u);
ASSERT_EQ(responder.read_message(msg, msg_len), 0);
ASSERT_EQ(responder.action(), Action::ACTION_WRITE);
size_t reply_len = 0;
ASSERT_EQ(responder.write_message(msg, sizeof(msg), reply_len), 0);
ASSERT_GT(reply_len, 0u);
ASSERT_EQ(responder.action(), Action::ACTION_SPLIT);
ASSERT_EQ(initiator.read_message(msg, reply_len), 0);
initiator.split();
NoiseCipherState *send_cipher = nullptr;
NoiseCipherState *recv_cipher = nullptr;
ASSERT_EQ(responder.split(send_cipher, recv_cipher), 0);
ASSERT_NE(send_cipher, nullptr);
ASSERT_NE(recv_cipher, nullptr);
// The handshake state is released by split(); the class reports FAILED after
EXPECT_EQ(responder.action(), Action::ACTION_FAILED);
EXPECT_EQ(static_cast<size_t>(noise_cipherstate_get_mac_length(send_cipher)), MAC_SIZE);
// Responder encrypts, initiator decrypts
uint8_t frame[64];
static constexpr char PLAINTEXT[] = "encrypted ota";
std::memcpy(frame, PLAINTEXT, sizeof(PLAINTEXT));
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_inout(mbuf, frame, sizeof(PLAINTEXT), sizeof(frame));
ASSERT_EQ(noise_cipherstate_encrypt(send_cipher, &mbuf), 0);
EXPECT_EQ(mbuf.size, sizeof(PLAINTEXT) + MAC_SIZE);
noise_buffer_set_inout(mbuf, frame, mbuf.size, sizeof(frame));
ASSERT_EQ(noise_cipherstate_decrypt(initiator.recv_, &mbuf), 0);
ASSERT_EQ(mbuf.size, sizeof(PLAINTEXT));
EXPECT_EQ(std::memcmp(frame, PLAINTEXT, sizeof(PLAINTEXT)), 0);
noise_cipherstate_free(send_cipher);
noise_cipherstate_free(recv_cipher);
}
TEST(NoiseResponderHandshakeTest, ReInitRestartsHandshake) {
// The documented retry shape: a repeated init() frees the previous state
// and starts over. The first message under the new key authenticating
// proves the restart took effect; the old state surviving would fail the
// MAC here.
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0);
ASSERT_EQ(responder.init(make_psk(9), PROLOGUE, sizeof(PROLOGUE)), 0);
EXPECT_EQ(responder.action(), Action::ACTION_READ);
Initiator initiator(make_psk(9), PROLOGUE, sizeof(PROLOGUE));
uint8_t msg[MAX_HANDSHAKE_SIZE];
size_t msg_len = initiator.write_message(msg, sizeof(msg));
ASSERT_GT(msg_len, 0u);
EXPECT_EQ(responder.read_message(msg, msg_len), 0);
}
TEST(NoiseResponderHandshakeTest, WrongPskFailsWithMacFailure) {
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0);
Initiator initiator(make_psk(200), PROLOGUE, sizeof(PROLOGUE));
uint8_t msg[MAX_HANDSHAKE_SIZE];
size_t msg_len = initiator.write_message(msg, sizeof(msg));
ASSERT_GT(msg_len, 0u);
int err = responder.read_message(msg, msg_len);
EXPECT_EQ(err, NOISE_ERROR_MAC_FAILURE);
EXPECT_EQ(responder.action(), Action::ACTION_FAILED);
}
TEST(NoiseResponderHandshakeTest, MismatchedPrologueFailsWithMacFailure) {
// The prologue binds the plaintext preamble for downgrade resistance; a
// tampered preamble must fail even with the right key.
const psk_t psk = make_psk(7);
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0);
static const uint8_t TAMPERED[] = {'x'};
Initiator initiator(psk, TAMPERED, sizeof(TAMPERED));
uint8_t msg[MAX_HANDSHAKE_SIZE];
size_t msg_len = initiator.write_message(msg, sizeof(msg));
ASSERT_GT(msg_len, 0u);
EXPECT_EQ(responder.read_message(msg, msg_len), NOISE_ERROR_MAC_FAILURE);
}
} // namespace esphome::noise::testing
@@ -0,0 +1,74 @@
#include <gtest/gtest.h>
#include <cstring>
#include <noise/protocol.h>
#include "esphome/components/noise/noise.h"
namespace esphome::noise::testing {
TEST(NoiseContextTest, AllZerosPskIsReserved) {
psk_t zeros{};
EXPECT_TRUE(NoiseContext::is_all_zeros(zeros));
psk_t psk{};
psk[31] = 1;
EXPECT_FALSE(NoiseContext::is_all_zeros(psk));
NoiseContext ctx;
EXPECT_FALSE(ctx.has_psk());
ctx.set_psk(zeros);
EXPECT_FALSE(ctx.has_psk());
ctx.set_psk(psk);
EXPECT_TRUE(ctx.has_psk());
EXPECT_EQ(ctx.get_psk(), psk);
}
TEST(WireFormatTest, FrameHeaderIsIndicatorPlusBigEndianLength) {
uint8_t header[FRAME_HEADER_SIZE];
write_frame_header(header, 0x1234);
EXPECT_EQ(header[0], FRAME_INDICATOR);
EXPECT_EQ(header[1], 0x12);
EXPECT_EQ(header[2], 0x34);
}
TEST(WireFormatTest, RejectPayloadCarriesStatusByteAndMacFailureContract) {
// The MAC failure string is a wire contract: clients match it to report a
// wrong key. Format the payload exactly the way the handshake read path does.
uint8_t buf[64];
size_t len = format_reject_payload(buf, sizeof(buf), reject_reason_for(NOISE_ERROR_MAC_FAILURE));
static constexpr char EXPECTED[] = "Handshake MAC failure";
ASSERT_EQ(len, 1 + strlen(EXPECTED));
EXPECT_EQ(buf[0], HANDSHAKE_STATUS_REJECT);
EXPECT_EQ(memcmp(buf + 1, EXPECTED, strlen(EXPECTED)), 0);
// The exported floor covers the full MAC failure payload exactly
EXPECT_EQ(MAC_FAILURE_PAYLOAD_SIZE, 1 + strlen(EXPECTED));
// Any other error maps to the generic reason
len = format_reject_payload(buf, sizeof(buf), reject_reason_for(NOISE_ERROR_INVALID_STATE));
static constexpr char GENERIC[] = "Handshake error";
ASSERT_EQ(len, 1 + strlen(GENERIC));
EXPECT_EQ(memcmp(buf + 1, GENERIC, strlen(GENERIC)), 0);
}
TEST(WireFormatTest, RejectPayloadTruncatesToCapacity) {
uint8_t buf[8];
size_t len = format_reject_payload(buf, sizeof(buf), reject_reason_for(NOISE_ERROR_MAC_FAILURE));
ASSERT_EQ(len, sizeof(buf));
EXPECT_EQ(buf[0], HANDSHAKE_STATUS_REJECT);
EXPECT_EQ(memcmp(buf + 1, "Handsha", 7), 0);
// A one-byte buffer still carries the status byte
uint8_t tiny[1];
len = format_reject_payload(tiny, sizeof(tiny), reject_reason_for(NOISE_ERROR_MAC_FAILURE));
ASSERT_EQ(len, 1u);
EXPECT_EQ(tiny[0], HANDSHAKE_STATUS_REJECT);
// A zero-capacity buffer yields no payload and stays untouched
uint8_t none[1] = {0xAA};
EXPECT_EQ(format_reject_payload(none, 0, reject_reason_for(NOISE_ERROR_MAC_FAILURE)), 0u);
EXPECT_EQ(none[0], 0xAA);
}
} // namespace esphome::noise::testing
@@ -57,6 +57,11 @@ image:
url: http://www.faqs.org/images/library.jpg
format: JPG
type: RGB565
- platform: online_image
id: online_auto_image
url: http://www.faqs.org/images/library.jpg
format: AUTO
type: RGB565
# Check the set_url action
esphome:
+41
View File
@@ -0,0 +1,41 @@
// Pins the lazy erase-ahead arithmetic used by the ESP-IDF OTA backend: the
// erased watermark must always cover the write end, stay 64 KiB block-aligned
// until the clamp, and never exceed the partition.
#include <gtest/gtest.h>
#include "esphome/components/ota/ota_backend.h"
namespace esphome::ota::testing {
static constexpr size_t BLOCK = 64 * 1024;
static constexpr size_t PART = 1835008; // 0x1C0000, a real app slot size
TEST(NextEraseEnd, FirstWriteRoundsUpToOneBlock) { EXPECT_EQ(next_erase_end(1024, PART), BLOCK); }
TEST(NextEraseEnd, ExactBlockBoundaryDoesNotOverErase) { EXPECT_EQ(next_erase_end(BLOCK, PART), BLOCK); }
TEST(NextEraseEnd, StraddlingWriteCoversNextBlock) { EXPECT_EQ(next_erase_end(BLOCK + 1, PART), 2 * BLOCK); }
TEST(NextEraseEnd, ClampsToPartitionEnd) {
// Partition sizes are sector multiples but not always block multiples
constexpr size_t part = 27 * BLOCK + 4096;
EXPECT_EQ(next_erase_end(27 * BLOCK + 1, part), part);
EXPECT_EQ(next_erase_end(part, part), part);
}
// Bootloader staging seeds erased_end_ mid-block (e.g. 0x8000); the target for
// a write past that seed must still cover the write end.
TEST(NextEraseEnd, MidBlockSeedStillCovered) { EXPECT_EQ(next_erase_end(0x8000 + 1024, PART), BLOCK); }
TEST(NextEraseEnd, SweepAlwaysCoversWriteEndWithinPartition) {
for (size_t end = 1; end <= PART; end += 4093) {
const size_t erased = next_erase_end(end, PART);
ASSERT_GE(erased, end);
ASSERT_LE(erased, PART);
// Block-aligned unless clamped at the partition end
ASSERT_TRUE(erased == PART || erased % BLOCK == 0);
}
}
} // namespace esphome::ota::testing

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