mirror of
https://github.com/esphome/esphome.git
synced 2026-09-18 10:38:38 +00:00
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:
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user