Merge branch 'dev' into jesserockz-2026-503

This commit is contained in:
Jesse Hills
2026-09-02 11:12:53 +12:00
committed by GitHub
1889 changed files with 60990 additions and 21606 deletions
+4 -2
View File
@@ -3,8 +3,9 @@ from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# api must run its to_code to define USE_API, USE_API_PLAINTEXT,
# and add the noise-c library dependency.
# api must run its to_code to define USE_API and USE_API_NOISE. The
# AUTO_LOADed noise component runs its own to_code via the override in
# tests/benchmarks/components/noise/__init__.py.
manifest.enable_codegen()
original_to_code = manifest.to_code
@@ -15,6 +16,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
# components have hardware dependencies (BLE/UART/RMT); lightweight
# stub headers in tests/benchmarks/stubs/ satisfy the includes.
cg.add_define("USE_BLUETOOTH_PROXY")
cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS")
cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 3)
cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16)
cg.add_define("USE_ZWAVE_PROXY")
@@ -49,7 +49,7 @@ static void Encode_ListEntitiesSensorResponse(benchmark::State &state) {
auto msg = make_sensor_response();
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -69,7 +69,7 @@ static void CalcAndEncode_ListEntitiesSensorResponse(benchmark::State &state) {
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
@@ -117,7 +117,7 @@ static void Encode_ListEntitiesBinarySensorResponse(benchmark::State &state) {
auto msg = make_binary_sensor_response();
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -137,7 +137,7 @@ static void CalcAndEncode_ListEntitiesBinarySensorResponse(benchmark::State &sta
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
@@ -202,7 +202,7 @@ static void Encode_ListEntitiesLightResponse(benchmark::State &state) {
auto msg = make_light_response();
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -222,7 +222,7 @@ static void CalcAndEncode_ListEntitiesLightResponse(benchmark::State &state) {
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
@@ -23,7 +23,7 @@ static void Encode_LogResponse_Typical(benchmark::State &state) {
msg.level = enums::LOG_LEVEL_DEBUG;
msg.set_message(reinterpret_cast<const uint8_t *>(kTypicalLogLine), strlen(kTypicalLogLine));
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -42,7 +42,7 @@ static void Encode_LogResponse_Short(benchmark::State &state) {
msg.level = enums::LOG_LEVEL_INFO;
msg.set_message(reinterpret_cast<const uint8_t *>(kShortLogLine), strlen(kShortLogLine));
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -84,7 +84,7 @@ static void CalcAndEncode_LogResponse_Typical(benchmark::State &state) {
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
@@ -105,7 +105,7 @@ static void CalcAndEncode_LogResponse_Typical_Fresh(benchmark::State &state) {
for (int i = 0; i < kInnerIterations; i++) {
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
benchmark::DoNotOptimize(buffer.data());
@@ -33,7 +33,7 @@ static void PlaintextFrame_WriteSensorState(benchmark::State &state) {
// Pre-init buffer to typical TCP MSS size to avoid benchmarking
// heap allocation — in real use the buffer is reused across writes.
APIBuffer buffer;
buffer.reserve(1460);
(void) buffer.reserve(1460);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -44,7 +44,7 @@ static void PlaintextFrame_WriteSensorState(benchmark::State &state) {
msg.missing_state = false;
uint32_t size = msg.calculate_size();
buffer.resize(padding + size);
(void) buffer.resize(padding + size);
ProtoWriteBuffer writer(&buffer, padding);
msg.encode(writer);
@@ -70,7 +70,7 @@ static void PlaintextFrame_WriteBatch5(benchmark::State &state) {
// Pre-init buffer to typical TCP MSS size to avoid benchmarking
// heap allocation — in real use the buffer is reused across writes.
APIBuffer buffer;
buffer.reserve(1460);
(void) buffer.reserve(1460);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -85,7 +85,7 @@ static void PlaintextFrame_WriteBatch5(benchmark::State &state) {
msg.missing_state = false;
uint32_t size = msg.calculate_size();
buffer.resize(offset + padding + size + footer);
(void) buffer.resize(offset + padding + size + footer);
ProtoWriteBuffer writer(&buffer, offset + padding);
msg.encode(writer);
@@ -16,7 +16,7 @@ static constexpr int kInnerIterations = 2000;
template<typename T> static APIBuffer encode_message(const T &msg) {
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
return buffer;
@@ -19,7 +19,7 @@ static void Encode_SensorStateResponse(benchmark::State &state) {
msg.state = 23.5f;
msg.missing_state = false;
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -60,7 +60,7 @@ static void CalcAndEncode_SensorStateResponse(benchmark::State &state) {
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
@@ -84,7 +84,7 @@ static void CalcAndEncode_SensorStateResponse_Fresh(benchmark::State &state) {
for (int i = 0; i < kInnerIterations; i++) {
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
benchmark::DoNotOptimize(buffer.data());
@@ -103,7 +103,7 @@ static void Encode_BinarySensorStateResponse(benchmark::State &state) {
msg.state = true;
msg.missing_state = false;
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -126,7 +126,7 @@ static void Encode_HelloResponse(benchmark::State &state) {
msg.server_info = StringRef::from_lit("esphome v2026.3.0");
msg.name = StringRef::from_lit("living-room-sensor");
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -158,7 +158,7 @@ static void Encode_LightStateResponse(benchmark::State &state) {
msg.warm_white = 0.0f;
msg.effect = StringRef::from_lit("rainbow");
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -243,7 +243,7 @@ static void Encode_DeviceInfoResponse(benchmark::State &state) {
auto msg = make_device_info_response();
APIBuffer buffer;
uint32_t total_size = msg.calculate_size();
buffer.resize(total_size);
(void) buffer.resize(total_size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -264,7 +264,7 @@ static void CalcAndEncode_DeviceInfoResponse(benchmark::State &state) {
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
@@ -285,7 +285,7 @@ static void CalcAndEncode_DeviceInfoResponse_Fresh(benchmark::State &state) {
for (int i = 0; i < kInnerIterations; i++) {
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
benchmark::DoNotOptimize(buffer.data());
@@ -335,7 +335,7 @@ static void Encode_BLERawAdvs12(benchmark::State &state) {
auto msg = make_ble_raw_advs_12();
APIBuffer buffer;
uint32_t total_size = msg.calculate_size();
buffer.resize(total_size);
(void) buffer.resize(total_size);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -355,7 +355,7 @@ static void CalcAndEncode_BLERawAdvs12(benchmark::State &state) {
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
}
@@ -372,7 +372,7 @@ static void CalcAndEncode_BLERawAdvs12_Fresh(benchmark::State &state) {
for (int i = 0; i < kInnerIterations; i++) {
APIBuffer buffer;
uint32_t size = msg.calculate_size();
buffer.resize(size);
(void) buffer.resize(size);
ProtoWriteBuffer writer(&buffer, 0);
msg.encode(writer);
benchmark::DoNotOptimize(buffer.data());
@@ -16,7 +16,7 @@ static constexpr int kInnerIterations = 2000;
// Encodes `src` into `out`. Caller owns `out` and must keep it alive across
// the decode loop (decoded messages may store pointers back into its bytes).
template<typename T> static void encode_into(APIBuffer &out, const T &src) {
out.resize(src.calculate_size());
(void) out.resize(src.calculate_size());
ProtoWriteBuffer writer(&out, 0);
src.encode(writer);
}
@@ -33,7 +33,7 @@ static void Encode_ZWaveProxyFrame(benchmark::State &state) {
msg.data = kZWaveFrameData;
msg.data_len = sizeof(kZWaveFrameData);
APIBuffer buffer;
buffer.resize(msg.calculate_size());
(void) buffer.resize(msg.calculate_size());
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -111,7 +111,7 @@ static void Encode_SerialProxyDataReceived(benchmark::State &state) {
msg.instance = 0;
msg.set_data(kSerialPayload, kSerialPayloadSize);
APIBuffer buffer;
buffer.resize(msg.calculate_size());
(void) buffer.resize(msg.calculate_size());
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -171,7 +171,7 @@ static void Encode_InfraredRFReceiveEvent(benchmark::State &state) {
msg.key = 0xDEADBEEF;
msg.timings = &get_ir_timings_100();
APIBuffer buffer;
buffer.resize(msg.calculate_size());
(void) buffer.resize(msg.calculate_size());
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -254,7 +254,7 @@ static APIBuffer build_infrared_rf_transmit_wire() {
put_varint(1);
APIBuffer buf;
buf.resize(len);
(void) buf.resize(len);
std::memcpy(buf.data(), bytes, len);
return buf;
}
@@ -58,7 +58,7 @@ BENCHMARK(ProtoVarInt_Parse_FiveByte);
static void Encode_Varint_Small(benchmark::State &state) {
APIBuffer buffer;
buffer.resize(16);
(void) buffer.resize(16);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -73,7 +73,7 @@ BENCHMARK(Encode_Varint_Small);
static void Encode_Varint_Large(benchmark::State &state) {
APIBuffer buffer;
buffer.resize(16);
(void) buffer.resize(16);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -88,7 +88,7 @@ BENCHMARK(Encode_Varint_Large);
static void Encode_Varint_MaxUint32(benchmark::State &state) {
APIBuffer buffer;
buffer.resize(16);
(void) buffer.resize(16);
for (auto _ : state) {
for (int i = 0; i < kInnerIterations; i++) {
@@ -0,0 +1,7 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# to_code must run: it defines USE_NOISE and adds the noise-c library
# the api benchmark sources need.
manifest.enable_codegen()
+1 -1
View File
@@ -4,6 +4,6 @@ from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code(config):
cg.add_build_flag("-DUSE_TIME_TIMEZONE")
cg.add_define("USE_TIME_TIMEZONE")
manifest.to_code = to_code
+43
View File
@@ -363,4 +363,47 @@ static void Snprintf_Uint32_Large(benchmark::State &state) {
}
BENCHMARK(Snprintf_Uint32_Large);
// --- step_to_accuracy_decimals() ---
// Called from climate traits and web_server for every number/climate step.
static void StepToAccuracyDecimals_Tenth(benchmark::State &state) {
for (auto _ : state) {
int result = 0;
for (int i = 0; i < kInnerIterations; i++) {
result += step_to_accuracy_decimals(0.1f);
}
benchmark::DoNotOptimize(result);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(StepToAccuracyDecimals_Tenth);
static void StepToAccuracyDecimals_Whole(benchmark::State &state) {
for (auto _ : state) {
int result = 0;
for (int i = 0; i < kInnerIterations; i++) {
result += step_to_accuracy_decimals(1.0f);
}
benchmark::DoNotOptimize(result);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(StepToAccuracyDecimals_Whole);
static void StepToAccuracyDecimals_Mixed(benchmark::State &state) {
static constexpr float steps[] = {
0.001f, 0.01f, 0.05f, 0.1f, 0.25f, 0.5f, 1.0f, 2.5f, 5.0f, 10.0f,
};
static constexpr int num_steps = sizeof(steps) / sizeof(steps[0]);
for (auto _ : state) {
int result = 0;
for (int i = 0; i < kInnerIterations; i++) {
result += step_to_accuracy_decimals(steps[i % num_steps]);
}
benchmark::DoNotOptimize(result);
}
state.SetItemsProcessed(state.iterations() * kInnerIterations);
}
BENCHMARK(StepToAccuracyDecimals_Mixed);
} // namespace esphome::benchmarks
@@ -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,155 @@
"""Tests for user-defined action field metadata (description / example)."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components.api import (
_action_strings,
_action_strings_size,
_has_action_metadata,
_validate_esp8266_action_strings,
validate_variable,
)
from esphome.config_validation import Invalid
from esphome.const import PlatformFramework
from esphome.core import CORE
from esphome.cpp_generator import safe_exp
from esphome.helpers import fnv1_hash
from tests.component_tests.helpers import get_define_value
from tests.component_tests.types import SetCoreConfigCallable
CONFIG = "tests/component_tests/api/test_action_metadata.yaml"
CONFIG_ESP8266 = "tests/component_tests/api/test_action_metadata_esp8266.yaml"
CONFIG_SHORTHAND = "tests/component_tests/api/test_action_metadata_shorthand.yaml"
def test_metadata_is_emitted_as_progmem_table(
generate_main: Callable[[str | Path], str],
) -> None:
"""Every action string is a PROGMEM array referenced from one PROGMEM table."""
main_cpp = generate_main(CONFIG)
assert (
'static constexpr char api_action_str0[] PROGMEM = "play_buzzer";' in main_cpp
)
assert (
'static constexpr char api_action_str1[] PROGMEM = "Play an RTTTL melody on the buzzer";'
in main_cpp
)
assert (
'static constexpr char api_action_str4[] PROGMEM = "two_short:d=4,o=5,b=100:16e6,16e6";'
in main_cpp
)
assert (
"static constexpr const char * api_action0_strings[] PROGMEM = {"
"api_action_str0, api_action_str1, api_action_str2, api_action_str3, "
"api_action_str4, api_action_str5, nullptr, nullptr};" in main_cpp
)
# An action without metadata still carries the metadata slots (as nullptr)
assert (
"static constexpr const char * api_action1_strings[] PROGMEM = {"
"api_action_str6, nullptr, api_action_str7, nullptr, nullptr};" in main_cpp
)
assert f"(api_action0_strings, {safe_exp(fnv1_hash('play_buzzer'))});" in main_cpp
assert "USE_API_USER_DEFINED_ACTION_METADATA" in {d.name for d in CORE.defines}
assert get_define_value("API_USER_ACTION_STRINGS_SCRATCH_SIZE") is None
def test_esp8266_sizes_scratch_buffer_for_largest_action(
generate_main: Callable[[str | Path], str],
) -> None:
"""ESP8266 gets a scratch buffer define equal to the byte total of the largest action."""
generate_main(CONFIG_ESP8266)
# play_buzzer: name, description, two variable names, one description, one example,
# each with a terminator
assert get_define_value("API_USER_ACTION_STRINGS_SCRATCH_SIZE") == "117"
def test_shorthand_variables_emit_no_metadata(
generate_main: Callable[[str | Path], str],
) -> None:
"""The name: type shorthand emits a name-only table and no define."""
main_cpp = generate_main(CONFIG_SHORTHAND)
assert (
"static constexpr const char * api_action0_strings[] PROGMEM = "
"{api_action_str0, api_action_str1};" in main_cpp
)
assert "USE_API_USER_DEFINED_ACTION_METADATA" not in {d.name for d in CORE.defines}
def test_variable_shorthand_normalizes_to_mapping() -> None:
"""A bare type string validates to the mapping form."""
assert validate_variable("string") == {"type": "string"}
@pytest.mark.parametrize(
"value",
[
{"description": "no type given"},
{"type": "string", "selector": "text"},
"stringy",
{"type": "stringy"},
],
)
def test_variable_rejects_invalid(value: object) -> None:
"""Missing or unknown type and unknown keys raise in both forms."""
with pytest.raises(Invalid):
validate_variable(value)
def _oversized_action_config() -> dict:
return {
"actions": [
{
"action": "big",
"description": "x" * 300,
"variables": {"a": {"type": "string", "example": "y" * 300}},
}
]
}
def test_esp8266_rejects_actions_over_string_budget(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP8266_ARDUINO)
with pytest.raises(Invalid, match="ESP8266 allows at most 384 bytes"):
_validate_esp8266_action_strings(_oversized_action_config())
def test_other_platforms_have_no_string_budget(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF)
config = _oversized_action_config()
assert _validate_esp8266_action_strings(config) is config
def test_empty_metadata_is_unset_and_not_counted() -> None:
"""An empty description or example emits nullptr and takes no scratch space."""
conf = {
"action": "a",
"description": "",
"variables": {"b": {"type": "int", "description": "", "example": "ex"}},
}
strings = _action_strings(conf, has_metadata=True)
assert strings == ["a", None, "b", None, "ex"]
# Every emitted string counts its terminator: "a" + "b" + "ex"
assert _action_strings_size(strings) == 2 + 2 + 3
def test_empty_metadata_does_not_enable_the_define() -> None:
actions = [
{
"action": "a",
"description": "",
"variables": {"b": {"type": "int", "example": ""}},
}
]
assert not _has_action_metadata(actions)
actions[0]["variables"]["b"]["example"] = "1"
assert _has_action_metadata(actions)
@@ -0,0 +1,14 @@
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: MySSID
password: password1
logger:
packages:
api: !include test_action_metadata_common.yaml
@@ -0,0 +1,18 @@
api:
actions:
- action: play_buzzer
description: Play an RTTTL melody on the buzzer
variables:
song_str:
type: string
description: RTTTL melody string
example: "two_short:d=4,o=5,b=100:16e6,16e6"
volume:
type: int
then:
- logger.log: Action Called
- action: plain_action
variables:
value: int
then:
- logger.log: Action Called
@@ -0,0 +1,14 @@
esphome:
name: test
esp8266:
board: d1_mini
wifi:
ssid: MySSID
password: password1
logger:
packages:
api: !include test_action_metadata_common.yaml
@@ -0,0 +1,19 @@
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: MySSID
password: password1
logger:
api:
actions:
- action: plain_action
variables:
value: int
then:
- logger.log: Action Called
@@ -9,7 +9,7 @@ def test_synchronous_chain_keeps_zero_copy_args(generate_main):
assert (
"api::UserServiceTrigger<api::enums::SUPPORTS_RESPONSE_NONE, StringRef>"
'("zero_copy_args", {"message"})' in main_cpp
"(api_action0_strings," in main_cpp
)
@@ -22,7 +22,7 @@ def test_response_callback_args_are_owning(generate_main):
assert (
"api::UserServiceTrigger<api::enums::SUPPORTS_RESPONSE_NONE, std::string>"
'("response_args", {"message"})' in main_cpp
"(api_action1_strings," in main_cpp
)
assert "api::HomeAssistantServiceCallAction<std::string>" in main_cpp
assert "api::HomeAssistantServiceCallAction<StringRef>" not in main_cpp
@@ -0,0 +1,63 @@
"""Tests for variables handling in homeassistant.event and homeassistant.action."""
from collections.abc import Callable
import logging
from pathlib import Path
import pytest
CONFIG = "tests/component_tests/api/test_homeassistant_variables.yaml"
def test_plain_string_with_return_is_compiled_as_lambda_with_warning(
generate_main: Callable[[str | Path], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""A plain string with a return statement compiles as a lambda and warns."""
with caplog.at_level(logging.WARNING):
main_cpp = generate_main(CONFIG)
assert main_cpp.count('add_variable(ESPHOME_F("lambda_var"), []() {') == 2
assert "return millis();" in main_cpp
# The source text must not be sent as a static string value.
assert '"return millis();"' not in main_cpp
assert "missing the !lambda tag" in caplog.text
def test_static_string_is_kept_as_static_value(
generate_main: Callable[[str | Path], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""A static string stays static, PROGMEM wrapped, with no warning."""
with caplog.at_level(logging.WARNING):
main_cpp = generate_main(CONFIG)
assert (
main_cpp.count(
'add_variable(ESPHOME_F("static_var"), ESPHOME_F("static value"));'
)
== 2
)
assert "static value" not in caplog.text
def test_static_id_value_stays_literal_with_hint(
generate_main: Callable[[str | Path], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Lambda source without a return stays literal text but warns."""
with caplog.at_level(logging.WARNING):
main_cpp = generate_main(CONFIG)
assert 'ESPHOME_F("id(test_sensor).state")' in main_cpp
assert "sent as literal text" in caplog.text
def test_explicit_lambda_tag_is_compiled_as_lambda(
generate_main: Callable[[str | Path], str],
) -> None:
"""A !lambda value keeps working unchanged."""
main_cpp = generate_main(CONFIG)
assert 'add_variable(ESPHOME_F("tagged_var"), []() {' in main_cpp
assert "return App.get_name();" in main_cpp
@@ -0,0 +1,32 @@
esphome:
name: test
on_boot:
then:
# Plain strings with a return statement compile as lambdas
- homeassistant.event:
event: esphome.test_event
data_template:
message: "{{ lambda_var }} {{ static_var }} {{ tagged_var }}"
variables:
lambda_var: |-
return millis();
static_var: static value
tagged_var: !lambda return App.get_name();
hint_var: id(test_sensor).state
- homeassistant.action:
action: notify.notify
data_template:
message: "{{ lambda_var }} {{ static_var }}"
variables:
lambda_var: |-
return millis();
static_var: static value
esp32:
board: esp32dev
wifi:
ssid: SomeNetwork
password: SomePassword
api:
@@ -0,0 +1,7 @@
esphome:
name: bk-family-gate-n
bk72xx:
board: cb2s
bk72xx_ble:
@@ -0,0 +1,7 @@
esphome:
name: bk-family-gate-q
bk72xx:
board: wa2
bk72xx_ble:
@@ -0,0 +1,7 @@
esphome:
name: bk-family-gate-t
bk72xx:
board: generic-bk7231t-qfn32-tuya
bk72xx_ble:
@@ -0,0 +1,7 @@
esphome:
name: bk-family-gate-7238
bk72xx:
board: generic-bk7238
bk72xx_ble:
@@ -0,0 +1,7 @@
esphome:
name: bk-family-gate-7252
bk72xx:
board: generic-bk7252
bk72xx_ble:
@@ -0,0 +1,41 @@
"""The non-5.x family rejection lives in to_code (config validation must stay
family-agnostic for the validate-only CI fixtures), so codegen is the only
place it can be pinned."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.core import EsphomeError
@pytest.mark.parametrize(
("config_file", "match"),
[
("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(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
match: str,
caplog: pytest.LogCaptureFixture,
) -> None:
with pytest.raises(EsphomeError, match=match):
generate_main(component_config_path(config_file))
# Validation itself must not fail (CI validate fixtures run on a BLE 4.2
# board), but it warns before codegen raises.
assert "cannot compile" in caplog.text
def test_ble5_family_generates(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("test_bk7231n.yaml"))
assert "bk72xx_ble::BK72xxBLE" in main_cpp
@@ -2,6 +2,6 @@ esphome:
name: slotcount-controller
bk72xx:
board: generic-bk7252
board: cb2s
bk72xx_ble:
@@ -2,6 +2,6 @@ esphome:
name: slotcount-tracker
bk72xx:
board: generic-bk7252
board: cb2s
bk72xx_ble_tracker:
@@ -57,7 +57,12 @@ def test_bk72xx_defaults_are_valid() -> None:
def test_esp32_defaults_are_valid() -> None:
"""esp32 pins the ESP-IDF reference rate and exposes active (default on)."""
"""esp32 pins the ESP-IDF reference rate and exposes active (default on).
Without wifi loaded, the conditional window default falls back to the
historical 30 ms; the wifi-aware resolution is covered by the
esp32_ble_tracker component tests.
"""
config = ESP32_SCHEMA({})
assert to_ble_units(config["interval"]) == 512
assert to_ble_units(config["window"]) == 48
+8
View File
@@ -57,6 +57,14 @@ def reset_core() -> Generator[None]:
CORE.reset()
@pytest.fixture(autouse=True)
def reset_full_config() -> Generator[None]:
"""Give each test a clean final-validate config and restore it after."""
token = final_validate.full_config.set({})
yield
final_validate.full_config.reset(token)
@pytest.fixture
def set_core_config() -> Generator[SetCoreConfigCallable]:
"""Fixture to set up the core configuration for tests."""
@@ -1,5 +1,13 @@
"""Tests for the deep sleep component."""
import pytest
from esphome import config_validation as cv
from esphome.components import deep_sleep
from esphome.const import CONF_WAKEUP_PIN, PlatformFramework
from ..types import SetCoreConfigCallable
def test_deep_sleep_setup(generate_main):
"""
@@ -83,3 +91,35 @@ def test_deep_sleep_run_duration_dictionary(generate_main):
" .gpio_cause = 30000,\n"
"});"
) in main_cpp
def test_deep_sleep_bk72xx_wakeup_pin_mode_at_both_levels_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""On BK72xx, wakeup_pin_mode at the top level and under the pin entry is an error."""
set_core_config(PlatformFramework.BK72XX_ARDUINO)
config = {
CONF_WAKEUP_PIN: [
{"pin": "GPIO12", deep_sleep.CONF_WAKEUP_PIN_MODE: "KEEP_AWAKE"}
],
deep_sleep.CONF_WAKEUP_PIN_MODE: "INVERT_WAKEUP",
}
with pytest.raises(cv.Invalid, match="not both"):
deep_sleep.validate_config(config)
def test_deep_sleep_bk72xx_top_level_wakeup_pin_mode_moved_onto_single_pin(
set_core_config: SetCoreConfigCallable,
) -> None:
"""On BK72xx, a top-level wakeup_pin_mode is moved onto the only pin entry."""
set_core_config(PlatformFramework.BK72XX_ARDUINO)
config = {
CONF_WAKEUP_PIN: [{"pin": "GPIO12"}],
deep_sleep.CONF_WAKEUP_PIN_MODE: "INVERT_WAKEUP",
}
result = deep_sleep.validate_config(config)
assert deep_sleep.CONF_WAKEUP_PIN_MODE not in result
assert (
result[CONF_WAKEUP_PIN][0][deep_sleep.CONF_WAKEUP_PIN_MODE] == "INVERT_WAKEUP"
)
@@ -0,0 +1,209 @@
"""Tests for emontx sensor tag defaults."""
import pytest
from esphome.components import sensor
from esphome.components.emontx.sensor import CONFIG_SCHEMA, apply_tag_defaults
from esphome.const import (
CONF_ACCURACY_DECIMALS,
CONF_DEVICE_CLASS,
CONF_STATE_CLASS,
CONF_UNIT_OF_MEASUREMENT,
DEVICE_CLASS_APPARENT_POWER,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_FREQUENCY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_POWER_FACTOR,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLTAGE,
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_CELSIUS,
UNIT_EMPTY,
UNIT_HERTZ,
UNIT_PULSES,
UNIT_VOLT,
UNIT_VOLT_AMPS,
UNIT_WATT,
UNIT_WATT_HOURS,
)
def _resolve_via_config_schema(tag: str) -> dict:
"""Run a minimal config through the real CONFIG_SCHEMA pipeline, the
same path a user's YAML goes through."""
return CONFIG_SCHEMA(
{"tag_name": tag, "emontx_id": "my_emontx", "name": f"{tag} sensor"}
)
def test_config_schema_applies_tag_default_state_class():
"""If sensor_schema(state_class=...) is reintroduced, the schema-level
default wins over apply_tag_defaults' per-prefix value, and E1 would
resolve to measurement instead of total_increasing. Driving the real
CONFIG_SCHEMA (not just apply_tag_defaults) catches that, since
sensor_schema() runs before apply_tag_defaults in the cv.All() chain.
"""
result = _resolve_via_config_schema("E1")
assert result[CONF_STATE_CLASS] == sensor.validate_state_class(
STATE_CLASS_TOTAL_INCREASING
)
def test_config_schema_applies_tag_default_accuracy_decimals():
"""Same root cause as the state_class regression: reintroducing
sensor_schema(accuracy_decimals=...) would make V1 resolve to the
schema-level default instead of the prefix-specific value of 2.
"""
result = _resolve_via_config_schema("V1")
assert result[CONF_ACCURACY_DECIMALS] == 2
def _make_config(tag: str) -> dict:
"""Minimal config dict with only tag_name set — no overrides."""
return {"tag_name": tag}
@pytest.mark.parametrize(
("tag", "expected_state_class", "expected_decimals"),
[
# Known numeric-index prefixes
("E1", STATE_CLASS_TOTAL_INCREASING, 0),
("E12", STATE_CLASS_TOTAL_INCREASING, 0),
("P1", STATE_CLASS_MEASUREMENT, 0),
("V1", STATE_CLASS_MEASUREMENT, 2),
("I1", STATE_CLASS_MEASUREMENT, 2),
("T1", STATE_CLASS_MEASUREMENT, 2),
# Known patterns
("PULSE1", STATE_CLASS_TOTAL_INCREASING, 0),
("PULSE12", STATE_CLASS_TOTAL_INCREASING, 0),
("PF1", STATE_CLASS_MEASUREMENT, 2),
("AP1", STATE_CLASS_MEASUREMENT, 2),
("AP12", STATE_CLASS_MEASUREMENT, 2),
# Frequency: reported as a single, un-numbered tag
("F", STATE_CLASS_MEASUREMENT, 2),
# Unknown / free-form tags fall back to generic defaults
("CUSTOM1", STATE_CLASS_MEASUREMENT, 0),
("X", STATE_CLASS_MEASUREMENT, 0),
# "F1" is not the exact "F" tag, so it falls back to generic defaults
("F1", STATE_CLASS_MEASUREMENT, 0),
# "PULSE" (no index) is how some real emonTx firmware reports a
# single pulse counter, so it still resolves to the PULSE defaults
("PULSE", STATE_CLASS_TOTAL_INCREASING, 0),
# Real firmware sends this lowercase; tag_upper's case-folding must
# still match it against the PULSE pattern
("pulse", STATE_CLASS_TOTAL_INCREASING, 0),
# PF/AP require a numeric index; the bare prefix alone (no index)
# falls back to generic defaults
("PF", STATE_CLASS_MEASUREMENT, 0),
("AP", STATE_CLASS_MEASUREMENT, 0),
],
)
def test_apply_tag_defaults(tag, expected_state_class, expected_decimals):
"""apply_tag_defaults must inject the correct state_class and accuracy_decimals
for each tag type when no user overrides are present."""
config = _make_config(tag)
result = apply_tag_defaults(config)
assert result[CONF_STATE_CLASS] == sensor.validate_state_class(expected_state_class)
assert result[CONF_ACCURACY_DECIMALS] == expected_decimals
@pytest.mark.parametrize(
("tag", "expected_unit", "expected_device_class"),
[
# Known numeric-index prefixes
("E1", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY),
("E12", UNIT_WATT_HOURS, DEVICE_CLASS_ENERGY),
("P1", UNIT_WATT, DEVICE_CLASS_POWER),
("V1", UNIT_VOLT, DEVICE_CLASS_VOLTAGE),
("I1", UNIT_AMPERE, DEVICE_CLASS_CURRENT),
("T1", UNIT_CELSIUS, DEVICE_CLASS_TEMPERATURE),
# Known patterns
("PULSE1", UNIT_PULSES, DEVICE_CLASS_ENERGY),
("PULSE12", UNIT_PULSES, DEVICE_CLASS_ENERGY),
# Bare "PULSE" (no index), as reported by some real emonTx firmware
("PULSE", UNIT_PULSES, DEVICE_CLASS_ENERGY),
# Real firmware sends this lowercase; tag_upper's case-folding must
# still match it against the PULSE pattern
("pulse", UNIT_PULSES, DEVICE_CLASS_ENERGY),
("PF1", UNIT_EMPTY, DEVICE_CLASS_POWER_FACTOR),
("AP1", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER),
("AP12", UNIT_VOLT_AMPS, DEVICE_CLASS_APPARENT_POWER),
# Frequency: reported as a single, un-numbered tag
("F", UNIT_HERTZ, DEVICE_CLASS_FREQUENCY),
],
)
def test_apply_tag_defaults_unit_and_device_class(
tag, expected_unit, expected_device_class
):
"""apply_tag_defaults must inject the correct, validated unit_of_measurement
and device_class for each tag type when no user overrides are present."""
config = _make_config(tag)
result = apply_tag_defaults(config)
assert result[CONF_UNIT_OF_MEASUREMENT] == sensor.validate_unit_of_measurement(
expected_unit
)
assert result[CONF_DEVICE_CLASS] == sensor.validate_device_class(
expected_device_class
)
@pytest.mark.parametrize(
"tag",
[
"CUSTOM1",
"X",
# Non-numeric suffixes must not collide with a PATTERN_CONFIGS prefix
# (e.g. "APPLE" starting with "AP", "PFX" starting with "PF").
"APPLE",
"PFX",
"PULSE_A",
# "F1" is not the exact "F" tag
"F1",
# Bare "PF"/"AP" (no numeric index) don't match; unlike "PULSE",
# real firmware never reports these without an index
"PF",
"AP",
],
)
def test_apply_tag_defaults_unknown_tag_has_no_unit_or_device_class(tag):
"""Unknown / free-form tags only get generic state_class and
accuracy_decimals defaults; unit_of_measurement and device_class are left
for the user to set explicitly."""
config = _make_config(tag)
result = apply_tag_defaults(config)
assert CONF_UNIT_OF_MEASUREMENT not in result
assert CONF_DEVICE_CLASS not in result
assert result[CONF_STATE_CLASS] == sensor.validate_state_class(
STATE_CLASS_MEASUREMENT
)
assert result[CONF_ACCURACY_DECIMALS] == 0
@pytest.mark.parametrize(
("tag", "user_state_class", "user_decimals"),
[
# User overrides must not be clobbered by defaults
("E1", STATE_CLASS_MEASUREMENT, 3),
("PULSE1", STATE_CLASS_MEASUREMENT, 1),
("V1", STATE_CLASS_TOTAL_INCREASING, 0),
("CUSTOM1", STATE_CLASS_TOTAL_INCREASING, 4),
],
)
def test_apply_tag_defaults_respects_user_overrides(
tag, user_state_class, user_decimals
):
"""apply_tag_defaults must not overwrite values already set by the user."""
config = _make_config(tag)
config[CONF_STATE_CLASS] = sensor.validate_state_class(user_state_class)
config[CONF_ACCURACY_DECIMALS] = user_decimals
result = apply_tag_defaults(config)
assert result[CONF_STATE_CLASS] == sensor.validate_state_class(user_state_class)
assert result[CONF_ACCURACY_DECIMALS] == user_decimals
@@ -0,0 +1,15 @@
esphome:
name: test
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
spi:
clk_pin: GPIO7
mosi_pin: GPIO9
display:
- platform: epaper_spi
id: epaper_display
model: seeed-reterminal-e1001
@@ -439,6 +439,23 @@ def test_enable_pin_multiple(
assert all(pin["mode"]["output"] is True for pin in enable_pins)
def test_uc8179_e1001_code_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Test that the reTerminal E1001 model generates the UC8179 driver and init sequence."""
main_cpp = generate_main(component_config_path("uc8179_e1001_test.yaml"))
# The model must instantiate the UC8179 driver class with the panel dimensions
assert "epaper_spi::EPaperUC8179" in main_cpp
assert re.search(r'"SEEED-RETERMINAL-E1001",\s*800,\s*480', main_cpp)
# The generated init sequence must contain the UC8179 resolution setting
# for 800x480: command 0x61, 4 data bytes 0x03 0x20 0x01 0xE0
# (rendered as decimal in the generated array)
assert "97, 4, 3, 32, 1, 224" in main_cpp
def test_enable_pin_code_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
@@ -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,11 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
espnow:
channel: 1
auto_add_peer: 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: false
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
sensor:
- platform: internal_temperature
name: Internal Temperature
@@ -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,13 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: MySSID
password: password1
esp32_ble_tracker:
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
sensor:
- platform: internal_temperature
name: Internal Temperature
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
sdkconfig_options:
CONFIG_NVS_ENCRYPTION: n
@@ -0,0 +1,13 @@
esphome:
name: test
esp32:
variant: esp32s31
board: esp32-s31-devkitc
framework:
type: esp-idf
advanced:
execute_from_psram: true
psram:
mode: octal
@@ -6,6 +6,8 @@ esp32:
framework:
type: esp-idf
mdns:
ethernet:
type: W5500
clk_pin: 19
@@ -6,6 +6,8 @@ esp32:
framework:
type: esp-idf
mdns:
wifi:
ssid: "test_ssid"
password: "test_password"
+271 -4
View File
@@ -17,12 +17,14 @@ from esphome.components.esp32 import (
VARIANT_ESP32,
VARIANTS,
NetworkSdkconfigData,
RawSdkconfigValue,
_ota_downgrade_protection_errors,
_reconcile_network_sdkconfig,
_reconcile_vfs_fatfs_sdkconfig,
)
from esphome.components.esp32.const import (
KEY_ESP32,
KEY_EXCLUDE_COMPONENTS,
KEY_NETWORK_SDKCONFIG,
KEY_SDKCONFIG_OPTIONS,
KEY_VARIANT,
@@ -131,6 +133,20 @@ def test_esp32_rejects_unsupported_toolchains(
CONFIG_SCHEMA({"variant": VARIANT_ESP32, "toolchain": config_toolchain})
def test_esp32_rejects_unsupported_cli_toolchain(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A --toolchain the platform cannot serve fails instead of silently
building with PlatformIO (the CLI path bypasses the YAML validator)."""
set_core_config(PlatformFramework.ESP32_IDF)
from esphome.components.esp32 import CONFIG_SCHEMA
CORE.toolchain = Toolchain.ARDUINO
with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"):
CONFIG_SCHEMA({"variant": VARIANT_ESP32})
@pytest.mark.parametrize(
("config", "error_match"),
[
@@ -187,6 +203,18 @@ def test_esp32_rejects_unsupported_toolchains(
r"'execute_from_psram' requires PSRAM to be configured @ data\['framework'\]\['advanced'\]\['execute_from_psram'\]",
id="execute_from_psram_requires_psram_p4_config",
),
pytest.param(
{
"variant": "esp32s31",
"board": "esp32-s31-devkitc",
"framework": {
"type": "esp-idf",
"advanced": {"execute_from_psram": True},
},
},
r"'execute_from_psram' requires PSRAM to be configured @ data\['framework'\]\['advanced'\]\['execute_from_psram'\]",
id="execute_from_psram_requires_psram_s31_config",
),
pytest.param(
{
"variant": "esp32s3",
@@ -236,16 +264,182 @@ 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",
),
pytest.param(
# esp_wifi/wpa_supplicant from request_wifi(), bt from
# request_bluetooth(), esp_coex from esp32_ble_tracker's software
# coexistence (defaults on with wifi). esp_phy stays excluded;
# IDF requirement expansion pulls it back via esp_wifi.
"exclusion_reincludes_wifi_ble.yaml",
("esp_wifi", "wpa_supplicant", "bt", "esp_coex"),
id="wifi_ble",
),
pytest.param(
"exclusion_reincludes_espnow.yaml",
("esp_wifi",),
id="espnow",
),
pytest.param(
# temprature_sens_read() on the original ESP32 lives in the esp_phy blob.
"exclusion_reincludes_internal_temperature.yaml",
("esp_phy",),
id="internal_temperature",
),
],
)
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."""
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_esp_phy_stays_excluded_for_internal_temperature_on_newer_variants(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Only the original ESP32 reads the PHY blob; other variants use esp_driver_tsens."""
generate_main(component_config_path("exclusion_stays_internal_temperature_s3.yaml"))
assert "esp_phy" in CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
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."""
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],
) -> None:
"""Test that execute_from_psram on ESP32-S3 sets the correct sdkconfig options."""
"""Test that execute_from_psram on ESP32-S3 sets the correct sdkconfig option."""
generate_main(component_config_path("execute_from_psram_s3.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_SPIRAM_FETCH_INSTRUCTIONS") is True
assert sdkconfig.get("CONFIG_SPIRAM_RODATA") is True
assert "CONFIG_SPIRAM_XIP_FROM_PSRAM" not in sdkconfig
assert sdkconfig.get("CONFIG_SPIRAM_XIP_FROM_PSRAM") is True
assert "CONFIG_SPIRAM_FETCH_INSTRUCTIONS" not in sdkconfig
assert "CONFIG_SPIRAM_RODATA" not in sdkconfig
def test_execute_from_psram_p4_sdkconfig(
@@ -260,6 +454,18 @@ def test_execute_from_psram_p4_sdkconfig(
assert "CONFIG_SPIRAM_RODATA" not in sdkconfig
def test_execute_from_psram_s31_sdkconfig(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Test that execute_from_psram on ESP32-S31 sets the correct sdkconfig option."""
generate_main(component_config_path("execute_from_psram_s31.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_SPIRAM_XIP_FROM_PSRAM") is True
assert "CONFIG_SPIRAM_FETCH_INSTRUCTIONS" not in sdkconfig
assert "CONFIG_SPIRAM_RODATA" not in sdkconfig
def test_nvs_encryption_sdkconfig(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
@@ -783,6 +989,14 @@ def test_network_wifi_only_reconciles_end_to_end(
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False
assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False
# request_wifi() also puts the WiFi components back in the build set;
# esp_phy stays excluded, IDF requirement expansion pulls it back.
excluded = CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
assert "esp_wifi" not in excluded
assert "wpa_supplicant" not in excluded
assert "esp_phy" in excluded
# With wifi present mdns keeps its predefined interfaces.
assert "CONFIG_MDNS_PREDEF_NETIF_STA" not in sdkconfig
# WiFi stack stays enabled (no ethernet) and no Bluetooth requested.
assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig
assert "CONFIG_BT_ENABLED" not in sdkconfig
@@ -798,6 +1012,12 @@ def test_network_ethernet_only_reconciles_end_to_end(
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_ESP_WIFI_ENABLED") is False
assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is False
# The whole radio stack stays out of the build set as well.
excluded = CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
assert {"esp_wifi", "wpa_supplicant", "esp_phy", "esp_coex", "bt"} <= excluded
# Without wifi, mdns drops its predefined STA/AP interfaces.
assert sdkconfig.get("CONFIG_MDNS_PREDEF_NETIF_STA") is False
assert sdkconfig.get("CONFIG_MDNS_PREDEF_NETIF_AP") is False
def test_network_wifi_ble_coexistence_reconciles_end_to_end(
@@ -1072,3 +1292,50 @@ def test_parse_pio_platform_version(value: str, expected: str) -> None:
from esphome.components.esp32 import _parse_pio_platform_version
assert _parse_pio_platform_version(value) == expected
def test_esp32_s31_gpio_validation(
set_core_config: SetCoreConfigCallable,
caplog: pytest.LogCaptureFixture,
) -> None:
"""S31: GPIO26-28/30-32 are reserved for the SPI flash interface, GPIO29
and GPIO41 do not exist, GPIO33 is a normal pin, and GPIO36 is a
strapping pin."""
from esphome.components.esp32.const import VARIANT_ESP32S31
from esphome.components.esp32.gpio import validate_supports
from esphome.const import CONF_INPUT, CONF_MODE, CONF_OPEN_DRAIN, CONF_OUTPUT
set_core_config(
PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32S31}
)
input_mode = {CONF_INPUT: True, CONF_OUTPUT: False, CONF_OPEN_DRAIN: False}
# Not reserved; a normal GPIO
pin = {CONF_NUMBER: 33, CONF_IGNORE_PIN_VALIDATION_ERROR: False}
assert validate_gpio_pin(pin)[CONF_NUMBER] == 33
# Reserved for the SPI flash interface, but can be bypassed with
# ignore_pin_validation_error
for num in (26, 27, 28, 30, 31, 32):
with pytest.raises(cv.Invalid, match=f"GPIO{num} is reserved"):
validate_gpio_pin(
{CONF_NUMBER: num, CONF_IGNORE_PIN_VALIDATION_ERROR: False}
)
pin = {CONF_NUMBER: num, CONF_IGNORE_PIN_VALIDATION_ERROR: True}
assert validate_gpio_pin(pin)[CONF_NUMBER] == num
for num in (29, 41):
with pytest.raises(cv.Invalid, match=f"GPIO{num} does not exist"):
validate_gpio_pin(
{CONF_NUMBER: num, CONF_IGNORE_PIN_VALIDATION_ERROR: False}
)
# Also rejected in validate_supports so ignore_pin_validation_error
# cannot bypass it
with pytest.raises(cv.Invalid, match=f"GPIO{num} does not exist"):
validate_supports({CONF_NUMBER: num, CONF_MODE: input_mode})
pin = {CONF_NUMBER: 36, CONF_MODE: input_mode}
with caplog.at_level("WARNING"):
validate_supports(pin)
assert "GPIO36 is a strapping PIN" in caplog.text
@@ -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
@@ -0,0 +1,275 @@
"""Tests for the esp32_ble_tracker conditional scan window default.
The scan window default depends on wifi coexistence and the IDF version:
IDF 5.5.5 fixed a coexistence bug where BLE scans ran far longer than the
configured window (espressif/esp-idf#18931), so on fixed versions the
historical 30 ms default would only listen 9.4 % of the time and miss most
advertisements. With the coexistence arbiter compiled in on a fixed IDF, the
window instead defaults to the interval, as Espressif recommends; without the
arbiter a full-duty scan would starve wifi, so the 30 ms default is kept.
"""
from __future__ import annotations
from collections.abc import Callable
import logging
from pathlib import Path
import pytest
from esphome import config_validation as cv
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 (
CONF_SOFTWARE_COEXISTENCE,
CONFIG_SCHEMA,
)
from esphome.const import CONF_INTERVAL, PlatformFramework
from esphome.core import CORE
from esphome.types import ConfigType
from ..types import SetCoreConfigCallable
@pytest.fixture
def stage_esp32(
set_core_config: SetCoreConfigCallable,
) -> Callable[..., None]:
"""Stage an esp32 build with a given IDF version and wifi presence."""
def stage(idf: str, *, wifi: bool) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
)
if wifi:
# Makes cv.OnlyWith default software_coexistence to True, exactly
# as a real config with wifi: does.
CORE.loaded_integrations.add("wifi")
return stage
def _scan_params(config: ConfigType) -> ConfigType:
return CONFIG_SCHEMA(config)[CONF_SCAN_PARAMETERS]
@pytest.mark.parametrize(
("idf", "config", "expected_units"),
[
("5.5.5", {}, 512), # first fixed version, default 320 ms interval
("6.0.1", {}, 512), # any newer version behaves the same
# Follows a user-set interval.
("5.5.5", {"scan_parameters": {"interval": "1s"}}, 1600),
],
)
def test_wifi_on_fixed_idf_defaults_window_to_interval(
stage_esp32: Callable[..., None],
idf: str,
config: ConfigType,
expected_units: int,
) -> None:
"""With wifi coexistence on a fixed IDF, the window defaults to the interval."""
stage_esp32(idf, wifi=True)
params = _scan_params(config)
assert params[CONF_WINDOW] == params[CONF_INTERVAL]
assert to_ble_units(params[CONF_WINDOW]) == expected_units
@pytest.mark.parametrize(
("idf", "wifi", "config"),
[
# Buggy IDF over-scans anyway; keep the 30 ms default.
("5.5.4", True, {}),
# No wifi (e.g. ethernet) means no radio contention.
("5.5.5", False, {}),
# Coexistence disabled: no arbiter, so a full-duty scan would starve
# wifi outright.
("5.5.5", True, {CONF_SOFTWARE_COEXISTENCE: False}),
],
)
def test_30ms_default_kept(
stage_esp32: Callable[..., None],
idf: str,
wifi: bool,
config: ConfigType,
) -> None:
stage_esp32(idf, wifi=wifi)
assert to_ble_units(_scan_params(config)[CONF_WINDOW]) == 48
@pytest.mark.parametrize("window", ["60ms", "30ms"])
def test_explicit_window_is_never_touched(
stage_esp32: Callable[..., None], window: str
) -> None:
"""A user-set window wins over the conditional default.
The explicit 30 ms case matters: it is indistinguishable from the
defaulted value by inspection, so the defaulted flag must separate them.
"""
stage_esp32("5.5.5", wifi=True)
params = _scan_params({"scan_parameters": {"window": window}})
assert to_ble_units(params[CONF_WINDOW]) == to_ble_units(
cv.positive_time_period(window)
)
def test_short_interval_without_window_still_rejected(
stage_esp32: Callable[..., None],
) -> None:
"""The provisional 30 ms default validates against the interval as before."""
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
@pytest.mark.parametrize(
("wifi", "params", "expect_warning"),
[
(True, {"interval": "1100ms", "window": "1100ms"}, True),
(True, {"interval": "1100ms", "window": "601ms"}, True),
(True, {"interval": "1100ms", "window": "600ms"}, False),
(False, {"interval": "1100ms", "window": "1100ms"}, False),
],
)
def test_long_window_with_wifi_warns(
stage_esp32: Callable[..., None],
caplog: pytest.LogCaptureFixture,
wifi: bool,
params: ConfigType,
expect_warning: bool,
) -> None:
"""A scan window above 600 ms warns only when wifi shares the radio."""
stage_esp32("5.5.5", wifi=wifi)
with caplog.at_level(logging.WARNING):
_scan_params({"scan_parameters": params})
assert ("starves wifi" in caplog.text) is expect_warning
def test_long_window_warns_with_coexistence_disabled(
stage_esp32: Callable[..., None],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Disabling the arbiter is the worst case for a long window, so it still warns."""
stage_esp32("5.5.5", wifi=True)
with caplog.at_level(logging.WARNING):
_scan_params(
{
CONF_SOFTWARE_COEXISTENCE: False,
"scan_parameters": {"interval": "1100ms", "window": "1100ms"},
}
)
assert "BLE scan window of 1100ms" in caplog.text
def test_raised_window_warning_points_at_interval(
stage_esp32: Callable[..., None],
caplog: pytest.LogCaptureFixture,
) -> None:
"""When the window was raised to a long interval, the warning names the interval."""
stage_esp32("5.5.5", wifi=True)
with caplog.at_level(logging.WARNING):
_scan_params({"scan_parameters": {"interval": "1s"}})
assert "BLE scan interval of 1s" in caplog.text
assert "BLE scan window of" not in caplog.text
@@ -0,0 +1,35 @@
"""Tests for the esp32_hosted ESP-IDF version gate."""
import pytest
from esphome import config_validation as cv
from esphome.components.esp32 import KEY_IDF_VERSION
from esphome.components.esp32_hosted import _final_validate
from esphome.const import PlatformFramework
from ..types import SetCoreConfigCallable
@pytest.mark.parametrize("idf", ["5.3.0", "5.4.2", "5.5.5"])
def test_final_validate_accepts_supported_idf(
set_core_config: SetCoreConfigCallable, idf: str
) -> None:
"""ESP-IDF 5.3 and newer passes validation unchanged."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
)
_final_validate({})
@pytest.mark.parametrize("idf", ["5.0.0", "5.2.2"])
def test_final_validate_rejects_old_idf(
set_core_config: SetCoreConfigCallable, idf: str
) -> None:
"""ESP-IDF older than 5.3 is rejected with a clear error."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_IDF_VERSION: cv.Version.parse(idf)},
)
with pytest.raises(cv.Invalid, match="requires ESP-IDF 5.3 or newer"):
_final_validate({})
@@ -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
@@ -1,16 +1,21 @@
"""Tests for the external_components skip-update behavior driven by CORE.skip_external_update."""
"""Tests for the external_components config pass."""
import logging
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
import pytest
from esphome.components.external_components import do_external_components_pass
from esphome.const import (
CONF_EXTERNAL_COMPONENTS,
CONF_PATH,
CONF_REFRESH,
CONF_SOURCE,
CONF_URL,
TYPE_GIT,
TYPE_LOCAL,
)
from esphome.core import CORE, TimePeriodSeconds
@@ -69,3 +74,112 @@ def test_external_components_normal_refresh(
mock_clone_or_update.assert_called_once()
call_args = mock_clone_or_update.call_args
assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1)
def test_external_components_logs_built_in_override(
tmp_path: Path,
mock_clone_or_update: MagicMock,
mock_install_meta_finder: MagicMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A source that provides a component with the same name as a built-in one logs an info message."""
mock_clone_or_update.return_value = (tmp_path, None)
config = _make_config(tmp_path)
for name in ("gpio", "some_custom_component"):
component_dir = tmp_path / "components" / name
component_dir.mkdir()
(component_dir / "__init__.py").write_text("# Test component")
with caplog.at_level(logging.INFO):
do_external_components_pass(config)
assert (
"External components are overriding built-in components:\n"
" source: https://github.com/test/components\n"
" components: gpio" in caplog.text
)
assert "some_custom_component" not in caplog.text
def test_external_components_override_log_includes_ref(
tmp_path: Path,
mock_clone_or_update: MagicMock,
mock_install_meta_finder: MagicMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A git source with a ref logs the ref appended to the url."""
mock_clone_or_update.return_value = (tmp_path, None)
config = _make_config(tmp_path)
config[CONF_EXTERNAL_COMPONENTS][0][CONF_SOURCE] = "github://test/components@main"
component_dir = tmp_path / "components" / "gpio"
component_dir.mkdir()
(component_dir / "__init__.py").write_text("# Test component")
with caplog.at_level(logging.INFO):
do_external_components_pass(config)
assert " source: https://github.com/test/components.git@main\n" in caplog.text
def test_external_components_override_log_includes_git_path(
tmp_path: Path,
mock_clone_or_update: MagicMock,
mock_install_meta_finder: MagicMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A git source with a subdirectory path logs the path after the url."""
mock_clone_or_update.return_value = (tmp_path, None)
config = _make_config(tmp_path)
config[CONF_EXTERNAL_COMPONENTS][0][CONF_SOURCE][CONF_PATH] = "components"
component_dir = tmp_path / "components" / "gpio"
component_dir.mkdir()
(component_dir / "__init__.py").write_text("# Test component")
with caplog.at_level(logging.INFO):
do_external_components_pass(config)
assert " source: https://github.com/test/components (components)\n" in caplog.text
def test_external_components_override_log_local_source(
tmp_path: Path,
mock_install_meta_finder: MagicMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A local source logs its resolved path."""
components_dir = tmp_path / "my_components"
gpio_dir = components_dir / "gpio"
gpio_dir.mkdir(parents=True)
(gpio_dir / "__init__.py").write_text("# Test component")
CORE.config_path = tmp_path / "dummy.yaml"
config = {
CONF_EXTERNAL_COMPONENTS: [
{CONF_SOURCE: {"type": TYPE_LOCAL, CONF_PATH: "my_components"}}
]
}
with caplog.at_level(logging.INFO):
do_external_components_pass(config)
assert f" source: {components_dir}\n" in caplog.text
assert " components: gpio" in caplog.text
def test_external_components_no_override_no_log(
tmp_path: Path,
mock_clone_or_update: MagicMock,
mock_install_meta_finder: MagicMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A source that only provides components not shipped with ESPHome logs nothing."""
mock_clone_or_update.return_value = (tmp_path, None)
config = _make_config(tmp_path)
with caplog.at_level(logging.INFO):
do_external_components_pass(config)
assert "are overriding built-in components" not in caplog.text
@@ -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,61 @@
"""Tests for the shared io expander interrupt_pin validator."""
from __future__ import annotations
import importlib
import pytest
from esphome import config_validation as cv
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
from esphome.components.gpio_expander import validate_interrupt_pin
from esphome.const import PlatformFramework
from tests.component_tests.types import SetCoreConfigCallable
@pytest.fixture
def stage_esp32(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
def test_plain_pin_accepted(stage_esp32: None) -> None:
value = validate_interrupt_pin(
{"number": 16, "mode": {"input": True, "pullup": True}}
)
assert value["number"] == 16
def test_inverted_rejected(stage_esp32: None) -> None:
with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"):
validate_interrupt_pin({"number": 16, "inverted": True})
def test_allow_other_uses_rejected(stage_esp32: None) -> None:
with pytest.raises(cv.Invalid, match="'allow_other_uses: true' is not supported"):
validate_interrupt_pin({"number": 16, "allow_other_uses": True})
# mcp23017 covers the shared mcp23xxx_base schema
@pytest.mark.parametrize(
"component",
[
"pcf8574",
"pca9554",
"tca9555",
"pca6416a",
"pi4ioe5v6408",
"mcp23016",
"mcp23017",
],
)
def test_component_schemas_route_through_validator(
stage_esp32: None, component: str
) -> None:
module = importlib.import_module(f"esphome.components.{component}")
with pytest.raises(cv.Invalid, match="'inverted: true' is not supported"):
module.CONFIG_SCHEMA(
{"id": "expander_hub", "interrupt_pin": {"number": 16, "inverted": True}}
)
+6 -16
View File
@@ -87,13 +87,11 @@ def test_cache_path_is_deterministic_per_url(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""The cache path is derived from (and stable for) the URL."""
monkeypatch.setattr(
gsl.external_files, "compute_local_file_dir", lambda _: tmp_path
)
monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path))
first = gsl._cache_path(VALID_URL)
assert first == gsl._cache_path(VALID_URL)
assert first != gsl._cache_path("https://example.com/other.bin")
assert first.parent == tmp_path
assert first.parent == tmp_path / "gsl3670"
def test_firmware_path_prefers_local_file(tmp_path: Path) -> None:
@@ -106,9 +104,7 @@ def test_firmware_path_uses_cache_for_url(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A ``url`` source resolves to the cache path for that URL."""
monkeypatch.setattr(
gsl.external_files, "compute_local_file_dir", lambda _: tmp_path
)
monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path))
assert gsl.firmware_path({"url": VALID_URL}) == gsl._cache_path(VALID_URL)
@@ -145,9 +141,7 @@ def test_firmware_url_downloads_and_validates(
) -> None:
"""A url source downloads the content and validates its structure."""
data = _make_firmware()
monkeypatch.setattr(
gsl.external_files, "compute_local_file_dir", lambda _: tmp_path
)
monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path))
monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data)
assert gsl._validate_firmware({"url": VALID_URL}) == {"url": VALID_URL}
@@ -157,9 +151,7 @@ def test_firmware_url_sha256_mismatch_rejected(
) -> None:
"""A configured SHA-256 that does not match the download is rejected."""
data = _make_firmware()
monkeypatch.setattr(
gsl.external_files, "compute_local_file_dir", lambda _: tmp_path
)
monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path))
monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data)
with pytest.raises(cv.Invalid, match="SHA-256 mismatch"):
gsl._validate_firmware({"url": VALID_URL, "sha256": "00" * 32})
@@ -169,9 +161,7 @@ def test_firmware_url_invalid_structure_rejected(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Downloaded content that is not a valid blob is rejected."""
monkeypatch.setattr(
gsl.external_files, "compute_local_file_dir", lambda _: tmp_path
)
monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path))
monkeypatch.setattr(
gsl.external_files, "download_content", lambda url, path: b"\x00\x01\x02"
)
@@ -0,0 +1,12 @@
esphome:
name: test
esp32:
board: nodemcu-32s
wifi:
ssid: test
password: testtest
http_request:
timeout: 10s
@@ -0,0 +1,13 @@
esphome:
name: test
esp32:
board: nodemcu-32s
wifi:
ssid: test
password: testtest
http_request:
timeout: 10s
watchdog_timeout: 20s
@@ -0,0 +1,13 @@
esphome:
name: test
esp32:
board: nodemcu-32s
watchdog_timeout: 60s
wifi:
ssid: test
password: testtest
http_request:
timeout: 10s
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
board: nodemcu-32s
wifi:
ssid: test
password: testtest
http_request:
@@ -0,0 +1,13 @@
esphome:
name: test
esp8266:
board: d1_mini
wifi:
ssid: test
password: testtest
http_request:
timeout: 10s
verify_ssl: false
@@ -0,0 +1,13 @@
esphome:
name: test
rp2:
board: rpipicow
wifi:
ssid: test
password: testtest
http_request:
timeout: 10s
verify_ssl: false
@@ -0,0 +1,42 @@
"""Tests for the http_request watchdog timeout default."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.config import read_config
from esphome.const import CONF_WATCHDOG_TIMEOUT
from esphome.core import CORE, TimePeriodMilliseconds
@pytest.mark.parametrize(
("yaml_file", "expected_ms"),
[
# stock 4.5s timeout: 3 x 4.5s plus 1s margin
("test_esp32_stock.yaml", 14500),
# 3 x 10s plus 1s margin
("test_esp32_default.yaml", 31000),
# esp32.watchdog_timeout: 60s is wider than the derived value and wins
("test_esp32_platform_wider.yaml", 60000),
# explicit value is kept as is
("test_esp32_explicit.yaml", 20000),
],
)
def test_esp32_watchdog_timeout(
component_config_path: Callable[[str], Path], yaml_file: str, expected_ms: int
) -> None:
CORE.config_path = component_config_path(yaml_file)
config = read_config({})
assert config["http_request"][CONF_WATCHDOG_TIMEOUT] == TimePeriodMilliseconds(
milliseconds=expected_ms
)
@pytest.mark.parametrize("yaml_file", ["test_esp8266.yaml", "test_rp2040.yaml"])
def test_other_platforms_leave_watchdog_unset(
component_config_path: Callable[[str], Path], yaml_file: str
) -> None:
CORE.config_path = component_config_path(yaml_file)
config = read_config({})
assert CONF_WATCHDOG_TIMEOUT not in config["http_request"]
+334 -11
View File
@@ -21,16 +21,20 @@ from esphome.components.image import (
CONF_OPAQUE,
CONF_TRANSPARENCY,
PLATFORM_FILE,
_expand_platform_entry,
_flatten_legacy_image_config,
_is_legacy_image_format,
_is_new_image_format,
_migrate_legacy_image_config,
expand_platform_config,
get_all_image_metadata,
get_image_metadata,
)
from esphome.const import (
CONF_DEFAULTS,
CONF_DITHER,
CONF_FILE,
CONF_FILES,
CONF_ID,
CONF_PLATFORM,
CONF_RAW_DATA_ID,
@@ -259,6 +263,15 @@ def test_flatten_keeps_byte_order_for_endian_type() -> None:
assert out[0][CONF_BYTE_ORDER] == "little_endian"
def test_flatten_drops_byte_order_written_directly_on_legacy_entry() -> None:
"""The legacy flattener drops an incompatible byte_order even when written directly on the entry."""
out = _flatten_legacy_image_config(
{"binary": [{"id": "a", "file": "x.png", "byte_order": "little_endian"}]}
)
assert out == [{"id": "a", "file": "x.png", "type": "binary"}]
assert CONF_BYTE_ORDER not in out[0]
def test_flatten_skips_meta_and_unknown_keys() -> None:
out = _flatten_legacy_image_config(
{
@@ -342,6 +355,42 @@ def test_migrate_legacy_warns_and_prepends_platform(
),
pytest.param({"foo": 1}, False, id="dict_unknown_keys"),
pytest.param("a string", False, id="scalar"),
# A `platform:`-tagged dict is the new format written without list brackets.
pytest.param(
{CONF_PLATFORM: "file", "id": "a", "file": "x.png"},
False,
id="platform_tagged_flat_dict",
),
pytest.param(
{
CONF_PLATFORM: "file",
"defaults": {"type": "rgb565"},
"files": [{"id": "a", "file": "x.png"}],
},
False,
id="platform_tagged_defaults_files_dict",
),
# `files:` without `platform:` is not legacy either -- the flattener has no branch for it.
pytest.param(
{
"defaults": {"type": "rgb565"},
"files": [{"id": "a", "file": "x.png"}],
},
False,
id="defaults_files_dict_without_platform",
),
# Same as above in a list -- without this exclusion it would be silently
# migrated to a hard-coded `platform: file` instead of raising the error.
pytest.param(
[
{
"defaults": {"type": "rgb565"},
"files": [{"id": "a", "file": "x.png"}],
}
],
False,
id="defaults_files_list_entry_without_platform",
),
],
)
def test_is_legacy_image_format(config: object, expected: bool) -> None:
@@ -359,39 +408,313 @@ def test_is_legacy_image_format(config: object, expected: bool) -> None:
def test_migrate_returns_none_for_invalid_legacy_shapes(
config: object, caplog: pytest.LogCaptureFixture
) -> None:
"""Unrecognised shapes are not migrated (and emit no warning) so normal
platform validation surfaces a proper error instead of silently dropping
the offending input."""
"""Unrecognised shapes are not migrated (and emit no warning), so normal platform validation reports them."""
with caplog.at_level(logging.WARNING):
assert _migrate_legacy_image_config(config) is None
assert "deprecated" not in caplog.text
def test_migrate_returns_none_for_mapping_form_defaults_files() -> None:
"""A `platform:`-tagged `defaults:`/`files:` mapping must not be swallowed by the legacy migrator."""
config = {
CONF_PLATFORM: "file",
"defaults": {"type": "rgb565"},
"files": [{"id": "a", "file": "a.png"}],
}
assert _migrate_legacy_image_config(config) is None
def test_migrate_returns_none_for_defaults_files_dict_without_platform() -> None:
"""`defaults:`/`files:` without `platform:` must not be swallowed either -- the flattener has
no `files:` branch and would silently return `[]`."""
config = {
"defaults": {"type": "rgb565"},
"files": [{"id": "a", "file": "a.png"}],
}
assert _migrate_legacy_image_config(config) is None
def test_migrate_returns_none_for_defaults_files_list_entry_without_platform() -> None:
"""Same, in a list -- previously the list branch migrated it to a hard-coded
`platform: file` instead of raising a missing-platform error."""
config = [
{
"defaults": {"type": "rgb565"},
"files": [{"id": "a", "file": "a.png"}],
}
]
assert _migrate_legacy_image_config(config) is None
# --------------------------- end legacy migration --------------------------
def test_expand_platform_entry_passes_through_plain_entry() -> None:
entry = {CONF_PLATFORM: "file", "id": "a", "file": "x.png"}
assert _expand_platform_entry(0, entry) == [entry]
def test_expand_platform_entry_expands_files_with_defaults() -> None:
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "RGB565", "transparency": "opaque"},
CONF_FILES: [
{"id": "img1", "file": "foo.png"},
{"id": "img2", "file": "bar.png", "type": "GRAYSCALE"},
],
}
assert _expand_platform_entry(0, entry) == [
{
CONF_PLATFORM: "file",
"id": "img1",
"file": "foo.png",
"type": "RGB565",
"transparency": "opaque",
},
{
CONF_PLATFORM: "file",
"id": "img2",
"file": "bar.png",
"type": "GRAYSCALE",
"transparency": "opaque",
},
]
def test_expand_platform_entry_files_without_defaults() -> None:
entry = {
CONF_PLATFORM: "file",
CONF_FILES: [{"id": "img1", "file": "foo.png"}],
}
assert _expand_platform_entry(0, entry) == [
{CONF_PLATFORM: "file", "id": "img1", "file": "foo.png"}
]
def test_expand_platform_entry_preserves_source_range() -> None:
"""A merged entry keeps the source range of its `files:` item so whole-entry errors anchor there."""
from esphome import yaml_util
file_entry = yaml_util.make_data_base({"id": "img1", "file": "foo.png"})
file_entry._esp_range = "sentinel-range"
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "RGB565"},
CONF_FILES: [file_entry],
}
[out] = _expand_platform_entry(0, entry)
assert isinstance(out, yaml_util.ESPHomeDataBase)
assert out.esp_range == "sentinel-range"
def test_expand_platform_entry_plain_dict_file_entry_has_no_source_range() -> None:
"""Plain-dict `files:` items must not crash -- `from_database` reads `.esp_range` unconditionally."""
entry = {
CONF_PLATFORM: "file",
CONF_FILES: [{"id": "img1", "file": "foo.png"}],
}
[out] = _expand_platform_entry(0, entry)
assert out == {CONF_PLATFORM: "file", "id": "img1", "file": "foo.png"}
def test_expand_platform_entry_per_file_overrides_win() -> None:
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "RGB565"},
CONF_FILES: [{"id": "img1", "file": "foo.png", "type": "BINARY"}],
}
[out] = _expand_platform_entry(0, entry)
assert out["type"] == "BINARY"
def test_expand_platform_entry_drops_byte_order_for_non_endian_override() -> None:
"""A `byte_order` default merged into a non-endian override is dropped, as the legacy flattener did."""
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "rgb565", "byte_order": "little_endian"},
CONF_FILES: [
{"id": "a", "file": "x.png"},
{"id": "b", "file": "y.png", "type": "binary"},
],
}
out = _expand_platform_entry(0, entry)
assert out[0]["byte_order"] == "little_endian"
assert "byte_order" not in out[1]
def test_expand_platform_entry_invalid_byte_order_in_defaults_raises() -> None:
"""A dropped `byte_order` inherited from `defaults:` is still validated, so a typo raises."""
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "rgb565", "byte_order": "little_andian"},
CONF_FILES: [{"id": "a", "file": "x.png", "type": "binary"}],
}
with pytest.raises(cv.Invalid, match="did you mean") as excinfo:
_expand_platform_entry(0, entry)
assert excinfo.value.path == [0]
def test_expand_platform_entry_keeps_byte_order_for_endian_override() -> None:
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "rgb565", "byte_order": "big_endian"},
CONF_FILES: [{"id": "a", "file": "x.png", "type": "rgb565"}],
}
[out] = _expand_platform_entry(0, entry)
assert out["byte_order"] == "big_endian"
def test_expand_platform_entry_keeps_explicit_byte_order_conflict() -> None:
"""A `byte_order` written directly on the entry is kept so validate_settings raises the normal error."""
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "rgb565"},
CONF_FILES: [
{
"id": "a",
"file": "x.png",
"type": "binary",
"byte_order": "little_endian",
}
],
}
[out] = _expand_platform_entry(0, entry)
assert out["byte_order"] == "little_endian"
def test_expand_platform_entry_defaults_without_files_raises() -> None:
entry = {CONF_PLATFORM: "file", CONF_DEFAULTS: {"type": "RGB565"}}
with pytest.raises(cv.Invalid, match="may only be used together with") as excinfo:
_expand_platform_entry(0, entry)
assert excinfo.value.path == [0]
def test_expand_platform_entry_null_files_raises_not_empty() -> None:
"""A `files:` key with no value parses to `None` and must be reported clearly."""
entry = {CONF_PLATFORM: "file", CONF_DEFAULTS: {"type": "RGB565"}, CONF_FILES: None}
with pytest.raises(cv.Invalid, match="must not be empty"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_empty_files_list_raises_not_empty() -> None:
"""An explicit `files: []` must not silently drop the whole platform entry."""
entry = {CONF_PLATFORM: "file", CONF_FILES: []}
with pytest.raises(cv.Invalid, match="must not be empty"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_files_with_stray_key_raises() -> None:
entry = {
CONF_PLATFORM: "file",
CONF_FILES: [{"id": "a", "file": "x.png"}],
"extra": 1,
}
with pytest.raises(cv.Invalid, match="cannot be combined with"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_id_in_defaults_raises() -> None:
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {CONF_ID: "a"},
CONF_FILES: [{"file": "x.png"}],
}
with pytest.raises(cv.Invalid, match="not allowed inside"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_platform_in_defaults_raises() -> None:
"""`platform:` inside `defaults:` would silently reassign every file's platform."""
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: {CONF_PLATFORM: "animation"},
CONF_FILES: [{"id": "a", "file": "x.png"}],
}
with pytest.raises(cv.Invalid, match="not allowed inside"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_platform_in_file_entry_raises() -> None:
"""`platform:` on a `files:` item must not silently override the entry's platform."""
entry = {
CONF_PLATFORM: "file",
CONF_FILES: [{"id": "a", "file": "x.png", CONF_PLATFORM: "animation"}],
}
with pytest.raises(cv.Invalid, match="not allowed inside"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_files_not_list_raises() -> None:
entry = {CONF_PLATFORM: "file", CONF_FILES: "not-a-list"}
with pytest.raises(cv.Invalid, match="must be a list"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_defaults_not_mapping_raises() -> None:
entry = {
CONF_PLATFORM: "file",
CONF_DEFAULTS: "not-a-mapping",
CONF_FILES: [{"id": "a", "file": "x.png"}],
}
with pytest.raises(cv.Invalid, match="must be a mapping"):
_expand_platform_entry(0, entry)
def test_expand_platform_entry_file_item_not_mapping_raises() -> None:
entry = {CONF_PLATFORM: "file", CONF_FILES: [1, 2]}
with pytest.raises(cv.Invalid, match="must be a mapping"):
_expand_platform_entry(0, entry)
def test_expand_platform_config_mixes_plain_and_expanded_entries() -> None:
config = [
{
CONF_PLATFORM: "file",
CONF_DEFAULTS: {"type": "RGB565"},
CONF_FILES: [
{"id": "img1", "file": "foo.png"},
{"id": "img2", "file": "bar.png"},
],
},
{CONF_PLATFORM: "file", "id": "plain", "file": "baz.png", "type": "BINARY"},
]
out = expand_platform_config(config)
assert [entry["id"] for entry in out] == ["img1", "img2", "plain"]
def test_expand_platform_config_ignores_non_platform_entries() -> None:
# Not expanded here -- legacy_config_migrate runs before this hook and is
# responsible for tagging/flattening pre-platform shapes.
config = ["not-a-platform-entry"]
assert expand_platform_config(config) == config
# --------------------- end defaults/files expansion -------------------------
def test_validate_image_final_defaults_to_little_endian() -> None:
out = validate_image_final({CONF_FILE: "x.png"})
assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN"
config = {CONF_FILE: "x.png"}
validate_image_final(config)
assert config[CONF_BYTE_ORDER] == "LITTLE_ENDIAN"
def test_validate_image_final_keeps_little_endian(
caplog: pytest.LogCaptureFixture,
) -> None:
config = {CONF_FILE: "x.png", CONF_BYTE_ORDER: "LITTLE_ENDIAN"}
with caplog.at_level(logging.WARNING):
out = validate_image_final(
{CONF_FILE: "x.png", CONF_BYTE_ORDER: "LITTLE_ENDIAN"}
)
assert out[CONF_BYTE_ORDER] == "LITTLE_ENDIAN"
validate_image_final(config)
assert config[CONF_BYTE_ORDER] == "LITTLE_ENDIAN"
assert "big-endian" not in caplog.text
def test_validate_image_final_warns_on_big_endian(
caplog: pytest.LogCaptureFixture,
) -> None:
config = {CONF_FILE: "x.png", CONF_BYTE_ORDER: "BIG_ENDIAN"}
with caplog.at_level(logging.WARNING):
out = validate_image_final({CONF_FILE: "x.png", CONF_BYTE_ORDER: "BIG_ENDIAN"})
assert out[CONF_BYTE_ORDER] == "BIG_ENDIAN"
validate_image_final(config)
assert config[CONF_BYTE_ORDER] == "BIG_ENDIAN"
assert "big-endian" in caplog.text
@@ -0,0 +1,36 @@
esphome:
name: test-dropdown-update-event
on_boot:
- lvgl.dropdown.update:
id: test_dropdown
selected_index: 2
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin: GPIO3
lvgl:
widgets:
- dropdown:
id: test_dropdown
options:
- First
- Second
- Third
on_update:
- lambda: |-
ESP_LOGD("test", "dropdown updated");
@@ -0,0 +1,36 @@
esphome:
name: test-list-on-add-lvgl-action
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin: GPIO3
lvgl:
widgets:
- label:
id: later_label
text: orig
- list:
id: test_list
on_add:
- lvgl.label.update:
id: later_label
text: "changed"
on_remove:
- lvgl.label.update:
id: later_label
text: "removed"
@@ -0,0 +1,41 @@
esphome:
name: test-list-outside-block
on_boot:
priority: -100
then:
- lvgl.list.add:
id: test_list
switch:
transform_rotation: 100
drop_shadow_color: 0x000000
bg_image_src: my_image
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin: GPIO3
image:
- platform: file
file: mdi:battery
id: my_image
resize: 8x8
type: binary
lvgl:
widgets:
- list:
id: test_list
@@ -0,0 +1,77 @@
esphome:
name: test-list
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin: GPIO3
lvgl:
theme:
label:
bg_color: 0xFF0000
widgets:
- list:
id: test_list
pad_row: 4
on_add:
- delay: 10ms
- delay: 20ms
on_remove:
- delay: 10ms
- button:
id: trigger_button
text: "Trigger"
on_click:
- lvgl.list.add_text:
id: test_list
text: "Header"
- lvgl.list.add_text:
id: test_list
text: "Pinned"
index: 0
- lvgl.list.add:
id: test_list
button:
text: "Entry"
checkable: true
- lvgl.list.add:
id: test_list
index: 1
obj:
widgets:
- label:
text: "Nested"
- dropdown:
options:
- "One"
- "Two"
- lvgl.list.add:
id: test_list
obj:
widgets:
- obj:
widgets:
- label:
text: "Grandchild"
- lvgl.list.remove:
id: test_list
index: 0
- lvgl.list.clear:
id: test_list
- lvgl.list.update:
id: test_list
pad_row: 8
@@ -0,0 +1,41 @@
"""Regression test: lvgl.dropdown.update with selected_index must fire on_value/on_update.
LvSelect (backing both dropdown and roller) did not set `value_property`, so the generic
update-action machinery in automation.py never sent the synthetic update event for a
`selected_index:` change made via `lvgl.dropdown.update`/`lvgl.roller.update`, unlike `value:`
on number widgets or `text:` on text widgets. Fixed by setting `LvSelect.value_property` to
`CONF_SELECTED_INDEX`.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
from esphome.core import CORE
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
config_path = (
Path(request.fspath).parent / "config" / "dropdown_update_fires_event_test.yaml"
)
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_dropdown_update_sends_update_event(main_cpp: str) -> None:
assert (
"lv_obj_send_event(test_dropdown->obj, lvgl::lv_update_event, nullptr)"
in main_cpp
)
+403
View File
@@ -0,0 +1,403 @@
"""Tests for the LVGL ``list`` widget: schema validation for its actions
(``lvgl.list.add_text``/``add``/``remove``/``clear``) and the code they generate.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.components.lvgl.widgets.lv_list import (
LIST_CREATE_SCHEMA,
LIST_REMOVE_SCHEMA,
LIST_SCHEMA,
list_add_schema,
)
from esphome.config import read_config
import esphome.config_validation as cv
from esphome.core import CORE
# ---------------------------------------------------------------------------
# lvgl.list.add schema: id + optional index + exactly one widget-type key
# ---------------------------------------------------------------------------
class TestListAddSchema:
def test_valid_single_widget(self) -> None:
result = list_add_schema({"id": "my_list", "label": {"text": "hi"}})
assert result["id"].id == "my_list"
assert "widget" in result
def test_index_optional_and_templatable(self) -> None:
result = list_add_schema({"id": "my_list", "index": 2, "label": {"text": "hi"}})
assert result["index"] == 2
def test_index_omitted_when_not_given(self) -> None:
result = list_add_schema({"id": "my_list", "label": {"text": "hi"}})
assert "index" not in result
def test_missing_id_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="required key 'id' not provided"):
list_add_schema({"label": {"text": "hi"}})
def test_no_widget_key_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="exactly one widget definition"):
list_add_schema({"id": "my_list"})
def test_two_widget_keys_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="exactly one widget definition"):
list_add_schema(
{
"id": "my_list",
"label": {"text": "a"},
"button": {"text": "b"},
}
)
def test_non_mapping_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="Expected a mapping"):
list_add_schema("not_a_mapping")
def test_any_registered_widget_type_accepted(self) -> None:
for widget_key, widget_conf in (
("checkbox", {"text": "Option"}),
("switch", {}),
("spinner", {}),
("obj", {}),
("dropdown", {"options": ["a", "b"]}),
):
result = list_add_schema({"id": "my_list", widget_key: widget_conf})
assert widget_key in result["widget"][0]
@pytest.mark.parametrize(
("widget_key", "widget_conf"),
[
("buttonmatrix", {"rows": [{"buttons": [{"text": "A"}]}]}),
("tabview", {"tabs": [{"name": "Tab1"}]}),
("tileview", {"tiles": [{"row": 0, "column": 0}]}),
("meter", {"scales": [{"range_from": 0, "range_to": 100}]}),
("canvas", {"width": 20, "height": 20}),
],
)
def test_dynamic_widget_unsupported_rejected(
self, widget_key: str, widget_conf: dict
) -> None:
"""buttonmatrix/tabview/tileview all register their own child widgets into
the global widget map from inside their to_code - fine for a widget built
once at boot, but broken if lvgl.list.add re-enters that on every call.
meter/canvas are rejected for a related but distinct reason: they declare a
Pvariable (meter's scale/indicator objects; canvas's draw buffer) with
cg.Pvariable()/cg.new_Pvariable(), which emits its assignment wherever code
is currently being generated -- fine at the top level of a boot-time
to_code, but lvgl.list.add's do_add runs inside a lambda. meter's assignment
would then end up outside the very lambda that declares the local object it
refers to (doesn't compile); canvas's Pvariable is declared once per config
site rather than per call, so every call overwrites its one draw buffer
(compiles, but leaks the old buffer and shares one buffer across every row).
"""
with pytest.raises(cv.Invalid, match="cannot be used with lvgl.list.add"):
list_add_schema({"id": "my_list", widget_key: widget_conf})
def test_dynamic_widget_unsupported_rejected_when_nested(self) -> None:
"""The check must recurse into `widgets:` so a tabview hidden a few levels
deep inside another widget is caught too, not just at the top level.
"""
with pytest.raises(cv.Invalid, match="cannot be used with lvgl.list.add"):
list_add_schema(
{
"id": "my_list",
"obj": {"widgets": [{"tabview": {"tabs": [{"name": "Tab1"}]}}]},
}
)
def test_explicit_id_rejected(self) -> None:
"""A dynamically-added widget is LocalVariable-scoped and rebuilt fresh
on every call, never registered anywhere an id could be looked up by --
an explicit id: would otherwise validate fine and then fail confusingly
(an uncaught traceback, not a clean config error) the moment anything
tries to reference it.
"""
with pytest.raises(cv.Invalid, match="'id' is not allowed"):
list_add_schema(
{"id": "my_list", "label": {"id": "dyn_label", "text": "hi"}}
)
def test_explicit_id_rejected_when_nested(self) -> None:
with pytest.raises(cv.Invalid, match="'id' is not allowed"):
list_add_schema(
{
"id": "my_list",
"obj": {"widgets": [{"label": {"id": "dyn_label", "text": "hi"}}]},
}
)
def test_no_explicit_id_still_valid(self) -> None:
"""An id is auto-generated (and simply unused) when none is given --
only an explicit one is rejected."""
result = list_add_schema({"id": "my_list", "label": {"text": "hi"}})
assert "id" in result["widget"][0]["label"]
@pytest.mark.parametrize(
("key", "conf"),
[
("on_swipe_left", [{"logger.log": "swiped"}]),
("on_swipe_right", [{"logger.log": "swiped"}]),
("on_swipe_up", [{"logger.log": "swiped"}]),
("on_swipe_down", [{"logger.log": "swiped"}]),
("on_boot", [{"logger.log": "booted"}]),
("align_to", {"id": "some_other_widget", "align": "OUT_LEFT_TOP"}),
],
)
def test_unsupported_trigger_rejected(self, key: str, conf: list) -> None:
"""_wire_dynamic_triggers only wires LV_EVENT_TRIGGERS/on_value/on_update --
on_swipe_*/on_boot would otherwise validate fine and then silently generate
nothing at all for a widget added via lvgl.list.add. align_to is in the same
bucket: it's only ever consumed by generate_triggers() reading
get_widget_map(), which a widget built via lvgl.list.add never enters.
"""
with pytest.raises(cv.Invalid, match="is not supported"):
list_add_schema({"id": "my_list", "obj": {key: conf}})
def test_unsupported_trigger_rejected_when_nested(self) -> None:
with pytest.raises(cv.Invalid, match="is not supported"):
list_add_schema(
{
"id": "my_list",
"obj": {
"widgets": [
{
"label": {
"text": "hi",
"on_swipe_left": [{"logger.log": "swiped"}],
}
}
]
},
}
)
# ---------------------------------------------------------------------------
# lvgl.list.remove: index must be non-negative -- LVGL treats a negative index as
# counting back from the end, which would silently delete the wrong row while
# reporting a list_index that matches nothing real to on_remove.
# ---------------------------------------------------------------------------
class TestListRemoveSchema:
def test_negative_index_rejected(self) -> None:
with pytest.raises(cv.Invalid, match="at least 0"):
LIST_REMOVE_SCHEMA({"id": "my_list", "index": -1})
def test_zero_index_accepted(self) -> None:
result = LIST_REMOVE_SCHEMA({"id": "my_list", "index": 0})
assert result["index"] == 0
# ---------------------------------------------------------------------------
# The list widget's own schema: pad_row is shared between create/update, but
# on_add/on_remove only make sense at creation time.
# ---------------------------------------------------------------------------
class TestListCreateVsModifySchema:
def test_create_schema_has_pad_row_and_triggers(self) -> None:
keys = {str(k) for k in LIST_CREATE_SCHEMA.schema}
assert "pad_row" in keys
assert "on_add" in keys
assert "on_remove" in keys
def test_modify_schema_has_pad_row_but_not_triggers(self) -> None:
"""``lvgl.list.update`` can change pad_row but can't (re-)declare triggers."""
keys = {str(k) for k in LIST_SCHEMA.schema}
assert "pad_row" in keys
assert "on_add" not in keys
assert "on_remove" not in keys
def test_on_add_single_automation_with_multiple_actions(self) -> None:
"""A bare action list under on_add: is one automation with a multi-step
`then:`, not multiple independent automations.
"""
config = LIST_CREATE_SCHEMA({"on_add": [{"delay": "10ms"}, {"delay": "20ms"}]})
assert len(config["on_add"]) == 1
assert len(config["on_add"][0]["then"]) == 2
def test_on_add_accepts_multiple_independent_automations(self) -> None:
"""Each explicit `then:` entry gets its own Trigger, so on_add can fire
more than one independent automation.
"""
config = LIST_CREATE_SCHEMA(
{
"on_add": [
{"then": [{"delay": "10ms"}]},
{"then": [{"delay": "20ms"}]},
]
}
)
assert len(config["on_add"]) == 2
# ---------------------------------------------------------------------------
# Code generation
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
"""Generate the C++ output for the shared list-widget YAML config once per
module -- see test_widget_state.py for why this is module-scoped and
inlines the generate_main fixture logic rather than depending on it.
"""
config_path = Path(request.fspath).parent / "config" / "list_test.yaml"
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_global_section + CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_pad_row_set_at_creation(main_cpp: str) -> None:
assert "lv_obj_set_style_pad_row(test_list, 4, LV_PART_MAIN);" in main_cpp
def test_pad_row_updated_via_update_action(main_cpp: str) -> None:
assert "lv_obj_set_style_pad_row(test_list, 8, LV_PART_MAIN);" in main_cpp
def test_add_text_appends(main_cpp: str) -> None:
assert 'lv_list_add_text(test_list, "Header");' in main_cpp
def test_add_text_with_index_moves_before_firing_on_add(main_cpp: str) -> None:
"""The index move must happen before on_add fires, so the reported
list_index reflects the entry's final position, not where it was appended.
"""
assert (
'lv_obj_t *list_entry_VAR_ = lv_list_add_text(test_list, "Pinned");\n'
" lv_obj_move_to_index(list_entry_VAR_, 0);\n"
" triggerint_id->trigger(lvgl::lv_list_get_row_index(test_list, list_entry_VAR_));"
) in main_cpp
def test_add_button_with_checkable_flag(main_cpp: str) -> None:
assert "lv_obj_t *dyn_button_VAR_ = lv_btn_create(test_list);" in main_cpp
assert (
"lv_obj_add_flag(dyn_button_VAR_, (lv_obj_flag_t)(LV_OBJ_FLAG_CHECKABLE));"
in main_cpp
)
assert (
'lv_label_set_text(lv_obj_get_child(dyn_button_VAR_, 0), "Entry");' in main_cpp
)
def test_add_nested_hierarchy_with_compound_child(main_cpp: str) -> None:
"""`obj: {widgets: [label, dropdown]}` builds a plain label child and a
heap-allocated (compound) dropdown child, both parented to the new row.
The child variable names carry a `_1` (depth) suffix, distinguishing them
from the row's own top-level variable -- necessary so that a child of the
*same* widget type as its parent (e.g. `obj: {widgets: [{obj: {...}}]}`)
doesn't declare a C++ variable that shadows its own not-yet-initialized
self, silently parenting the child to garbage.
"""
assert "lv_obj_t *dyn_obj_VAR_ = lv_obj_create(test_list);" in main_cpp
assert (
"lv_obj_t *dyn_label_1_VAR_ = lv_label_create(dyn_obj_VAR_);\n"
" lv_obj_add_style(dyn_label_1_VAR_, _lv_theme_style_label_main_default, "
"(lv_state_t)(LV_PART_MAIN));\n"
' lv_label_set_text(dyn_label_1_VAR_, "Nested");'
) in main_cpp
def test_add_applies_theme_styles_to_dynamic_widget(main_cpp: str) -> None:
"""A widget added via lvgl.list.add must pick up the same `theme:` styling a
statically-declared widget of the same type gets, not render unthemed.
"""
assert (
"lv_obj_add_style(dyn_label_1_VAR_, _lv_theme_style_label_main_default, "
"(lv_state_t)(LV_PART_MAIN));"
) in main_cpp
assert "LvDropdownType *dyn_dropdown_1_VAR_ = new LvDropdownType();" in main_cpp
assert "lv_dropdown_create(dyn_obj_VAR_)" in main_cpp
assert (
"lvgl::delete_lv_compound_on_delete<LvDropdownType>, LV_EVENT_DELETE, "
"dyn_dropdown_1_VAR_);"
) in main_cpp
def test_add_nested_same_type_child_does_not_shadow_parent(main_cpp: str) -> None:
"""A child of the same widget type as its parent (`obj: {widgets: [{obj:
...}]}`) must get a distinct C++ variable name (or the child's declaration
would shadow its own not-yet-initialized self, parenting it to garbage --
compiling clean but for a -Wuninitialized warning). A grandchild of a third
type proves depth, not just type, drives the disambiguating suffix.
"""
assert "lv_obj_t *dyn_obj_VAR_ = lv_obj_create(test_list);" in main_cpp
assert "lv_obj_t *dyn_obj_1_VAR_ = lv_obj_create(dyn_obj_VAR_);" in main_cpp
assert (
"lv_obj_t *dyn_label_2_VAR_ = lv_label_create(dyn_obj_1_VAR_);\n"
" lv_obj_add_style(dyn_label_2_VAR_, _lv_theme_style_label_main_default, "
"(lv_state_t)(LV_PART_MAIN));\n"
' lv_label_set_text(dyn_label_2_VAR_, "Grandchild");'
) in main_cpp
def test_add_moves_row_to_given_index_before_firing_on_add(main_cpp: str) -> None:
assert (
"lv_obj_move_to_index(dyn_obj_VAR_, 1);\n"
" triggerint_id->trigger(lvgl::lv_list_get_row_index(test_list, dyn_obj_VAR_));"
) in main_cpp
def test_on_add_fires_once_per_entry_via_shared_trigger(main_cpp: str) -> None:
"""A single on_add: automation means a single Trigger instance, reused by
every lvgl.list.add_text/add call site.
"""
assert main_cpp.count("triggerint_id->trigger(lvgl::lv_list_get_row_index(") == 5
def test_remove_guards_against_missing_child_and_fires_before_delete(
main_cpp: str,
) -> None:
"""The index is materialised into a local once (list_index_VAR_) and reused for
both the child lookup and the on_remove trigger, so a templatable index isn't
evaluated twice.
"""
assert (
"int list_index_VAR_ = 0;\n"
" {\n"
" lv_obj_t *list_child_VAR_ = lvgl::lv_list_get_row_for_remove(test_list, list_index_VAR_);\n"
" if (list_child_VAR_) {\n"
" triggerint_id_2->trigger(list_index_VAR_);\n"
" lv_obj_del(list_child_VAR_);"
) in main_cpp
def test_remove_out_of_range_lookup_uses_shared_cpp_helper(main_cpp: str) -> None:
"""The out-of-range lookup (and its log line) live in a single C++ helper --
lvgl::lv_list_get_row_for_remove() in lvgl_esphome.cpp -- rather than being
generated inline at every lvgl.list.remove call site, since a config can
have many of them and duplicating that logic (and its log string) at each
one would waste flash for no benefit.
"""
assert (
"lv_obj_t *list_child_VAR_ = lvgl::lv_list_get_row_for_remove(test_list, list_index_VAR_);"
in main_cpp
)
assert "ESP_LOGV" not in main_cpp
def test_clear_fires_on_remove_for_every_entry_then_cleans(main_cpp: str) -> None:
assert (
"for (int list_index = (int) (lv_obj_get_child_count(test_list)) - 1; "
"list_index >= 0; list_index--) {\n"
" triggerint_id_2->trigger(list_index);\n"
" }\n"
" lv_obj_clean(test_list);"
) in main_cpp
@@ -0,0 +1,47 @@
"""Regression test: on_add:/on_remove: containing an lvgl action must not deadlock.
ListType.to_code() used to build the on_add/on_remove automations directly, during
widget creation. Every lvgl action's to_code awaits wait_for_widgets(), which only
resolves once *all* widgets - including the list itself - have finished being
created. Building an automation containing an lvgl action from inside that same
widget-creation walk therefore could never complete: codegen deadlocked with
"Circular dependency detected!". Fixed by deferring the actual build_automation()
call to finish_list_triggers(), run after set_widgets_completed(True) - and,
critically, before generate_triggers(), which is what processes other widgets'
on_click etc. automations that might reference this list (e.g. via lvgl.list.add),
and which therefore need the list's own on_add/on_remove triggers to already exist.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
from esphome.core import CORE
@pytest.fixture(scope="module")
def main_cpp(request: pytest.FixtureRequest) -> str:
config_path = (
Path(request.fspath).parent / "config" / "list_on_add_lvgl_action_test.yaml"
)
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
return CORE.cpp_main_section
finally:
CORE.config_path = original_path
CORE.reset()
def test_on_add_with_lvgl_action_does_not_deadlock(main_cpp: str) -> None:
assert 'lv_label_set_text(later_label, "changed");' in main_cpp
def test_on_remove_with_lvgl_action_does_not_deadlock(main_cpp: str) -> None:
assert 'lv_label_set_text(later_label, "removed");' in main_cpp
@@ -0,0 +1,84 @@
"""Regression test for lvgl.list.add called from outside the lvgl: block.
lv_list.py's list_add_to_code() must call _register_lv_uses() and
_register_dynamic_widget_style_uses() before its first await (get_widgets(),
which can block until the target list is defined) -- for an action referenced
outside the lvgl: block, that wait can outlast lvgl's own to_code, which reads
get_lv_uses()/get_styles_used() and flushes everything they drive (USE_LVGL_*
defines, plus add_lv_use(image)/screen-transparency/A8-draw-support triggered
by style properties) just once, near the end of its run. Every existing
list_test.yaml call site lives inside lvgl: widgets:, so neither ordering
requirement had any coverage.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import pytest
from esphome.__main__ import generate_cpp_contents
from esphome.config import read_config
from esphome.core import CORE
@dataclass
class GeneratedOutput:
main_cpp: str
define_names: set[str]
lv_define_names: set[str]
@pytest.fixture(scope="module")
def generated(request: pytest.FixtureRequest) -> GeneratedOutput:
config_path = (
Path(request.fspath).parent / "config" / "list_outside_block_test.yaml"
)
original_path = CORE.config_path
try:
CORE.config_path = config_path
CORE.config = read_config({})
generate_cpp_contents(CORE.config)
# Copy out before CORE.reset() below clears these out from under us.
from esphome.components.lvgl import defines as df
return GeneratedOutput(
main_cpp=CORE.cpp_global_section + CORE.cpp_main_section,
define_names={d.name for d in CORE.defines},
lv_define_names=set(df.get_defines()),
)
finally:
CORE.config_path = original_path
CORE.reset()
def test_dynamic_widget_creates_correctly(generated: GeneratedOutput) -> None:
assert (
"lv_obj_t *dyn_switch_VAR_ = lv_switch_create(test_list);" in generated.main_cpp
)
def test_dynamic_widget_type_use_define_is_registered(
generated: GeneratedOutput,
) -> None:
"""The switch type is only ever referenced via the on_boot lvgl.list.add call
(never declared as a static widget), so USE_LVGL_SWITCH can only be present
if _register_lv_uses() ran in time for lvgl's own to_code to flush it.
"""
assert "USE_LVGL_SWITCH" in generated.define_names
assert "USE_LVGL_LIST" in generated.define_names
def test_dynamic_widget_style_use_defines_are_registered(
generated: GeneratedOutput,
) -> None:
"""bg_image_src/transform_rotation/drop_shadow_color are only ever set on
the dynamically-added switch (never on a static widget), so
USE_LVGL_IMAGE/LV_COLOR_SCREEN_TRANSP/LV_DRAW_SW_SUPPORT_A8 can only be
present if _register_dynamic_widget_style_uses() ran in time for lvgl's own
to_code to flush them.
"""
assert "USE_LVGL_IMAGE" in generated.define_names
assert "LV_COLOR_SCREEN_TRANSP" in generated.lv_define_names
assert "LV_DRAW_SW_SUPPORT_A8" in generated.lv_define_names
@@ -10,7 +10,13 @@ from esphome import config_validation as cv
# via ch422g) can be validated by the mipi_rgb CONFIG_SCHEMA in this test.
import esphome.components.ch422g # noqa: F401
from esphome.components.display import get_display_metadata
from esphome.components.esp32 import KEY_BOARD, VARIANT_ESP32S3
from esphome.components.esp32 import (
KEY_BOARD,
VARIANT_ESP32C3,
VARIANT_ESP32P4,
VARIANT_ESP32S3,
VARIANT_ESP32S31,
)
import esphome.components.pca9554 # noqa: F401
import esphome.components.xl9535 # noqa: F401
from esphome.const import (
@@ -135,3 +141,63 @@ def test_metadata_records_rotation(
config = CONFIG_SCHEMA({**base, "id": "unrotated"})
assert get_display_metadata(config["id"]).rotation == 0
@pytest.mark.parametrize(
("variant", "board", "model"),
[
# ESP32-8048S070 is a real Sunton board wired for ESP32-S3 (e.g. its
# default de_pin is GPIO41, which doesn't exist on S31), so it is
# only meaningful as a config on that variant.
(VARIANT_ESP32S3, "esp32-s3-devkitc-1", "ESP32-8048S070"),
# P4 and S31 use the pin-agnostic CUSTOM model so this only checks
# that the chip itself is accepted, independent of board wiring.
(VARIANT_ESP32P4, "esp32-p4-evboard", "CUSTOM"),
# No dedicated board is registered for ESP32-S31 yet; an unknown board
# name simply skips per-board pin validation.
(VARIANT_ESP32S31, "esp32-s31-devkitc", "CUSTOM"),
],
)
def test_configuration_succeeds_on_supported_variants(
variant: str, board: str, model: str, set_core_config: SetCoreConfigCallable
) -> None:
"""mipi_rgb requires a chip with an RGB LCD peripheral: S3, P4 or S31."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: board, KEY_VARIANT: variant},
)
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA
config = {"model": model, "data_pins": DATA_PINS, "pclk_pin": 21}
if model == "CUSTOM":
config[CONF_INIT_SEQUENCE] = [[0xA0, 0x01]]
config[CONF_DIMENSIONS] = {CONF_WIDTH: 480, CONF_HEIGHT: 480}
CONFIG_SCHEMA(config)
def test_only_on_variant_rejects_unsupported_variant(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A variant without the RGB LCD peripheral (e.g. ESP32-C3) is rejected.
Exercises the exact ``only_on_variant`` call used by ``mipi_rgb.display``
directly, since building a full model config with GPIO numbers that are
also valid on an unsupported variant like ESP32-C3 is unrelated to what
this checks.
"""
from esphome.components.esp32 import only_on_variant
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_VARIANT: VARIANT_ESP32C3},
)
validator = only_on_variant(
supported=[VARIANT_ESP32S3, VARIANT_ESP32P4, VARIANT_ESP32S31]
)
with pytest.raises(
cv.Invalid,
match=r"This feature is only available on ESP32S3, ESP32P4, ESP32S31",
):
validator({})
@@ -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,74 @@
"""Config validation for the byte offset on holding-register write entities.
A 16-bit register write cannot target half a register, so an odd offset (or byte_offset) is
rejected for holding-register switches and outputs; even offsets and coil offsets pass.
"""
import pytest
from voluptuous import Invalid, MultipleInvalid
from esphome.components.modbus_controller.output import (
CONFIG_SCHEMA as OUTPUT_CONFIG_SCHEMA,
)
from esphome.components.modbus_controller.switch import (
CONFIG_SCHEMA as SWITCH_CONFIG_SCHEMA,
)
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_NAME, CONF_OFFSET
def _switch_config(register_type: str, offset: int) -> dict:
return {
CONF_NAME: "test switch",
CONF_ADDRESS: 0x10,
"register_type": register_type,
CONF_OFFSET: offset,
}
def _output_config(register_type: str, offset: int) -> dict:
return {
CONF_ID: "test_output",
CONF_ADDRESS: 0x10,
"register_type": register_type,
CONF_OFFSET: offset,
}
def test_odd_offset_on_holding_switch_rejected() -> None:
with pytest.raises((Invalid, MultipleInvalid), match="odd"):
SWITCH_CONFIG_SCHEMA(_switch_config("holding", 3))
def test_even_offset_on_holding_switch_accepted() -> None:
config = SWITCH_CONFIG_SCHEMA(_switch_config("holding", 2))
assert config[CONF_OFFSET] == 2
def test_odd_offset_on_coil_switch_accepted() -> None:
"""A coil offset is a coil count, so odd values are fine."""
config = SWITCH_CONFIG_SCHEMA(_switch_config("coil", 3))
assert config[CONF_OFFSET] == 3
def test_odd_byte_offset_on_holding_switch_rejected() -> None:
"""byte_offset is the alias the validator must also catch."""
config = _switch_config("holding", 0)
del config[CONF_OFFSET]
config["byte_offset"] = 3
with pytest.raises((Invalid, MultipleInvalid), match="byte_offset"):
SWITCH_CONFIG_SCHEMA(config)
def test_odd_offset_on_holding_output_rejected() -> None:
with pytest.raises((Invalid, MultipleInvalid), match="odd"):
OUTPUT_CONFIG_SCHEMA(_output_config("holding", 3))
def test_even_offset_on_holding_output_accepted() -> None:
config = OUTPUT_CONFIG_SCHEMA(_output_config("holding", 2))
assert config[CONF_OFFSET] == 2
def test_odd_offset_on_coil_output_accepted() -> None:
config = OUTPUT_CONFIG_SCHEMA(_output_config("coil", 3))
assert config[CONF_OFFSET] == 3
@@ -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()

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