Merge remote-tracking branch 'origin/dev' into store-yaml-firmware

# Conflicts:
#	esphome/components/api/api.proto
#	esphome/components/api/api_pb2.h
#	esphome/components/api/api_pb2_service.cpp
#	esphome/yaml_util.py
#	tests/integration/conftest.py
#	tests/unit_tests/test_yaml_util.py
This commit is contained in:
J. Nick Koston
2026-09-14 11:07:43 -05:00
2889 changed files with 129953 additions and 28736 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
@@ -52,7 +52,7 @@ def _prime_cache(yaml_path: Path) -> None:
Mirrors ``esphome compile``: ``read_config`` populates ``CORE.config``,
then ``update_storage_json`` writes both the StorageJSON sidecar and
the ``.validated.yaml`` compiled-config cache.
the ``.validated.json`` compiled-config cache.
"""
CORE.config_path = yaml_path
config = read_config({}, skip_external_update=True)
@@ -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,12 +32,23 @@ 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(uint32_t baudrate, bool flow_control, uint8_t parity, uint32_t stop_bits, uint32_t data_size) {}
void write_from_client(const uint8_t *data, size_t len) {}
void set_modem_pins(uint32_t line_states) {}
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) {}
SerialProxyResult set_mode_from_client(api::APIConnection *api_connection, api::enums::SerialProxyMode mode) {
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
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,32 @@
"""Tests for the ADC sensor component."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
def test_adc_temperature_pin_is_deprecated(
generate_main: Callable[[str | Path], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""`pin: TEMPERATURE` still works, but warns and points at internal_temperature."""
main_cpp = generate_main("tests/component_tests/adc/test_adc_sensor.yaml")
assert "adc_temperature->set_is_temperature();" in main_cpp
assert "`pin: TEMPERATURE` is deprecated" in caplog.text
assert "internal_temperature" in caplog.text
assert "2027.2.0" in caplog.text
def test_adc_regular_pin_is_not_deprecated(
generate_main: Callable[[str | Path], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""A normal ADC pin does not emit the temperature deprecation warning."""
main_cpp = generate_main("tests/component_tests/adc/test_adc_sensor.yaml")
assert "adc_voltage->set_is_temperature();" not in main_cpp
assert caplog.text.count("`pin: TEMPERATURE` is deprecated") == 1
@@ -0,0 +1,16 @@
esphome:
name: test
rp2:
board: rpipicow
sensor:
- platform: adc
pin: TEMPERATURE
name: Deprecated ADC Temperature
id: adc_temperature
- platform: adc
pin: 26
name: ADC Voltage
id: adc_voltage
@@ -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
@@ -0,0 +1,45 @@
esphome:
name: bk-trigger-codegen
on_boot:
then:
- bk72xx_ble_tracker.start_scan:
continuous: true
# Bare form: restores the configured scan_parameters mode — no
# set_continuous emitted (asserted in the codegen test).
- bk72xx_ble_tracker.start_scan:
- bk72xx_ble_tracker.stop_scan
bk72xx:
board: cb2s
bk72xx_ble_tracker:
scan_parameters:
continuous: false
active: false
on_ble_advertise:
- mac_address:
- AC:37:43:77:5F:4C
- 11:22:33:44:55:66
then:
- lambda: 'char addr[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; ESP_LOGD("t", "%s", x.address_str_to(addr));'
on_ble_service_data_advertise:
- service_uuid: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
mac_address: AC:37:43:77:5F:4C
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
- service_uuid: ABCDABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
on_ble_manufacturer_data_advertise:
- manufacturer_id: ABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
- manufacturer_id: ABCDABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
- manufacturer_id: ABCDABCD-ABCD-ABCD-ABCD-ABCDABCDABCD
then:
- lambda: 'ESP_LOGD("t", "%zu", x.size());'
on_scan_end:
- then:
- lambda: 'ESP_LOGD("t", "end");'
@@ -0,0 +1,62 @@
"""Codegen tests for the tracker automations.
The shared trigger classes (ble_device_base/automation.h) are compiled by every
esp32 BLE compile test via AUTO_LOAD, but the BK-specific side — automation.h's
action templates and restart_scan_duration() — compiles on no CI board (the
bk72xx base board generic-bk7252 is BLE 4.2 and cannot build the tracker), and
validate fixtures never run to_code. The generated main is therefore the only
automated check on the setter spellings and the listener accounting."""
from collections.abc import Callable
from pathlib import Path
import re
from esphome.components import ble_device_base
from tests.component_tests.helpers import get_define_value
def test_trigger_codegen(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("test_automations.yaml"))
# on_ble_advertise: multi-mac filter (two addresses in one initializer list)
assert "set_addresses({0xAC3743775F4CULL, 0x112233445566ULL})" in main_cpp
# 128-bit service uuid goes out reversed (BLE wire order); single-mac filter
assert (
"set_service_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB,"
"0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp
)
assert "set_address(0xAC3743775F4CULL)" in main_cpp
# 32-bit middle branch of the width dispatch
assert "set_service_uuid32(0xABCDABCDULL)" in main_cpp
# All three manufacturer widths: getattr() builds these names as strings,
# so a misspelling only ever fails here.
assert "set_manufacturer_uuid16(0xABCDULL)" in main_cpp
assert "set_manufacturer_uuid32(0xABCDABCDULL)" in main_cpp
assert (
"set_manufacturer_uuid128((uint8_t*)(const uint8_t[16]){0xCD,0xAB,0xCD,0xAB,"
"0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB,0xCD,0xAB})" in main_cpp
)
# scan-control actions: templatable continuous lambda + parented actions.
# Exactly one set_continuous: the bare start_scan emits none, pinning the
# restore-configured-mode divergence from esp32 against a future default=.
assert main_cpp.count("->set_continuous(") == 1
assert "startscanaction_id->set_continuous(" in main_cpp
assert "stopscanaction_id->set_parent(" in main_cpp
# scan_parameters continuous: false reaches the YAML-mode setter, not the
# runtime override.
assert "->set_configured_continuous(false)" in main_cpp
# active: false (non-default) flows through to the setter.
assert "->set_scan_active(false)" in main_cpp
# Constructor call, not just the declaration: the parent argument is what
# registers the trigger as a listener.
assert re.search(
r"new\(\w+\) ble_device_base::BLEEndOfScanTrigger\(\w+\)", main_cpp
)
# Seven triggers register as listeners; an undercount silently drops the
# last trigger at runtime (StaticVector::push_back past capacity), so the
# define is the assertion that matters most.
assert get_define_value(ble_device_base.LISTENER_COUNT_DEFINE) == "7"
@@ -0,0 +1,86 @@
"""Tests for ble_client config validation."""
import pytest
from esphome import config_validation as cv
from esphome.components.ble_client import (
CONF_DESCRIPTOR_UUID,
CONF_ON_NOTIFY,
notify_from_on_notify,
validate_descriptor_not_notify,
)
from esphome.components.ble_client.sensor import CONFIG_SCHEMA as SENSOR_SCHEMA
from esphome.components.ble_client.text_sensor import (
CONFIG_SCHEMA as TEXT_SENSOR_SCHEMA,
)
from esphome.const import (
CONF_CHARACTERISTIC_UUID,
CONF_NAME,
CONF_NOTIFY,
CONF_SERVICE_UUID,
CONF_TYPE,
)
from esphome.types import ConfigType
DESCRIPTOR_CONFIG: ConfigType = {
CONF_NAME: "test",
CONF_SERVICE_UUID: "6E400001-B5A3-F393-E0A9-E50E24DCCA9E",
CONF_CHARACTERISTIC_UUID: "6E400003-B5A3-F393-E0A9-E50E24DCCA9E",
CONF_DESCRIPTOR_UUID: "2902",
}
def test_notify_with_descriptor_uuid_rejected() -> None:
config: ConfigType = {CONF_NOTIFY: True, CONF_DESCRIPTOR_UUID: "2902"}
with pytest.raises(cv.Invalid, match="cannot send notifications"):
validate_descriptor_not_notify(config)
def test_on_notify_with_descriptor_uuid_rejected() -> None:
config: ConfigType = {
CONF_NOTIFY: False,
CONF_ON_NOTIFY: [{}],
CONF_DESCRIPTOR_UUID: "2902",
}
with pytest.raises(cv.Invalid, match="cannot send notifications"):
validate_descriptor_not_notify(config)
def test_descriptor_uuid_without_notify_allowed() -> None:
config: ConfigType = {CONF_NOTIFY: False, CONF_DESCRIPTOR_UUID: "2902"}
assert validate_descriptor_not_notify(config) is config
def test_notify_without_descriptor_uuid_allowed() -> None:
config: ConfigType = {CONF_NOTIFY: True}
assert validate_descriptor_not_notify(config) is config
def test_sensor_schema_rejects_notify_with_descriptor() -> None:
config = {**DESCRIPTOR_CONFIG, CONF_TYPE: "characteristic", CONF_NOTIFY: True}
with pytest.raises(cv.Invalid, match="cannot send notifications"):
SENSOR_SCHEMA(config)
def test_text_sensor_schema_rejects_notify_with_descriptor() -> None:
config = {**DESCRIPTOR_CONFIG, CONF_NOTIFY: True}
with pytest.raises(cv.Invalid, match="cannot send notifications"):
TEXT_SENSOR_SCHEMA(config)
def test_sensor_schema_allows_descriptor_polling() -> None:
assert SENSOR_SCHEMA({**DESCRIPTOR_CONFIG, CONF_TYPE: "characteristic"})
def test_text_sensor_schema_allows_descriptor_polling() -> None:
assert TEXT_SENSOR_SCHEMA(dict(DESCRIPTOR_CONFIG))
def test_on_notify_implies_notify() -> None:
config: ConfigType = {CONF_NOTIFY: False, CONF_ON_NOTIFY: [{}]}
assert notify_from_on_notify(config)[CONF_NOTIFY] is True
def test_notify_unchanged_without_on_notify() -> None:
config: ConfigType = {CONF_NOTIFY: False}
assert notify_from_on_notify(config)[CONF_NOTIFY] is False
@@ -0,0 +1,7 @@
esphome:
name: slotcount-controller
bk72xx:
board: cb2s
bk72xx_ble:
@@ -0,0 +1,7 @@
esphome:
name: slotcount-tracker
bk72xx:
board: cb2s
bk72xx_ble_tracker:
@@ -0,0 +1,16 @@
esphome:
name: slotcount-esp32-proxy
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: MySSID
password: password1
api:
bluetooth_proxy:
active: true
@@ -0,0 +1,9 @@
esphome:
name: slotcount-esp32-tracker
esp32:
board: esp32dev
framework:
type: esp-idf
esp32_ble_tracker:
@@ -0,0 +1,7 @@
esphome:
name: slotcount-ln882h-tracker
ln882x:
board: generic-ln882h
ln882h_ble_tracker:
@@ -0,0 +1,7 @@
esphome:
name: slotcount-rp2-controller
rp2:
board: rpipicow
rp2040_ble:
@@ -0,0 +1,7 @@
esphome:
name: slotcount-rp2-tracker
rp2:
board: rpipicow
rp2_ble_tracker:
@@ -0,0 +1,231 @@
"""Tests for the BLE hub provider registry and the missing-hub diagnostics."""
from collections.abc import Callable, Generator
from importlib import import_module
from pathlib import Path
import pytest
import esphome.codegen as cg
from esphome.components import ble_device_base
import esphome.config_validation as cv
from esphome.const import KEY_TARGET_PLATFORM, Platform
from esphome.core import CORE, ID, KEY_CORE
from esphome.cpp_generator import MockObjClass
COMPONENTS_DIR = Path(ble_device_base.__file__).parent.parent
@pytest.fixture
def hub_registry() -> Generator[set[str]]:
"""Save/restore _HUB_PROVIDERS — a module global with no reset hook.
CORE state needs no bookkeeping here: conftest's autouse reset_core
fixture reassigns it after every test.
"""
saved = set(ble_device_base._HUB_PROVIDERS)
yield ble_device_base._HUB_PROVIDERS
ble_device_base._HUB_PROVIDERS.clear()
ble_device_base._HUB_PROVIDERS.update(saved)
def _generated_id() -> ID:
"""An ID as cv.GenerateID leaves it before the ID-assignment pass."""
return ID(None, is_declaration=False, type="ble_device_base::BLEHub")
def _set_platform(platform: str | None) -> None:
core_data = CORE.data.setdefault(KEY_CORE, {})
if platform is None:
core_data.pop(KEY_TARGET_PLATFORM, None)
else:
core_data[KEY_TARGET_PLATFORM] = platform
# The missing-hub diagnostics: one test per path so a regression in one
# scenario cannot mask the others. The hub binding must fail with a
# tracker-naming message, not use_id's C++-class error, regardless of
# config-step ordering internals.
def test_empty_registry_names_every_in_tree_tracker(hub_registry: set[str]) -> None:
# The common failure: a fresh CLI process where the tracker was simply
# forgotten, so no tracker module was ever imported and the registry is
# empty. The error must still name the in-tree trackers.
hub_registry.clear()
CORE.loaded_integrations.clear()
_set_platform(None)
with pytest.raises(
cv.Invalid,
match="add one of: bk72xx_ble_tracker, esp32_ble_tracker, ln882h_ble_tracker, rp2_ble_tracker",
):
ble_device_base._require_hub(_generated_id())
def test_platform_filters_the_suggested_trackers(hub_registry: set[str]) -> None:
hub_registry.clear()
CORE.loaded_integrations.clear()
_set_platform("esp32")
with pytest.raises(cv.Invalid, match="add one of: esp32_ble_tracker$"):
ble_device_base._require_hub(_generated_id())
def test_ble_less_platform_is_not_misdirected(hub_registry: set[str]) -> None:
# A known platform with no in-tree hub must not be pointed at other
# platforms' trackers; out-of-tree BLE hubs are not supported.
hub_registry.clear()
CORE.loaded_integrations.clear()
_set_platform("esp8266")
with pytest.raises(
cv.Invalid,
match="No BLE tracker exists for esp8266; BLE components are not supported",
):
ble_device_base._require_hub(_generated_id())
def test_explicit_id_bypasses_the_registry(hub_registry: set[str]) -> None:
# Explicit ble_hub_id: is the multi-hub disambiguation case; the ID pass
# owns that diagnosis and its error names the missing id.
hub_registry.clear()
CORE.loaded_integrations.clear()
explicit = ID("my_hub", is_declaration=False, type="ble_device_base::BLEHub")
assert ble_device_base._require_hub(explicit) is explicit
def test_registered_and_loaded_provider_passes(hub_registry: set[str]) -> None:
hub_registry.add("esp32_ble_tracker")
CORE.loaded_integrations.add("esp32_ble_tracker")
generated = _generated_id()
assert ble_device_base._require_hub(generated) is generated
def _module_name(path: Path) -> str:
"""Dotted module name for a file under esphome/components."""
rel = path.relative_to(COMPONENTS_DIR.parent)
parts = rel.with_suffix("").parts
if parts[-1] == "__init__":
parts = parts[:-1]
return "esphome." + ".".join(parts)
def _hub_component_modules() -> list[str]:
"""Components whose codegen class inherits ble_device_base.BLEHub.
The source-text pass only selects import candidates (importing all ~900
component packages is too slow); membership is decided by the class
hierarchy via MockObjClass.inherits_from on every module whose source
matched — nested declaring modules included — so a comment mentioning
BLEHub in a consumer cannot produce a false positive.
"""
hub_modules = []
for pkg in sorted(COMPONENTS_DIR.iterdir()):
if pkg.name == "ble_device_base" or not (pkg / "__init__.py").is_file():
continue
matched = [
path
for path in pkg.rglob("*.py")
if "BLEHub" in path.read_text(encoding="utf-8")
]
if not matched:
continue
for path in matched:
mod = import_module(_module_name(path))
if any(
isinstance(attr, MockObjClass)
and attr is not ble_device_base.BLEHub
and attr.inherits_from(ble_device_base.BLEHub)
for attr in vars(mod).values()
):
hub_modules.append(pkg.name)
break
return hub_modules
def test_every_in_tree_hub_registers_as_provider() -> None:
"""A BLEHub subclass that forgets register_hub_provider() makes _require_hub
reject valid configs for that platform — fail CI instead of the user."""
hub_modules = _hub_component_modules()
assert hub_modules, "hub discovery found no BLEHub subclasses — scan stale?"
for name in hub_modules:
assert name in ble_device_base._HUB_PROVIDERS, (
f"{name} subclasses ble_device_base.BLEHub but never calls "
"register_hub_provider(); a valid config using it would be rejected"
)
# The per-platform error table must know every in-tree hub, keyed by real
# platform names — a typo'd key would silently route that platform into
# the no-in-tree-tracker branch.
assert set(ble_device_base._IN_TREE_HUB_PROVIDERS.values()) == set(hub_modules)
platforms = {platform.value for platform in Platform}
assert set(ble_device_base._IN_TREE_HUB_PROVIDERS) <= platforms
def test_ble_device_schema_declares_the_binding_key(hub_registry: set[str]) -> None:
"""Extending BLE_DEVICE_SCHEMA keeps ble_hub_id a declared key on a strict
schema, for both the generated and the explicit form, and the missing-hub
rejection surfaces through the schema itself."""
schema = cv.Schema({}).extend(ble_device_base.BLE_DEVICE_SCHEMA)
hub_registry.clear()
CORE.loaded_integrations.discard("esp32_ble_tracker")
with pytest.raises(cv.Invalid, match="No BLE tracker configured"):
schema({})
hub_registry.add("esp32_ble_tracker")
CORE.loaded_integrations.add("esp32_ble_tracker")
generated = schema({})[ble_device_base.CONF_BLE_HUB_ID]
assert isinstance(generated, ID) and generated.id is None
explicit = schema({"ble_hub_id": "my_hub"})[ble_device_base.CONF_BLE_HUB_ID]
assert explicit.id == "my_hub"
def test_rename_legacy_hub_id_migrates_the_old_key() -> None:
validator = ble_device_base.rename_legacy_hub_id("my_sensor")
migrated = validator({"esp32_ble_id": "tracker1"})
assert migrated == {ble_device_base.CONF_BLE_HUB_ID: "tracker1"}
untouched = validator({"name": "x"})
assert untouched == {"name": "x"}
def test_add_service_uuid_dispatches_by_width(monkeypatch: pytest.MonkeyPatch) -> None:
emitted: list[str] = []
monkeypatch.setattr(
"esphome.components.ble_device_base.cg.add", lambda e: emitted.append(str(e))
)
var = cg.MockObj("trig")
ble_device_base.add_service_uuid(var, "11AA")
ble_device_base.add_service_uuid(var, "11223344")
ble_device_base.add_service_uuid(var, "11223344-5566-7788-99aa-bbccddeeff00")
assert "set_service_uuid16" in emitted[0]
assert "set_service_uuid32" in emitted[1]
assert "set_service_uuid128" in emitted[2]
# BLE wire order: the 128-bit array must be byte-reversed — as_hex_array
# in its place would still emit the right setter name and silently never
# match on-air.
assert "0x00,0xff,0xee,0xdd" in emitted[2]
with pytest.raises(ValueError, match="Unsupported UUID format"):
ble_device_base.add_service_uuid(var, "123")
@pytest.mark.parametrize(
("config_name", "define"),
[
("esp32_tracker_only.yaml", "USE_ESP32_BLE_TRACKER"),
("rp2_tracker.yaml", "USE_RP2_BLE_TRACKER"),
("bk72xx_tracker.yaml", "USE_BK72XX_BLE_TRACKER"),
("ln882h_tracker.yaml", "USE_LN882H_BLE_TRACKER"),
],
)
def test_every_tracker_emits_its_alias_define(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_name: str,
define: str,
) -> None:
"""Each tracker's codegen must emit its USE_*_BLE_TRACKER define - the
ble_hub_impl.h alias ladder selects on it. Checked through real codegen
(the other two legs of the invariant, the ladder arm and the defines.h
mirror, are compile-enforced: a missing arm fails any build containing a
BLEHub consumer - today bluetooth_proxy, which CI compiles or tidy-parses
on every tracker platform - and clang-tidy compiles each arm's
static_assert)."""
generate_main(component_config_path(config_name))
assert define in {d.name for d in CORE.defines}, f"{define} not emitted by codegen"
@@ -0,0 +1,169 @@
"""Tests for the shared BLE tracker scan parameter validation."""
from __future__ import annotations
import pytest
from esphome import config_validation as cv
from esphome.components.bk72xx_ble_tracker import (
SCAN_PARAMETERS_SCHEMA as BK72XX_SCHEMA,
)
from esphome.components.ble_device_base import to_ble_units
from esphome.components.esp32_ble_tracker import SCAN_PARAMETERS_SCHEMA as ESP32_SCHEMA
from esphome.components.ln882h_ble_tracker import (
SCAN_PARAMETERS_SCHEMA as LN882H_SCHEMA,
)
from esphome.components.rp2_ble_tracker import SCAN_PARAMETERS_SCHEMA as RP2_SCHEMA
def _validate(**kwargs: str | bool) -> dict:
"""Run a scan_parameters config through the bk72xx tracker's real schema."""
return BK72XX_SCHEMA(kwargs)
# --- to_ble_units ---
@pytest.mark.parametrize(
("value", "expected"),
[
("2500us", 4), # controller minimum, 2.5 ms
("30ms", 48),
("100ms", 160),
("10240ms", 16384), # controller maximum, 0x4000
],
)
def test_to_ble_units_converts_to_controller_units(value: str, expected: int) -> None:
"""A time is converted to whole 0.625 ms units."""
assert to_ble_units(cv.positive_time_period(value)) == expected
def test_to_ble_units_truncates() -> None:
"""Sub-unit remainders are dropped, which is what makes collapse possible."""
assert to_ble_units(cv.positive_time_period("3000us")) == 4
assert to_ble_units(cv.positive_time_period("2500us")) == 4
# --- the real per-chip schemas ---
def test_bk72xx_defaults_are_valid() -> None:
"""bk72xx pins the BK reference rate — 100 ms interval, shared 30 ms window —
and exposes active (default on, like every active-capable tracker)."""
config = _validate()
assert to_ble_units(config["interval"]) == 160
assert to_ble_units(config["window"]) == 48
assert config["active"] is True
def test_esp32_defaults_are_valid() -> None:
"""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
assert config["active"] is True
def test_rp2_defaults_are_valid() -> None:
"""rp2 pins 100 ms interval / 30 ms window — a 30 % duty cycle leaving the
shared CYW43 radio mostly free for WiFi — and exposes active (default on)."""
config = RP2_SCHEMA({})
assert to_ble_units(config["interval"]) == 160
assert to_ble_units(config["window"]) == 48
assert config["active"] is True
def test_ln882h_defaults_are_valid() -> None:
"""ln882h pins the LN SDK reference rate — 100 ms interval / 50 ms window
(50 % duty) — and exposes active (default on)."""
config = LN882H_SCHEMA({})
assert to_ble_units(config["interval"]) == 160
assert to_ble_units(config["window"]) == 80
assert config["active"] is True
def test_esp32_active_can_disable() -> None:
config = ESP32_SCHEMA({"active": False})
assert config["active"] is False
def test_bk72xx_active_can_disable() -> None:
config = _validate(active=False)
assert config["active"] is False
# --- accepted configurations ---
def test_minimum_separation_accepted() -> None:
"""Values one unit apart at the 2.5 ms floor are honest, not collapsed."""
config = _validate(interval="5000us", window="2500us")
assert to_ble_units(config["interval"]) == 8
assert to_ble_units(config["window"]) == 4
def test_maximum_interval_accepted() -> None:
"""The documented 10240 ms ceiling is inclusive, and maps to 0x4000.
Pins the ceiling from the accept side, mirroring the 2.5 ms floor above: the
reject cases alone would let the bound silently become exclusive.
"""
config = _validate(interval="10240ms", window="30ms")
assert to_ble_units(config["interval"]) == 16384
def test_maximum_window_accepted() -> None:
"""The ceiling applies to the window too, and is likewise inclusive."""
config = _validate(interval="10240ms", window="10240ms")
assert to_ble_units(config["window"]) == 16384
def test_window_equal_to_interval_accepted() -> None:
"""A deliberate 100 % duty cycle is allowed; only an accidental one is not."""
config = _validate(interval="100ms", window="100ms")
assert to_ble_units(config["interval"]) == to_ble_units(config["window"])
def test_duration_equal_to_three_intervals_accepted() -> None:
"""The three-interval floor is inclusive, mirroring the ceilings above."""
_validate(duration="3s", interval="1s", window="500ms")
# --- rejected configurations ---
def test_window_larger_than_interval_rejected() -> None:
with pytest.raises(cv.Invalid, match="needs to be smaller than scan interval"):
_validate(interval="30ms", window="100ms")
@pytest.mark.parametrize(
("interval", "window", "offender"),
[
("2ms", "1ms", "interval"), # below the 2.5 ms controller floor
("20s", "1s", "interval"), # above the 10240 ms controller ceiling
("100ms", "1ms", "window"), # window below the floor
],
)
def test_out_of_range_rejected(interval: str, window: str, offender: str) -> None:
"""Values the controller cannot represent are rejected, not silently wrapped."""
with pytest.raises(
cv.Invalid, match=f"Scan {offender} .* must be between 2.5 ms and 10240 ms"
):
_validate(interval=interval, window=window)
def test_unit_collapse_rejected() -> None:
"""3000us/2500us both floor to 4 units — a hidden 100 % duty cycle."""
with pytest.raises(cv.Invalid, match="both truncate to 4 x 0.625 ms"):
_validate(interval="3000us", window="2500us")
def test_duration_shorter_than_three_intervals_rejected() -> None:
with pytest.raises(cv.Invalid, match="must cover at least three scan intervals"):
_validate(duration="1s", interval="500ms", window="100ms")
@@ -0,0 +1,130 @@
"""Tests for the shared slot_counter codegen factory.
The factory is exercised end to end through the real controllers: a tracker
config must emit the platform's scan listener count define, and a
controller-only config must emit nothing so the guarded StaticVector storage
compiles out.
"""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.core import CORE
from ..helpers import get_define_value
@pytest.mark.parametrize(
("config", "define"),
[
("bk72xx_tracker.yaml", "BK72XX_BLE_SCAN_LISTENER_COUNT"),
("rp2_tracker.yaml", "RP2040_BLE_SCAN_LISTENER_COUNT"),
],
)
def test_tracker_requests_one_slot(
config: str,
define: str,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The tracker's to_code requests a slot; the FINAL job emits the count.
The neutral listener count must stay absent from the same build: no BLE
consumer registered through register_ble_device().
"""
generate_main(component_config_path(config))
assert get_define_value(define) == "1"
assert get_define_value("ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT") is None
@pytest.mark.parametrize(
("config", "define"),
[
("bk72xx_controller_only.yaml", "BK72XX_BLE_SCAN_LISTENER_COUNT"),
("rp2_controller_only.yaml", "RP2040_BLE_SCAN_LISTENER_COUNT"),
],
)
def test_controller_only_emits_no_count(
config: str,
define: str,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""No consumer, no define — the guarded listener storage compiles out."""
generate_main(component_config_path(config))
assert get_define_value(define) is None
def test_neutral_listener_count_emitted_when_requested() -> None:
"""Registering through register_ble_device() emits the neutral count.
No in-tree sensor registers through ble_device_base.register_ble_device()
yet (consumer migration is a follow-up), so the coroutine is driven with a
mock hub instead of a config; every tracker's #ifdef-guarded listener
storage keys on this define, and a broken emit path would compile the
storage out silently.
"""
import esphome.codegen as cg
from esphome.components import ble_device_base
from esphome.core import ID
hub_id = ID("hub", type=ble_device_base.BLEHub)
CORE.register_variable(hub_id, cg.MockObj("hub"))
CORE.add_job(
ble_device_base.register_ble_device,
cg.MockObj("listener"),
{ble_device_base.CONF_BLE_HUB_ID: hub_id},
)
CORE.flush_tasks()
assert get_define_value(ble_device_base.LISTENER_COUNT_DEFINE) == "1"
def test_esp32_tracker_handler_counts(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A bare tracker registers its four esp32_ble handlers and nothing else."""
generate_main(component_config_path("esp32_tracker_only.yaml"))
assert get_define_value("ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT") == "1"
assert get_define_value("ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT") == "1"
assert get_define_value("ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT") == "1"
assert get_define_value("ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT") == "1"
assert get_define_value("ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT") is None
# No advertisement listener or client is registered, so both storages compile out.
assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None
assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") is None
def test_esp32_bluetooth_proxy_requests_client_slots_only(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The proxy requests a client slot per connection (three by default with
active: true); advertisements and scanner state arrive through the hub
callbacks, so no listener slot exists."""
generate_main(component_config_path("esp32_bluetooth_proxy.yaml"))
assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT") is None
assert get_define_value("ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT") == "3"
# One neutral GATT backend slot per connection (the hub-model flip).
assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "3"
def test_counts_reset_between_compiles(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A second compile in the same process starts from zero.
The module level counters this change removes leaked across compiles in a
long lived host process (dashboard, device-builder), growing the handler
counts by one per compile and oversizing the StaticCallbackManager storage.
"""
generate_main(component_config_path("esp32_tracker_only.yaml"))
assert get_define_value("ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT") == "1"
CORE.reset()
generate_main(component_config_path("esp32_tracker_only.yaml"))
assert get_define_value("ESPHOME_ESP32_BLE_GAP_EVENT_HANDLER_COUNT") == "1"
@@ -0,0 +1,13 @@
"""The codegen hook external filtering components use to turn on the filter slot."""
from esphome.components import bluetooth_proxy
from esphome.core import CORE
def test_enable_advertisement_filter_emits_define() -> None:
"""External components call this rather than emitting the define."""
bluetooth_proxy.enable_advertisement_filter()
assert "USE_BLUETOOTH_PROXY_ADVERTISEMENT_FILTER" in {
define.name for define in CORE.defines
}
@@ -0,0 +1,19 @@
"""bluetooth_proxy mirrors esp32_ble.IDF_MAX_CONNECTIONS; pin them together.
The mirror doubles as the outer CONFIG_SCHEMA's connection_slots bound, and
the esp32 schema builder lazily imports esp32_ble and asserts the two values
agree, but that assert only fires while building the esp32 schema. This test
catches drift when the upstream constant changes without any esp32 config
being validated.
"""
from esphome.components import esp32_ble
from esphome.components.bluetooth_proxy import _IDF_MAX_CONNECTIONS
def test_mirror_matches_esp32_ble() -> None:
assert _IDF_MAX_CONNECTIONS == esp32_ble.IDF_MAX_CONNECTIONS, (
"bluetooth_proxy._IDF_MAX_CONNECTIONS is out of sync with "
"esp32_ble.IDF_MAX_CONNECTIONS; update the mirror in "
"esphome/components/bluetooth_proxy/__init__.py"
)
@@ -0,0 +1,74 @@
"""The outer CONFIG_SCHEMA re-declares the esp32 scalar keys so tooling can walk
them without importing the esp32 BLE stack; pin the two declarations together.
The outer schema carries no defaults (the per-platform schema applies them), so
drift cannot surface in validation output — a key renamed or removed in
_esp32_config_schema() but not here would silently vanish from the dashboard's
field extractor. This test is what catches that. The outer schema bounds
connection_slots with the loosest platform cap (_IDF_MAX_CONNECTIONS) so range
walkers see a real Range; per-platform schemas tighten it, and the cap itself
is pinned by test_idf_max_connections_mirror.
"""
import voluptuous as vol
from esphome import config_validation as cv
from esphome.components.bluetooth_proxy import CONFIG_SCHEMA, _esp32_config_schema
def _esp32_schema_keys() -> dict[str, object]:
# The builder names its platform explicitly, so no CORE state is needed
# (this also mirrors how the language-schema dumper calls it).
return _keys(_schema_of(_esp32_config_schema()))
# esp32-schema keys with no place in the outer schema: COMPONENT_SCHEMA
# plumbing (derived, so a future core key does not fail this component's test),
# generated IDs (not user-walkable options), and connections (must validate
# exactly once — see the comment above CONFIG_SCHEMA).
_NOT_MIRRORED = {str(key.schema) for key in cv.COMPONENT_SCHEMA.schema} | {
"connections"
}
def _schema_of(validator: cv.All) -> vol.Schema:
"""The vol.Schema stage of a cv.All chain, found by type rather than by
position so reordering the chain cannot silently break these tests."""
schemas = [v for v in validator.validators if isinstance(v, vol.Schema)]
assert len(schemas) == 1, f"expected exactly one vol.Schema stage, got {schemas}"
return schemas[0]
def _keys(schema: vol.Schema) -> dict[str, object]:
return {str(key.schema): key for key in schema.schema}
def test_outer_scalar_keys_exist_in_esp32_schema() -> None:
outer = _keys(_schema_of(CONFIG_SCHEMA))
esp32 = _esp32_schema_keys()
missing = set(outer) - set(esp32)
assert not missing, (
f"outer CONFIG_SCHEMA declares {sorted(missing)} which the esp32 schema "
"does not; update one of them in "
"esphome/components/bluetooth_proxy/__init__.py"
)
def test_esp32_scalars_all_walkable() -> None:
"""Every non-generated esp32 scalar option must appear in the outer schema
(connections is deliberately excluded — it must validate exactly once)."""
outer = _keys(_schema_of(CONFIG_SCHEMA))
esp32 = _esp32_schema_keys()
scalar = {
name
for name, key in esp32.items()
if isinstance(key, vol.Optional)
and not isinstance(key, cv.GenerateID)
and name not in _NOT_MIRRORED
}
missing = scalar - set(outer)
assert not missing, (
f"esp32 scalar options {sorted(missing)} are missing from the outer "
"CONFIG_SCHEMA and invisible to schema tooling; update "
"esphome/components/bluetooth_proxy/__init__.py"
)
@@ -0,0 +1,263 @@
"""The three platform-gate branches: BLE-less platforms are rejected with the
real reason, hub platforms reject GATT-only options by name, and the
advertisement-only arm applies its own defaults."""
from pathlib import Path
import re
import pytest
from esphome import config_validation as cv
from esphome.components import ble_device_base, bluetooth_connection, bluetooth_proxy
from esphome.config_helpers import frameworks_for_platforms
from esphome.const import (
CONF_ACTIVE,
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
PLATFORM_BK72XX,
PLATFORM_ESP32,
PLATFORM_LN882X,
PLATFORM_RP2,
PlatformFramework,
)
from esphome.core import CORE
from ..types import SetCoreConfigCallable
# Advertisement-only hub platforms; rp2 runs the full proxy and has its own
# tests below.
HUB_PLATFORM_FRAMEWORKS = [
PlatformFramework.BK72XX_ARDUINO,
PlatformFramework.LN882X_ARDUINO,
]
HUB_TRACKERS = {
PLATFORM_BK72XX: "bk72xx_ble_tracker",
PLATFORM_LN882X: "ln882h_ble_tracker",
PLATFORM_RP2: "rp2_ble_tracker",
}
def test_hub_platform_list_covers_every_hub_platform() -> None:
# A platform added to _HUB_PLATFORMS would otherwise get no gate coverage
# at all; GATT platforms have their own tests.
advertisement_only = set(bluetooth_proxy._HUB_PLATFORMS) - set(
bluetooth_connection.HUB_MAX_CONNECTIONS
)
assert {pf.value[0] for pf in HUB_PLATFORM_FRAMEWORKS} == advertisement_only
assert set(HUB_TRACKERS) == set(bluetooth_proxy._HUB_PLATFORMS)
def _set_platform(platform: str | None) -> None:
# For arms set_core_config cannot express (bare platform, no framework).
CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = platform
def _register_tracker(platform: str) -> None:
# The ble_hub_id guard needs a loaded tracker, normally registered as an
# import side effect of the tracker module.
tracker = HUB_TRACKERS[platform]
ble_device_base.register_hub_provider(tracker)
CORE.loaded_integrations.add(tracker)
def test_ble_less_platform_gets_the_real_reason(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP8266_ARDUINO)
with pytest.raises(cv.Invalid, match="not supported on esp8266"):
bluetooth_proxy.CONFIG_SCHEMA({})
def test_ble_less_platform_connection_keys_fall_through(
set_core_config: SetCoreConfigCallable,
) -> None:
# The key-level rejection must not fire here — it would imply an
# advertisement-only proxy exists on this platform.
set_core_config(PlatformFramework.ESP8266_ARDUINO)
with pytest.raises(cv.Invalid, match="not supported on esp8266"):
bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2})
def test_no_target_platform_keeps_the_key_gate_out_of_the_way() -> None:
# set_core_config cannot express "no platform"; script/build_codeowners.py
# sets exactly this shape, and the key gate returns early on it so the
# platform gate is what reports.
CORE.data[KEY_CORE] = {KEY_TARGET_FRAMEWORK: None, KEY_TARGET_PLATFORM: None}
with pytest.raises(cv.Invalid, match="not supported on None"):
bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2})
@pytest.mark.parametrize("platform_framework", HUB_PLATFORM_FRAMEWORKS)
def test_hub_platform_rejects_active(
set_core_config: SetCoreConfigCallable,
platform_framework: PlatformFramework,
) -> None:
set_core_config(platform_framework)
_register_tracker(platform_framework.value[0])
with pytest.raises(cv.Invalid, match="Active connections are not supported"):
bluetooth_proxy.CONFIG_SCHEMA({"active": True})
@pytest.mark.parametrize("platform_framework", HUB_PLATFORM_FRAMEWORKS)
@pytest.mark.parametrize(
("key", "value"),
[
("connection_slots", 2),
("cache_services", True),
# Absent from the outer CONFIG_SCHEMA, so this gate is the only test
# that touches it.
("connections", [{}]),
],
)
def test_hub_platform_rejects_connection_keys_by_name(
set_core_config: SetCoreConfigCallable,
platform_framework: PlatformFramework,
key: str,
value: object,
) -> None:
set_core_config(platform_framework)
with pytest.raises(cv.Invalid, match=f"'{key}' requires active"):
bluetooth_proxy.CONFIG_SCHEMA({key: value})
@pytest.mark.parametrize("platform_framework", HUB_PLATFORM_FRAMEWORKS)
def test_hub_platform_accepts_the_advertisement_only_shape(
set_core_config: SetCoreConfigCallable,
platform_framework: PlatformFramework,
) -> None:
set_core_config(platform_framework)
_register_tracker(platform_framework.value[0])
validated = bluetooth_proxy.CONFIG_SCHEMA({})
assert validated[CONF_ACTIVE] is False
def test_rp2_defaults_to_the_full_proxy(
set_core_config: SetCoreConfigCallable,
) -> None:
# esp32 parity: active defaults to true, with the platform's slot limit,
# and one populated connection entry for the codegen to index.
set_core_config(PlatformFramework.RP2_ARDUINO)
_register_tracker(PLATFORM_RP2)
validated = bluetooth_proxy.CONFIG_SCHEMA({})
assert validated[CONF_ACTIVE] is True
assert validated[bluetooth_proxy.CONF_CONNECTION_SLOTS] == 3
assert len(validated[bluetooth_proxy.CONF_CONNECTIONS]) == 3
def test_rp2_accepts_explicit_passive(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.RP2_ARDUINO)
_register_tracker(PLATFORM_RP2)
validated = bluetooth_proxy.CONFIG_SCHEMA({CONF_ACTIVE: False})
assert validated[CONF_ACTIVE] is False
assert bluetooth_proxy.CONF_CONNECTIONS not in validated
def test_rp2_rejects_slots_beyond_the_btstack_limit(
set_core_config: SetCoreConfigCallable,
) -> None:
# The BTstack pool overrides are sized for RP2_MAX_CONNECTIONS slots.
set_core_config(PlatformFramework.RP2_ARDUINO)
_register_tracker(PLATFORM_RP2)
with pytest.raises(cv.Invalid, match="at most 3 connection slot"):
bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 4})
# Fewer slots than the cap stay accepted (the prebuilt single-client pool
# path for 1, the wrap path for 2).
validated = bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 1})
assert len(validated[bluetooth_proxy.CONF_CONNECTIONS]) == 1
# Values past even the loosest platform cap stop at the outer walkable
# schema, which stays bounded for range walkers (device-builder sync);
# in-range values get the platform message above.
with pytest.raises(cv.Invalid, match="at most 9"):
bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 12})
def test_rp2_rejects_esp32_only_keys_by_name(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.RP2_ARDUINO)
_register_tracker(PLATFORM_RP2)
with pytest.raises(cv.Invalid, match="'cache_services' is esp32-only"):
bluetooth_proxy.CONFIG_SCHEMA({"cache_services": True})
with pytest.raises(cv.Invalid, match="'connections' has no per-connection options"):
bluetooth_proxy.CONFIG_SCHEMA({"connections": [{}]})
def test_hub_source_filter_covers_every_hub_platform() -> None:
# bluetooth_connection cannot import this module to derive the hub.cpp
# framework set, so pin it here: a platform admitted to the proxy but
# missing from the filter would validate, then fail at link.
expected = frameworks_for_platforms(
[*bluetooth_proxy._HUB_PLATFORMS, PLATFORM_ESP32]
)
hub_frameworks = bluetooth_connection.SOURCE_FILE_FRAMEWORKS[
"bluetooth_connection_hub.cpp"
]
assert expected == hub_frameworks
def test_bluetooth_connection_auto_load_covers_its_includes() -> None:
# The backend registers with its platform BLE stack (and the Bluedroid
# header includes the tracker's), so that closure lives here and
# consumers stay platform-blind; the platform-less arm is the union for
# manifest-resolving tooling.
_set_platform("esp32")
assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "esp32_ble_tracker"]
_set_platform("rp2")
assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base", "rp2040_ble"]
_set_platform("ln882x")
assert bluetooth_connection.AUTO_LOAD() == ["ble_device_base"]
_set_platform(None)
assert bluetooth_connection.AUTO_LOAD() == [
"ble_device_base",
"esp32_ble_tracker",
"rp2040_ble",
]
def test_every_registered_hub_platform_has_a_schema_arm() -> None:
# A platform added to HUB_MAX_CONNECTIONS without a schema builder or
# _HUB_PLATFORMS entry would only fail when a config for it is validated
# (or not even then); pin both couplings here. Connection codegen is
# shared (bluetooth_connection.new_gatt_backend), so it needs no arm.
registered = set(bluetooth_connection.HUB_MAX_CONNECTIONS)
assert registered <= set(bluetooth_proxy._GATT_HUB_SCHEMAS)
assert registered <= set(bluetooth_proxy._HUB_PLATFORMS)
# Hub platforms must also be in the backend registry the shared codegen
# helpers dispatch on.
assert registered <= set(bluetooth_connection._PLATFORM_BACKENDS)
# The outer walkable schema's bound must stay the loosest platform cap.
assert (
max(bluetooth_connection.HUB_MAX_CONNECTIONS.values())
<= bluetooth_proxy._IDF_MAX_CONNECTIONS
)
def test_defines_h_mirrors_the_rp2_slot_cap() -> None:
# esphome/core/defines.h carries a literal BLUETOOTH_PROXY_MAX_CONNECTIONS
# for static analysis; pin it to the real rp2 cap.
defines = (Path(__file__).parents[3] / "esphome" / "core" / "defines.h").read_text()
cap = bluetooth_connection.RP2_MAX_CONNECTIONS
# The rp2 arm's define, tolerating blank/comment lines in between.
match = re.search(
r"#elif defined\(USE_RP2\)\s*(?:(?://[^\n]*)?\n)+#define BLUETOOTH_PROXY_MAX_CONNECTIONS (\d+)",
defines,
)
assert match is not None, "no USE_RP2 arm defines BLUETOOTH_PROXY_MAX_CONNECTIONS"
assert int(match.group(1)) == cap, (
f"defines.h rp2 arm carries {match.group(1)}, expected {cap}"
)
# The static-analysis client count scales with the same cap. Scoped to
# the USE_RP2 block: the esp32 arm carries its own count.
rp2_block = re.search(r"#ifdef USE_RP2\n((?:#define [^\n]*\n)+)", defines)
assert rp2_block is not None, "no USE_RP2 platform block in defines.h"
match = re.search(
r"#define ESPHOME_BLE_GATT_CLIENT_COUNT (\d+)", rp2_block.group(1)
)
assert match is not None, "ESPHOME_BLE_GATT_CLIENT_COUNT missing from rp2 block"
assert int(match.group(1)) == cap, (
f"rp2 ESPHOME_BLE_GATT_CLIENT_COUNT is {match.group(1)}, expected {cap}"
)
@@ -0,0 +1,154 @@
"""Tests for the bridge cdc_acm_uart platform's final validation."""
import pytest
from esphome import config_validation as cv
from esphome.components.cdc_acm_uart import bridge
from esphome.components.cdc_acm_uart.bridge import CONF_USB_CDC_ACM_ID
from esphome.config import Config
from esphome.const import CONF_DEBUG, CONF_ID, CONF_UART_ID, PlatformFramework
from esphome.core import ID
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
_final_validate = bridge._final_validate
def _set_esp32_s3(set_core_config: SetCoreConfigCallable, **kwargs) -> None:
from esphome.components.esp32 import KEY_VARIANT, VARIANT_ESP32S3
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_VARIANT: VARIANT_ESP32S3},
**kwargs,
)
def _full_config(uarts: list[ConfigType] | None = None, **domains) -> Config:
"""A full config declaring uart_0 and uart_1 (plus any extra entries), as the ID
pass leaves it, so the debug check can resolve a uart_id to its declaration."""
uarts = uarts or [{CONF_ID: ID("uart_0")}, {CONF_ID: ID("uart_1")}]
full = Config()
full["uart"] = uarts
for index, uart_conf in enumerate(uarts):
full.declare_ids.append((uart_conf[CONF_ID], ["uart", index, CONF_ID]))
full.update(domains)
return full
def _bridge_config(uart_id: str, cdc_id: str) -> dict:
return {CONF_UART_ID: ID(uart_id), CONF_USB_CDC_ACM_ID: ID(cdc_id)}
def test_accepts_distinct_uart_and_cdc_interfaces(
set_core_config: SetCoreConfigCallable,
) -> None:
_set_esp32_s3(set_core_config, full_config=_full_config())
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
_final_validate(_bridge_config("uart_1", "cdc_acm_2"))
def test_rejects_two_bridges_sharing_a_uart(
set_core_config: SetCoreConfigCallable,
) -> None:
_set_esp32_s3(set_core_config, full_config=_full_config())
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
with pytest.raises(cv.Invalid, match="already bridged"):
_final_validate(_bridge_config("uart_0", "cdc_acm_2"))
def test_rejects_two_bridges_sharing_a_cdc_interface(
set_core_config: SetCoreConfigCallable,
) -> None:
_set_esp32_s3(set_core_config, full_config=_full_config())
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
with pytest.raises(cv.Invalid, match="already bridged"):
_final_validate(_bridge_config("uart_1", "cdc_acm_1"))
def test_rejects_uart_shared_with_another_component(
set_core_config: SetCoreConfigCallable,
) -> None:
_set_esp32_s3(
set_core_config,
full_config=_full_config(
sensor=[{"platform": "pzemac", CONF_UART_ID: ID("uart_0")}],
),
)
with pytest.raises(cv.Invalid, match="exclusive"):
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
def test_rejects_cdc_interface_shared_with_another_component(
set_core_config: SetCoreConfigCallable,
) -> None:
# The CDC instance is itself a uart::UARTComponent, so other components can bind
# it as a plain UART via uart_id -- that must be rejected just like UART sharing.
_set_esp32_s3(
set_core_config,
full_config=_full_config(
sensor=[{"platform": "pzemac", CONF_UART_ID: ID("cdc_acm_1")}],
),
)
with pytest.raises(cv.Invalid, match="exclusive"):
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
def test_rejects_uart_referenced_from_nested_config(
set_core_config: SetCoreConfigCallable,
) -> None:
# References can sit arbitrarily deep, e.g. inside an automation's action list.
_set_esp32_s3(
set_core_config,
full_config=_full_config(
binary_sensor=[
{
"platform": "gpio",
"on_press": [{"then": [{CONF_UART_ID: ID("uart_0")}]}],
}
],
),
)
with pytest.raises(cv.Invalid, match="exclusive"):
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
def test_ignores_other_components_on_other_uarts(
set_core_config: SetCoreConfigCallable,
) -> None:
_set_esp32_s3(
set_core_config,
full_config=_full_config(
sensor=[{"platform": "pzemac", CONF_UART_ID: ID("uart_1")}],
# The bridge domain itself is skipped: this bridge's own entry (and any
# bridge-vs-bridge sharing, which the seen-set already rejects) must not
# trip the exclusivity scan.
bridge=[_bridge_config("uart_0", "cdc_acm_1")],
),
)
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
def test_rejects_debug_on_bridged_uart(
set_core_config: SetCoreConfigCallable,
) -> None:
# The bridge talks to the IDF driver directly, so the uart debugger would see
# nothing and its dummy_receiver would steal RX bytes.
_set_esp32_s3(
set_core_config,
full_config=_full_config(uarts=[{CONF_ID: ID("uart_0"), CONF_DEBUG: {}}]),
)
with pytest.raises(cv.Invalid, match="debug"):
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
def test_allows_debug_on_other_uart(
set_core_config: SetCoreConfigCallable,
) -> None:
_set_esp32_s3(
set_core_config,
full_config=_full_config(
uarts=[{CONF_ID: ID("uart_0")}, {CONF_ID: ID("uart_1"), CONF_DEBUG: {}}]
),
)
_final_validate(_bridge_config("uart_0", "cdc_acm_1"))
@@ -0,0 +1,53 @@
"""Power-user fields are marked as advanced on the shared schemas.
``filters``, ``manual_ip`` and the GPIO switch interlock options are knobs
whose defaults suit nearly every user, so a schema-aware editor should keep
them behind its "advanced settings" disclosure rather than on the main form.
"""
from __future__ import annotations
import importlib
import pytest
from esphome.components import binary_sensor, ethernet, sensor, text_sensor, wifi
import esphome.config_validation as cv
def _markers(schema: cv.Schema) -> dict[str, object]:
s = schema
if hasattr(s, "validators"):
# cv.All -> the schema is the first validator.
s = s.validators[0]
return {str(k): k for k in s.schema}
def _gpio_switch_schema() -> cv.Schema:
return importlib.import_module("esphome.components.gpio.switch").CONFIG_SCHEMA
@pytest.mark.parametrize(
("label", "schema_factory", "fields"),
[
("sensor", sensor.sensor_schema, ["filters"]),
("binary_sensor", binary_sensor.binary_sensor_schema, ["filters"]),
("text_sensor", text_sensor.text_sensor_schema, ["filters"]),
("wifi_network", lambda: wifi.WIFI_NETWORK_BASE, ["manual_ip"]),
("wifi", lambda: wifi.CONFIG_SCHEMA, ["manual_ip"]),
("ethernet", lambda: ethernet.BASE_SCHEMA, ["manual_ip"]),
("gpio_switch", _gpio_switch_schema, ["interlock", "interlock_wait_time"]),
],
)
def test_power_user_fields_are_advanced(
label: str, schema_factory, fields: list[str]
) -> None:
markers = _markers(schema_factory())
for field in fields:
assert markers[field].visibility is cv.Visibility.ADVANCED, f"{label}.{field}"
def test_interlock_wait_time_keeps_its_default() -> None:
"""Marking the field advanced must not drop its default."""
markers = _markers(_gpio_switch_schema())
assert markers["interlock_wait_time"].default() == "0ms"
+15 -2
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(Config())
yield
final_validate.full_config.reset(token)
@pytest.fixture
def set_core_config() -> Generator[SetCoreConfigCallable]:
"""Fixture to set up the core configuration for tests."""
@@ -67,7 +75,7 @@ def set_core_config() -> Generator[SetCoreConfigCallable]:
*,
core_data: ConfigType | None = None,
platform_data: ConfigType | None = None,
full_config: dict[str, ConfigType] | None = None,
full_config: dict[str, ConfigType] | Config | None = None,
) -> None:
platform, framework = platform_framework.value
@@ -86,7 +94,12 @@ def set_core_config() -> Generator[SetCoreConfigCallable]:
CORE.data[platform.value] = platform_data
config.path_context.set([])
final_validate.full_config.set(full_config or Config())
# Production always installs a Config (a FinalValidateConfig), never a plain dict.
if not isinstance(full_config, Config):
full = Config()
full.update(full_config or {})
full_config = full
final_validate.full_config.set(full_config)
yield setter
@@ -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,26 @@
esphome:
name: test
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
framework:
type: esp-idf
spi:
clk_pin: GPIO18
mosi_pin: GPIO19
display:
- platform: epaper_spi
id: epaper_display
model: t133a01
dc_pin: GPIO21
reset_pin: GPIO38
cs_pin: GPIO10
cs1_pin: GPIO2
busy_pin: GPIO13
update_interval: never
dimensions:
width: 200
height: 200
@@ -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],
@@ -462,3 +479,24 @@ def test_enable_pin_code_generation(
# Both pin objects must be passed to the display via set_enable_pins() as a
# std::vector initializer list, in the configured order.
assert f"set_enable_pins({{{pin_25}, {pin_26}}});" in main_cpp
def test_model_with_no_default_init_sequence_generates(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Test that code generation succeeds for a model with no default init sequence.
The base "t133a01" model (used directly, not via one of its `.extend()`
variants) doesn't override `get_init_sequence()` or pass `initsequence` to
its constructor, and the user didn't supply `init_sequence:` either.
`EpaperModel.get_init_sequence()` used to default to `None` in this case,
which made `flatten_sequence()` raise a `TypeError` during code
generation. Regression test for that crash.
"""
main_cpp = generate_main(component_config_path("t133a01_no_init_sequence.yaml"))
# The generated constructor call takes (name, width, height, init_sequence,
# init_sequence_length, ...); a length of 0 confirms the empty init
# sequence array was generated instead of raising during code generation.
assert re.search(r"epaper_spi::EPaperT133A01\([^;]*,\s*\w+,\s*0\);", main_cpp)
@@ -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
@@ -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,19 @@
esphome:
name: test
esp32:
variant: esp32c6
framework:
type: esp-idf
network:
enable_ipv6: true
openthread:
channel: 13
network_name: OpenThread-8f28
network_key: 0xdfd34f0f05cad978ec4e32b0413038ff
pan_id: 0x8f28
ext_pan_id: 0xd63e8e3e495ebbc3
pskc: 0xc23a76e98f1a6483639b1ac1271e2e27
mesh_local_prefix: fd53:145f:ed22:ad81::/64
@@ -0,0 +1,17 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
advanced:
disable_mbedtls_tls_server: false
disable_mbedtls_tls_extras: false
wifi:
ssid: "test_ssid"
password: "test_password"
http_request:
verify_ssl: true
@@ -0,0 +1,17 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
sdkconfig_options:
CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT: y
CONFIG_MBEDTLS_CCM_C: y
wifi:
ssid: "test_ssid"
password: "test_password"
http_request:
verify_ssl: true
@@ -0,0 +1,17 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ssid: "test_ssid"
eap:
identity: "user@example.org"
username: "user"
password: "secret"
http_request:
verify_ssl: true
@@ -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"
@@ -0,0 +1,10 @@
esphome:
name: test
esp32:
variant: esp32c6
framework:
type: esp-idf
advanced:
signed_ota_verification:
signing_scheme: ecdsa256
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
variant: esp32
framework:
type: esp-idf
advanced:
signed_ota_verification:
signing_scheme: ecdsa_v1
verification_key: ../../../components/esp32/dummy_signing_key_v1_ecdsa.pem
@@ -0,0 +1,10 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
signed_ota_verification:
signing_scheme: rsa3072
@@ -0,0 +1,11 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
signed_ota_verification:
signing_scheme: rsa3072
signing_key: ../../../components/esp32/dummy_signing_key.pem
@@ -0,0 +1,12 @@
esphome:
name: test
esp32:
variant: esp32s3
framework:
type: esp-idf
advanced:
signed_ota_verification:
signing_scheme: rsa3072
verification_keys:
- ../../../components/esp32/dummy_signing_key.pem
@@ -0,0 +1,7 @@
esphome:
name: test
esp32:
variant: esp32c6
framework:
type: esp-idf
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
variant: esp32c6
framework:
type: esp-idf
advanced:
enable_full_printf: true
+670 -14
View File
@@ -10,14 +10,24 @@ from typing import Any
import pytest
from esphome.components.esp32 import (
KEY_FATFS_REQUIRED,
KEY_MBEDTLS_TLS_EXTRAS_REQUIRED,
KEY_MBEDTLS_TLS_SERVER_REQUIRED,
KEY_VFS_DIR_REQUIRED,
KEY_VFS_SELECT_REQUIRED,
KEY_VFS_TERMIOS_REQUIRED,
MBEDTLS_TLS_EXTRA_OPTIONS,
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,
@@ -108,6 +118,38 @@ def test_esp32_default_toolchain_is_esp_idf(
assert CORE.toolchain == expected
@pytest.mark.parametrize(
"config_toolchain",
[Toolchain.SDK_NRF.value, "nonsense"],
)
def test_esp32_rejects_unsupported_toolchains(
set_core_config: SetCoreConfigCallable,
config_toolchain: str,
) -> None:
"""Toolchains esp32 does not support are rejected at validation time."""
set_core_config(PlatformFramework.ESP32_IDF)
from esphome.components.esp32 import CONFIG_SCHEMA
CORE.toolchain = None
with pytest.raises(cv.Invalid, match="Unknown value"):
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"),
[
@@ -164,6 +206,18 @@ def test_esp32_default_toolchain_is_esp_idf(
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",
@@ -213,16 +267,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(
@@ -237,6 +457,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],
@@ -252,6 +484,53 @@ def test_nvs_encryption_sdkconfig(
assert "PERMANENT and IRREVERSIBLE" in caplog.text
@pytest.mark.parametrize(
("fixture", "multi_key", "idf_on_update"),
[
# Externally-signed RSA with a declared trusted-key list hands
# verification to ESPHome's multi-key verifier, so IDF's single-block
# on-update check must be OFF. It defaults ON under
# SECURE_SIGNED_APPS_NO_SECURE_BOOT, so it has to be set to False
# explicitly -- not merely omitted.
("signed_ota_verification_keys_s3.yaml", True, False),
# Externally-signed RSA without a trusted-key list has no trust anchor,
# so it falls back to IDF's built-in check.
("signed_ota_external_rsa_s3.yaml", False, True),
# Build-time signing and the other schemes keep IDF's check.
("signed_ota_signing_key_s3.yaml", False, True),
("signed_ota_ecdsa256_c6.yaml", False, True),
("signed_ota_ecdsa_v1.yaml", False, True),
],
)
def test_signed_ota_verification_sdkconfig(
fixture: str,
multi_key: bool,
idf_on_update: bool,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Only external RSA disables IDF's on-update check and uses ESPHome's verifier."""
generate_main(component_config_path(fixture))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
# The padded, externally-signable image is always produced.
assert sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT") is True
# Explicit value (never left to the Kconfig default) decides who verifies.
assert (
sdkconfig.get("CONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT") is idf_on_update
)
defines = {define.name for define in CORE.defines}
assert ("USE_OTA_SIGNED_VERIFICATION_MULTI_KEY" in defines) is multi_key
if multi_key:
# The padding / reserved signature sector the verifier depends on keys
# off the RSA scheme symbol, not the hidden CONFIG_SECURE_SIGNED_APPS
# (which the explicit `n` above drives to n). Pin the real dependency.
assert sdkconfig.get("CONFIG_SECURE_SIGNED_APPS_RSA_SCHEME") is True
# The compiled-in trust anchor: the fixture lists one key.
define_values = {define.name: str(define.value) for define in CORE.defines}
assert define_values["OTA_TRUSTED_KEY_COUNT"] == "1"
assert "OTA_TRUSTED_KEY_DIGESTS" in define_values
@pytest.mark.parametrize(
("fixture", "expect_warning"),
[
@@ -454,26 +733,18 @@ def test_flash_mode_unset_leaves_defaults(
),
pytest.param(
PlatformFramework.ESP32_IDF,
NetworkSdkconfigData(
wifi=True, bluetooth=True, ble_42=True, software_coexistence=True
),
NetworkSdkconfigData(wifi=True, bluetooth=True, software_coexistence=True),
{},
{
"CONFIG_BT_ENABLED": True,
"CONFIG_BT_BLE_42_FEATURES_SUPPORTED": True,
"CONFIG_BT_BLE_50_FEATURES_SUPPORTED": False,
"CONFIG_SW_COEXIST_ENABLE": True,
"CONFIG_ESP_WIFI_SOFTAP_SUPPORT": False,
"CONFIG_LWIP_DHCPS": False,
},
id="idf_wifi_ble_tracker_coexistence",
),
pytest.param(
PlatformFramework.ESP32_IDF,
NetworkSdkconfigData(bluetooth=True),
{},
{"CONFIG_BT_ENABLED": True},
id="idf_ble_server_only_no_ble42",
),
# --- IDF: user sdkconfig_options always win ---
pytest.param(
PlatformFramework.ESP32_IDF,
@@ -557,6 +828,160 @@ def test_reconcile_network_sdkconfig(
assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected
@pytest.mark.parametrize(
("requires", "fatfs_required", "disables", "preset", "expected"),
[
# Nothing required and every disable_* flag off (NOT the shipped defaults, which
# disable everything): VFS enabled, FATFS left untouched entirely.
pytest.param(
{},
False,
(False, False, False, False),
{},
{
"CONFIG_VFS_SUPPORT_TERMIOS": True,
"CONFIG_VFS_SUPPORT_SELECT": True,
"CONFIG_VFS_SUPPORT_DIR": True,
},
id="nothing_disabled_nothing_required",
),
# The shipped out-of-the-box path: every disable_* flag defaults to True and nothing
# is required -- VFS off, FATFS at the smallest footprint (8.3 names, one volume).
pytest.param(
{},
False,
(True, True, True, True),
{},
{
"CONFIG_VFS_SUPPORT_TERMIOS": False,
"CONFIG_VFS_SUPPORT_SELECT": False,
"CONFIG_VFS_SUPPORT_DIR": False,
"CONFIG_FATFS_LFN_NONE": True,
"CONFIG_FATFS_VOLUME_COUNT": 1,
},
id="all_disabled_fatfs_fallback",
),
# A component's require_* beats the user's disable_* flag for every VFS feature.
pytest.param(
{
KEY_VFS_TERMIOS_REQUIRED: True,
KEY_VFS_SELECT_REQUIRED: True,
KEY_VFS_DIR_REQUIRED: True,
},
False,
(True, True, True, False),
{},
{
"CONFIG_VFS_SUPPORT_TERMIOS": True,
"CONFIG_VFS_SUPPORT_SELECT": True,
"CONFIG_VFS_SUPPORT_DIR": True,
},
id="require_beats_disable",
),
# A user sdkconfig_options preset wins over a require (the set_opt guard).
pytest.param(
{KEY_VFS_SELECT_REQUIRED: True},
False,
(False, False, False, False),
{"CONFIG_VFS_SUPPORT_SELECT": False},
{
"CONFIG_VFS_SUPPORT_TERMIOS": True,
"CONFIG_VFS_SUPPORT_SELECT": False,
"CONFIG_VFS_SUPPORT_DIR": True,
},
id="user_preset_wins_over_require",
),
# require_fatfs() with no user preset: long filenames on the heap, 255 chars,
# four volumes.
pytest.param(
{},
True,
(False, False, False, False),
{},
{
"CONFIG_VFS_SUPPORT_TERMIOS": True,
"CONFIG_VFS_SUPPORT_SELECT": True,
"CONFIG_VFS_SUPPORT_DIR": True,
"CONFIG_FATFS_LFN_NONE": False,
"CONFIG_FATFS_LFN_HEAP": True,
"CONFIG_FATFS_MAX_LFN": 255,
"CONFIG_FATFS_VOLUME_COUNT": 4,
},
id="fatfs_required_defaults",
),
# CONFIG_FATFS_LONG_FILENAMES is a Kconfig choice: a user picking any member
# (here LFN_STACK) leaves the whole group untouched -- no second =y in the choice.
pytest.param(
{},
True,
(False, False, False, False),
{"CONFIG_FATFS_LFN_STACK": "y"},
{
"CONFIG_VFS_SUPPORT_TERMIOS": True,
"CONFIG_VFS_SUPPORT_SELECT": True,
"CONFIG_VFS_SUPPORT_DIR": True,
"CONFIG_FATFS_LFN_STACK": "y",
"CONFIG_FATFS_VOLUME_COUNT": 4,
},
id="fatfs_user_lfn_stack_untouched",
),
# disable_fatfs (the shipped default) with a user LFN pick: the choice group is the
# user's -- no LFN_NONE=y written next to their member, only the volume fallback.
pytest.param(
{},
False,
(False, False, False, True),
{"CONFIG_FATFS_LFN_HEAP": "y"},
{
"CONFIG_VFS_SUPPORT_TERMIOS": True,
"CONFIG_VFS_SUPPORT_SELECT": True,
"CONFIG_VFS_SUPPORT_DIR": True,
"CONFIG_FATFS_LFN_HEAP": "y",
"CONFIG_FATFS_VOLUME_COUNT": 1,
},
id="disable_fatfs_user_lfn_untouched",
),
# Same for an explicit LFN_NONE preset: the group is the user's, only the volume
# count default is added.
pytest.param(
{},
True,
(False, False, False, False),
{"CONFIG_FATFS_LFN_NONE": "y"},
{
"CONFIG_VFS_SUPPORT_TERMIOS": True,
"CONFIG_VFS_SUPPORT_SELECT": True,
"CONFIG_VFS_SUPPORT_DIR": True,
"CONFIG_FATFS_LFN_NONE": "y",
"CONFIG_FATFS_VOLUME_COUNT": 4,
},
id="fatfs_user_lfn_none_untouched",
),
],
)
def test_reconcile_vfs_fatfs_sdkconfig(
set_core_config: SetCoreConfigCallable,
requires: dict[str, bool],
fatfs_required: bool,
disables: tuple[bool, bool, bool, bool],
preset: dict[str, Any],
expected: dict[str, Any],
) -> None:
"""The FINAL-priority reconciler resolves the VFS feature flags and the FATFS
defaults from the recorded require_* calls, with user sdkconfig_options winning
and the LFN Kconfig choice treated as one group."""
set_core_config(PlatformFramework.ESP32_IDF)
CORE.data[KEY_ESP32] = {KEY_SDKCONFIG_OPTIONS: dict(preset)}
if fatfs_required:
CORE.data[KEY_ESP32][KEY_FATFS_REQUIRED] = True
for key, value in requires.items():
CORE.data[key] = value
asyncio.run(_reconcile_vfs_fatfs_sdkconfig(*disables))
assert CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS] == expected
def test_network_wifi_only_reconciles_end_to_end(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
@@ -567,6 +992,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
@@ -582,6 +1015,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(
@@ -594,6 +1033,7 @@ def test_network_wifi_ble_coexistence_reconciles_end_to_end(
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_BT_ENABLED") is True
assert sdkconfig.get("CONFIG_BT_BLE_42_FEATURES_SUPPORTED") is True
assert sdkconfig.get("CONFIG_BT_BLE_50_FEATURES_SUPPORTED") is False
assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is True
assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False
assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False
@@ -696,6 +1136,9 @@ def test_downgrade_protection_reports_all_unmet_requirements() -> None:
# V1 ECDSA: exactly one of signing key / verification key.
{"signing_scheme": "ecdsa_v1", "signing_key": "key.pem"},
{"signing_scheme": "ecdsa_v1", "verification_key": "key.bin"},
# External RSA with a compiled-in trusted-key list (digests).
{"signing_scheme": "rsa3072", "verification_keys": ["ab" * 32]},
{"signing_scheme": "rsa3072", "verification_keys": ["ab" * 32, "cd" * 32]},
],
)
def test_signed_ota_keys_valid_combinations(config: dict) -> None:
@@ -750,6 +1193,34 @@ def test_signed_ota_bare_block_selects_v2_external_signing(value: dict | None) -
},
"not both",
),
# A trusted-key list only applies to external RSA.
(
{"signing_scheme": "ecdsa256", "verification_keys": ["ab" * 32]},
"only used with signing scheme 'rsa3072'",
),
# Can't both auto-sign and verify against a fixed trusted set.
(
{
"signing_scheme": "rsa3072",
"signing_key": "key.pem",
"verification_keys": ["ab" * 32],
},
"cannot be combined with",
),
# The singular V1 key and the RSA trusted-key list are mutually exclusive.
(
{
"signing_scheme": "rsa3072",
"verification_key": "key.bin",
"verification_keys": ["ab" * 32],
},
"at most one",
),
# Duplicate trusted keys are rejected.
(
{"signing_scheme": "rsa3072", "verification_keys": ["ab" * 32, "ab" * 32]},
"must be unique",
),
],
)
def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None:
@@ -759,6 +1230,48 @@ def test_signed_ota_keys_invalid_combinations(config: dict, match: str) -> None:
_validate_signed_ota_keys(config)
def test_sbv2_rsa_key_digest_known_answer() -> None:
"""The compiled-in trust anchor is the block-format digest the device
computes per signature block; pin it to espsecure's known output for the
shipped dummy key so a future change to the derivation can't drift silently.
"""
from esphome.components.esp32 import _sbv2_rsa_key_digest
key = (
Path(__file__).parent.parent.parent
/ "components"
/ "esp32"
/ "dummy_signing_key.pem"
)
assert (
_sbv2_rsa_key_digest(key).hex()
== "957671f5ec1b55b3fb1d32c5525a68d3b8c33847922daddb4feefe64cd679f65"
)
def test_validate_trusted_key_hex_forms() -> None:
"""The digest-input branch: the same key as an uppercase 64-hex digest
normalizes to the PEM-derived value (the two forms are interchangeable), and
a mangled digest fails clearly instead of as a missing file.
"""
from esphome.components.esp32 import _sbv2_rsa_key_digest, _validate_trusted_key
key = (
Path(__file__).parent.parent.parent
/ "components"
/ "esp32"
/ "dummy_signing_key.pem"
)
pem_digest = _sbv2_rsa_key_digest(key).hex()
assert _validate_trusted_key(pem_digest.upper()) == pem_digest
for bad in (pem_digest[:-1], "0x" + pem_digest):
with pytest.raises(cv.Invalid, match="64 hex"):
_validate_trusted_key(bad)
# An unquoted 0x.../all-digit digest reaches the validator as a YAML int.
with pytest.raises(cv.Invalid, match="Quote the digest"):
_validate_trusted_key(0x957671F5EC1B55B3)
@pytest.mark.parametrize(
("value", "expected"),
[
@@ -782,3 +1295,146 @@ 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
_TLS_SERVER_OPTIONS = (
"CONFIG_MBEDTLS_TLS_CLIENT_ONLY",
"CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT",
)
@pytest.mark.parametrize(
("config_file", "server", "extras"),
[
pytest.param("mbedtls_tls_default.yaml", (True, False), False, id="default"),
pytest.param("mbedtls_tls_opt_out.yaml", (None, None), None, id="opt_out"),
pytest.param("mbedtls_tls_wifi_eap.yaml", (True, False), None, id="wifi_eap"),
],
)
def test_mbedtls_tls_trim_sdkconfig(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
server: tuple[bool | None, bool | None],
extras: bool | None,
) -> None:
"""Client-only TLS and the unused-feature trims apply unless opted out or required."""
generate_main(component_config_path(config_file))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == server
assert {sdkconfig.get(name) for name in MBEDTLS_TLS_EXTRA_OPTIONS} == {extras}
_OPENTHREAD_EXTRAS = {"CONFIG_MBEDTLS_CCM_C", "CONFIG_MBEDTLS_ECDSA_DETERMINISTIC"}
def test_mbedtls_tls_openthread_keeps_only_what_it_uses(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The OpenThread config keeps the DTLS server, CCM and deterministic ECDSA; the rest is trimmed."""
generate_main(component_config_path("mbedtls_tls_openthread.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert tuple(sdkconfig.get(name) for name in _TLS_SERVER_OPTIONS) == (None, None)
for name in MBEDTLS_TLS_EXTRA_OPTIONS:
assert sdkconfig.get(name) is (None if name in _OPENTHREAD_EXTRAS else False)
def test_mbedtls_tls_user_sdkconfig_wins(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A user-set TLS role member leaves the whole choice alone; other user values are kept."""
generate_main(component_config_path("mbedtls_tls_user_sdkconfig.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_MBEDTLS_TLS_CLIENT_ONLY") is None
role = sdkconfig["CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT"]
assert isinstance(role, RawSdkconfigValue) and role.value == "y"
ccm = sdkconfig["CONFIG_MBEDTLS_CCM_C"]
assert isinstance(ccm, RawSdkconfigValue) and ccm.value == "y"
assert {
sdkconfig.get(name)
for name in MBEDTLS_TLS_EXTRA_OPTIONS
if name != "CONFIG_MBEDTLS_CCM_C"
} == {False}
def test_mbedtls_tls_openthread_requires_server_and_extras(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The OpenThread hooks mark the DTLS server and CCM/deterministic ECDSA as required."""
generate_main(component_config_path("mbedtls_tls_openthread.yaml"))
assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_SERVER_REQUIRED] is True
assert CORE.data[KEY_ESP32][KEY_MBEDTLS_TLS_EXTRAS_REQUIRED] == _OPENTHREAD_EXTRAS
_VASPRINTF_STUB_FLAGS = {"-Wl,--wrap=vasprintf", "-Wl,--undefined=__wrap_vasprintf"}
@pytest.mark.parametrize(
("config_file", "expected"),
[
pytest.param("vasprintf_stub_c6.yaml", True, id="c6"),
pytest.param("vasprintf_stub_c6_full_printf.yaml", False, id="c6_full_printf"),
pytest.param("exclusion_reincludes.yaml", False, id="esp32"),
],
)
def test_vasprintf_stub_only_on_rom_vsnprintf_variants(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
expected: bool,
) -> None:
"""The vasprintf wrap is emitted only where the ROM lacks vasprintf but has vsnprintf."""
generate_main(component_config_path(config_file))
assert (CORE.build_flags >= _VASPRINTF_STUB_FLAGS) is expected
defines = {define.name for define in CORE.defines}
assert ("USE_ESP32_VASPRINTF_STUB" in defines) is expected
@@ -0,0 +1,13 @@
esphome:
name: test
esp32:
variant: esp32
wifi:
ssid: MySSID
password: password1
# esp32_ble_server is only auto-loaded here, so it has no services of its own.
esp32_improv:
authorizer: none
@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
variant: esp32
esp32_ble_server:
id: ble_server
manufacturer_data: [0x72, 0x04, 0x00, 0x23]
@@ -0,0 +1,14 @@
esphome:
name: test
esp32:
variant: esp32
esp32_ble_server:
id: ble_server
services:
- uuid: 2a24b789-7aab-4535-af3e-ee76a35cc12d
characteristics:
- uuid: cad48e28-7fbe-41cf-bae9-d77a6c233423
read: true
value: [1, 2, 3, 4]
@@ -1,5 +1,10 @@
"""Tests for esp32_ble_server configuration helpers."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components.esp32_ble_server import (
@@ -45,3 +50,26 @@ def test_uuid_is_matches_descriptor_short_strings(uuid16) -> None:
assert uuid_is(uuid16, uuid16)
assert uuid_is(f"{uuid16:04X}", uuid16)
assert uuid_is(f"{uuid16:08X}", uuid16)
@pytest.mark.parametrize(
("config_file", "required"),
[
# Auto-loaded by esp32_improv only: nothing to find until Improv asks for it
("improv_only.yaml", False),
# The configuration defines a service clients are meant to connect to
("own_service.yaml", True),
# Manufacturer data is only useful if it is actually broadcast
("manufacturer_data_only.yaml", True),
],
)
def test_advertising_required(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
config_file: str,
required: bool,
) -> None:
"""The server only requests advertising when the configuration needs it."""
main_cpp = generate_main(component_config_path(config_file))
assert f"set_advertising_required({str(required).lower()})" in main_cpp
@@ -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:

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