mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 11:26:39 +00:00
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:
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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,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,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+99
@@ -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
|
||||
|
||||
+15
-18
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
noise:
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
noise: !include common.yaml
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
noise: !include common.yaml
|
||||
@@ -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:
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user