mirror of
https://github.com/esphome/esphome.git
synced 2026-09-25 05:54:01 +00:00
Merge branch 'neutral-ble-client' into radon-eye-single-node
This commit is contained in:
@@ -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++) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,15 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
variant: esp32s3
|
||||
|
||||
spi:
|
||||
clk_pin: GPIO7
|
||||
mosi_pin: GPIO9
|
||||
|
||||
display:
|
||||
- platform: epaper_spi
|
||||
id: epaper_display
|
||||
model: seeed-reterminal-e1001
|
||||
@@ -439,6 +439,23 @@ def test_enable_pin_multiple(
|
||||
assert all(pin["mode"]["output"] is True for pin in enable_pins)
|
||||
|
||||
|
||||
def test_uc8179_e1001_code_generation(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Test that the reTerminal E1001 model generates the UC8179 driver and init sequence."""
|
||||
main_cpp = generate_main(component_config_path("uc8179_e1001_test.yaml"))
|
||||
|
||||
# The model must instantiate the UC8179 driver class with the panel dimensions
|
||||
assert "epaper_spi::EPaperUC8179" in main_cpp
|
||||
assert re.search(r'"SEEED-RETERMINAL-E1001",\s*800,\s*480', main_cpp)
|
||||
|
||||
# The generated init sequence must contain the UC8179 resolution setting
|
||||
# for 800x480: command 0x61, 4 data bytes 0x03 0x20 0x01 0xE0
|
||||
# (rendered as decimal in the generated array)
|
||||
assert "97, 4, 3, 32, 1, 224" in main_cpp
|
||||
|
||||
|
||||
def test_enable_pin_code_generation(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
espnow:
|
||||
channel: 1
|
||||
auto_add_peer: true
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
sensor:
|
||||
- platform: internal_temperature
|
||||
name: Internal Temperature
|
||||
@@ -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,13 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
variant: esp32s31
|
||||
board: esp32-s31-devkitc
|
||||
framework:
|
||||
type: esp-idf
|
||||
advanced:
|
||||
execute_from_psram: true
|
||||
|
||||
psram:
|
||||
mode: octal
|
||||
@@ -6,6 +6,8 @@ esp32:
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
mdns:
|
||||
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: 19
|
||||
|
||||
@@ -6,6 +6,8 @@ esp32:
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
mdns:
|
||||
|
||||
wifi:
|
||||
ssid: "test_ssid"
|
||||
password: "test_password"
|
||||
|
||||
@@ -24,6 +24,7 @@ from esphome.components.esp32 import (
|
||||
)
|
||||
from esphome.components.esp32.const import (
|
||||
KEY_ESP32,
|
||||
KEY_EXCLUDE_COMPONENTS,
|
||||
KEY_NETWORK_SDKCONFIG,
|
||||
KEY_SDKCONFIG_OPTIONS,
|
||||
KEY_VARIANT,
|
||||
@@ -202,6 +203,18 @@ def test_esp32_rejects_unsupported_cli_toolchain(
|
||||
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",
|
||||
@@ -298,6 +311,26 @@ def test_esp32_configuration_errors(
|
||||
("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(
|
||||
@@ -309,8 +342,6 @@ def test_default_exclusions_reincluded_by_owning_components(
|
||||
"""Components whose IDF driver is excluded by default must re-include it
|
||||
during codegen; a dropped include_builtin_idf_component() call would only
|
||||
surface as a missing-header failure in a full compile job."""
|
||||
from esphome.components.esp32.const import KEY_EXCLUDE_COMPONENTS
|
||||
|
||||
generate_main(component_config_path(config_file))
|
||||
excluded = CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
|
||||
|
||||
@@ -324,13 +355,20 @@ def test_default_exclusions_reincluded_by_owning_components(
|
||||
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."""
|
||||
from esphome.components.esp32.const import KEY_EXCLUDE_COMPONENTS
|
||||
|
||||
generate_main(component_config_path("exclusion_stays_nvs_sdkconfig_off.yaml"))
|
||||
assert "nvs_sec_provider" in CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
|
||||
|
||||
@@ -396,12 +434,12 @@ 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(
|
||||
@@ -416,6 +454,18 @@ def test_execute_from_psram_p4_sdkconfig(
|
||||
assert "CONFIG_SPIRAM_RODATA" not in sdkconfig
|
||||
|
||||
|
||||
def test_execute_from_psram_s31_sdkconfig(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Test that execute_from_psram on ESP32-S31 sets the correct sdkconfig option."""
|
||||
generate_main(component_config_path("execute_from_psram_s31.yaml"))
|
||||
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
||||
assert sdkconfig.get("CONFIG_SPIRAM_XIP_FROM_PSRAM") is True
|
||||
assert "CONFIG_SPIRAM_FETCH_INSTRUCTIONS" not in sdkconfig
|
||||
assert "CONFIG_SPIRAM_RODATA" not in sdkconfig
|
||||
|
||||
|
||||
def test_nvs_encryption_sdkconfig(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
@@ -939,6 +989,14 @@ def test_network_wifi_only_reconciles_end_to_end(
|
||||
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
||||
assert sdkconfig.get("CONFIG_ESP_WIFI_SOFTAP_SUPPORT") is False
|
||||
assert sdkconfig.get("CONFIG_LWIP_DHCPS") is False
|
||||
# request_wifi() also puts the WiFi components back in the build set;
|
||||
# esp_phy stays excluded, IDF requirement expansion pulls it back.
|
||||
excluded = CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
|
||||
assert "esp_wifi" not in excluded
|
||||
assert "wpa_supplicant" not in excluded
|
||||
assert "esp_phy" in excluded
|
||||
# With wifi present mdns keeps its predefined interfaces.
|
||||
assert "CONFIG_MDNS_PREDEF_NETIF_STA" not in sdkconfig
|
||||
# WiFi stack stays enabled (no ethernet) and no Bluetooth requested.
|
||||
assert "CONFIG_ESP_WIFI_ENABLED" not in sdkconfig
|
||||
assert "CONFIG_BT_ENABLED" not in sdkconfig
|
||||
@@ -954,6 +1012,12 @@ def test_network_ethernet_only_reconciles_end_to_end(
|
||||
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
||||
assert sdkconfig.get("CONFIG_ESP_WIFI_ENABLED") is False
|
||||
assert sdkconfig.get("CONFIG_SW_COEXIST_ENABLE") is False
|
||||
# The whole radio stack stays out of the build set as well.
|
||||
excluded = CORE.data[KEY_ESP32][KEY_EXCLUDE_COMPONENTS]
|
||||
assert {"esp_wifi", "wpa_supplicant", "esp_phy", "esp_coex", "bt"} <= excluded
|
||||
# Without wifi, mdns drops its predefined STA/AP interfaces.
|
||||
assert sdkconfig.get("CONFIG_MDNS_PREDEF_NETIF_STA") is False
|
||||
assert sdkconfig.get("CONFIG_MDNS_PREDEF_NETIF_AP") is False
|
||||
|
||||
|
||||
def test_network_wifi_ble_coexistence_reconciles_end_to_end(
|
||||
@@ -1228,3 +1292,50 @@ def test_parse_pio_platform_version(value: str, expected: str) -> None:
|
||||
from esphome.components.esp32 import _parse_pio_platform_version
|
||||
|
||||
assert _parse_pio_platform_version(value) == expected
|
||||
|
||||
|
||||
def test_esp32_s31_gpio_validation(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""S31: GPIO26-28/30-32 are reserved for the SPI flash interface, GPIO29
|
||||
and GPIO41 do not exist, GPIO33 is a normal pin, and GPIO36 is a
|
||||
strapping pin."""
|
||||
from esphome.components.esp32.const import VARIANT_ESP32S31
|
||||
from esphome.components.esp32.gpio import validate_supports
|
||||
from esphome.const import CONF_INPUT, CONF_MODE, CONF_OPEN_DRAIN, CONF_OUTPUT
|
||||
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF, platform_data={KEY_VARIANT: VARIANT_ESP32S31}
|
||||
)
|
||||
|
||||
input_mode = {CONF_INPUT: True, CONF_OUTPUT: False, CONF_OPEN_DRAIN: False}
|
||||
|
||||
# Not reserved; a normal GPIO
|
||||
pin = {CONF_NUMBER: 33, CONF_IGNORE_PIN_VALIDATION_ERROR: False}
|
||||
assert validate_gpio_pin(pin)[CONF_NUMBER] == 33
|
||||
|
||||
# Reserved for the SPI flash interface, but can be bypassed with
|
||||
# ignore_pin_validation_error
|
||||
for num in (26, 27, 28, 30, 31, 32):
|
||||
with pytest.raises(cv.Invalid, match=f"GPIO{num} is reserved"):
|
||||
validate_gpio_pin(
|
||||
{CONF_NUMBER: num, CONF_IGNORE_PIN_VALIDATION_ERROR: False}
|
||||
)
|
||||
pin = {CONF_NUMBER: num, CONF_IGNORE_PIN_VALIDATION_ERROR: True}
|
||||
assert validate_gpio_pin(pin)[CONF_NUMBER] == num
|
||||
|
||||
for num in (29, 41):
|
||||
with pytest.raises(cv.Invalid, match=f"GPIO{num} does not exist"):
|
||||
validate_gpio_pin(
|
||||
{CONF_NUMBER: num, CONF_IGNORE_PIN_VALIDATION_ERROR: False}
|
||||
)
|
||||
# Also rejected in validate_supports so ignore_pin_validation_error
|
||||
# cannot bypass it
|
||||
with pytest.raises(cv.Invalid, match=f"GPIO{num} does not exist"):
|
||||
validate_supports({CONF_NUMBER: num, CONF_MODE: input_mode})
|
||||
|
||||
pin = {CONF_NUMBER: 36, CONF_MODE: input_mode}
|
||||
with caplog.at_level("WARNING"):
|
||||
validate_supports(pin)
|
||||
assert "GPIO36 is a strapping PIN" in caplog.text
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
"""Tests for the external_components skip-update behavior driven by CORE.skip_external_update."""
|
||||
"""Tests for the external_components config pass."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.external_components import do_external_components_pass
|
||||
from esphome.const import (
|
||||
CONF_EXTERNAL_COMPONENTS,
|
||||
CONF_PATH,
|
||||
CONF_REFRESH,
|
||||
CONF_SOURCE,
|
||||
CONF_URL,
|
||||
TYPE_GIT,
|
||||
TYPE_LOCAL,
|
||||
)
|
||||
from esphome.core import CORE, TimePeriodSeconds
|
||||
|
||||
@@ -69,3 +74,112 @@ def test_external_components_normal_refresh(
|
||||
mock_clone_or_update.assert_called_once()
|
||||
call_args = mock_clone_or_update.call_args
|
||||
assert call_args.kwargs["refresh"] == TimePeriodSeconds(days=1)
|
||||
|
||||
|
||||
def test_external_components_logs_built_in_override(
|
||||
tmp_path: Path,
|
||||
mock_clone_or_update: MagicMock,
|
||||
mock_install_meta_finder: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A source that provides a component with the same name as a built-in one logs an info message."""
|
||||
mock_clone_or_update.return_value = (tmp_path, None)
|
||||
config = _make_config(tmp_path)
|
||||
|
||||
for name in ("gpio", "some_custom_component"):
|
||||
component_dir = tmp_path / "components" / name
|
||||
component_dir.mkdir()
|
||||
(component_dir / "__init__.py").write_text("# Test component")
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
do_external_components_pass(config)
|
||||
|
||||
assert (
|
||||
"External components are overriding built-in components:\n"
|
||||
" source: https://github.com/test/components\n"
|
||||
" components: gpio" in caplog.text
|
||||
)
|
||||
assert "some_custom_component" not in caplog.text
|
||||
|
||||
|
||||
def test_external_components_override_log_includes_ref(
|
||||
tmp_path: Path,
|
||||
mock_clone_or_update: MagicMock,
|
||||
mock_install_meta_finder: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A git source with a ref logs the ref appended to the url."""
|
||||
mock_clone_or_update.return_value = (tmp_path, None)
|
||||
config = _make_config(tmp_path)
|
||||
config[CONF_EXTERNAL_COMPONENTS][0][CONF_SOURCE] = "github://test/components@main"
|
||||
|
||||
component_dir = tmp_path / "components" / "gpio"
|
||||
component_dir.mkdir()
|
||||
(component_dir / "__init__.py").write_text("# Test component")
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
do_external_components_pass(config)
|
||||
|
||||
assert " source: https://github.com/test/components.git@main\n" in caplog.text
|
||||
|
||||
|
||||
def test_external_components_override_log_includes_git_path(
|
||||
tmp_path: Path,
|
||||
mock_clone_or_update: MagicMock,
|
||||
mock_install_meta_finder: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A git source with a subdirectory path logs the path after the url."""
|
||||
mock_clone_or_update.return_value = (tmp_path, None)
|
||||
config = _make_config(tmp_path)
|
||||
config[CONF_EXTERNAL_COMPONENTS][0][CONF_SOURCE][CONF_PATH] = "components"
|
||||
|
||||
component_dir = tmp_path / "components" / "gpio"
|
||||
component_dir.mkdir()
|
||||
(component_dir / "__init__.py").write_text("# Test component")
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
do_external_components_pass(config)
|
||||
|
||||
assert " source: https://github.com/test/components (components)\n" in caplog.text
|
||||
|
||||
|
||||
def test_external_components_override_log_local_source(
|
||||
tmp_path: Path,
|
||||
mock_install_meta_finder: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A local source logs its resolved path."""
|
||||
components_dir = tmp_path / "my_components"
|
||||
gpio_dir = components_dir / "gpio"
|
||||
gpio_dir.mkdir(parents=True)
|
||||
(gpio_dir / "__init__.py").write_text("# Test component")
|
||||
|
||||
CORE.config_path = tmp_path / "dummy.yaml"
|
||||
config = {
|
||||
CONF_EXTERNAL_COMPONENTS: [
|
||||
{CONF_SOURCE: {"type": TYPE_LOCAL, CONF_PATH: "my_components"}}
|
||||
]
|
||||
}
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
do_external_components_pass(config)
|
||||
|
||||
assert f" source: {components_dir}\n" in caplog.text
|
||||
assert " components: gpio" in caplog.text
|
||||
|
||||
|
||||
def test_external_components_no_override_no_log(
|
||||
tmp_path: Path,
|
||||
mock_clone_or_update: MagicMock,
|
||||
mock_install_meta_finder: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A source that only provides components not shipped with ESPHome logs nothing."""
|
||||
mock_clone_or_update.return_value = (tmp_path, None)
|
||||
config = _make_config(tmp_path)
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
do_external_components_pass(config)
|
||||
|
||||
assert "are overriding built-in components" not in caplog.text
|
||||
|
||||
@@ -10,7 +10,13 @@ from esphome import config_validation as cv
|
||||
# via ch422g) can be validated by the mipi_rgb CONFIG_SCHEMA in this test.
|
||||
import esphome.components.ch422g # noqa: F401
|
||||
from esphome.components.display import get_display_metadata
|
||||
from esphome.components.esp32 import KEY_BOARD, VARIANT_ESP32S3
|
||||
from esphome.components.esp32 import (
|
||||
KEY_BOARD,
|
||||
VARIANT_ESP32C3,
|
||||
VARIANT_ESP32P4,
|
||||
VARIANT_ESP32S3,
|
||||
VARIANT_ESP32S31,
|
||||
)
|
||||
import esphome.components.pca9554 # noqa: F401
|
||||
import esphome.components.xl9535 # noqa: F401
|
||||
from esphome.const import (
|
||||
@@ -135,3 +141,63 @@ def test_metadata_records_rotation(
|
||||
|
||||
config = CONFIG_SCHEMA({**base, "id": "unrotated"})
|
||||
assert get_display_metadata(config["id"]).rotation == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("variant", "board", "model"),
|
||||
[
|
||||
# ESP32-8048S070 is a real Sunton board wired for ESP32-S3 (e.g. its
|
||||
# default de_pin is GPIO41, which doesn't exist on S31), so it is
|
||||
# only meaningful as a config on that variant.
|
||||
(VARIANT_ESP32S3, "esp32-s3-devkitc-1", "ESP32-8048S070"),
|
||||
# P4 and S31 use the pin-agnostic CUSTOM model so this only checks
|
||||
# that the chip itself is accepted, independent of board wiring.
|
||||
(VARIANT_ESP32P4, "esp32-p4-evboard", "CUSTOM"),
|
||||
# No dedicated board is registered for ESP32-S31 yet; an unknown board
|
||||
# name simply skips per-board pin validation.
|
||||
(VARIANT_ESP32S31, "esp32-s31-devkitc", "CUSTOM"),
|
||||
],
|
||||
)
|
||||
def test_configuration_succeeds_on_supported_variants(
|
||||
variant: str, board: str, model: str, set_core_config: SetCoreConfigCallable
|
||||
) -> None:
|
||||
"""mipi_rgb requires a chip with an RGB LCD peripheral: S3, P4 or S31."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: board, KEY_VARIANT: variant},
|
||||
)
|
||||
|
||||
from esphome.components.mipi_rgb.display import CONFIG_SCHEMA
|
||||
|
||||
config = {"model": model, "data_pins": DATA_PINS, "pclk_pin": 21}
|
||||
if model == "CUSTOM":
|
||||
config[CONF_INIT_SEQUENCE] = [[0xA0, 0x01]]
|
||||
config[CONF_DIMENSIONS] = {CONF_WIDTH: 480, CONF_HEIGHT: 480}
|
||||
CONFIG_SCHEMA(config)
|
||||
|
||||
|
||||
def test_only_on_variant_rejects_unsupported_variant(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""A variant without the RGB LCD peripheral (e.g. ESP32-C3) is rejected.
|
||||
|
||||
Exercises the exact ``only_on_variant`` call used by ``mipi_rgb.display``
|
||||
directly, since building a full model config with GPIO numbers that are
|
||||
also valid on an unsupported variant like ESP32-C3 is unrelated to what
|
||||
this checks.
|
||||
"""
|
||||
from esphome.components.esp32 import only_on_variant
|
||||
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_VARIANT: VARIANT_ESP32C3},
|
||||
)
|
||||
|
||||
validator = only_on_variant(
|
||||
supported=[VARIANT_ESP32S3, VARIANT_ESP32P4, VARIANT_ESP32S31]
|
||||
)
|
||||
with pytest.raises(
|
||||
cv.Invalid,
|
||||
match=r"This feature is only available on ESP32S3, ESP32P4, ESP32S31",
|
||||
):
|
||||
validator({})
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Config validation for the byte offset on holding-register write entities.
|
||||
|
||||
A 16-bit register write cannot target half a register, so an odd offset (or byte_offset) is
|
||||
rejected for holding-register switches and outputs; even offsets and coil offsets pass.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from voluptuous import Invalid, MultipleInvalid
|
||||
|
||||
from esphome.components.modbus_controller.output import (
|
||||
CONFIG_SCHEMA as OUTPUT_CONFIG_SCHEMA,
|
||||
)
|
||||
from esphome.components.modbus_controller.switch import (
|
||||
CONFIG_SCHEMA as SWITCH_CONFIG_SCHEMA,
|
||||
)
|
||||
from esphome.const import CONF_ADDRESS, CONF_ID, CONF_NAME, CONF_OFFSET
|
||||
|
||||
|
||||
def _switch_config(register_type: str, offset: int) -> dict:
|
||||
return {
|
||||
CONF_NAME: "test switch",
|
||||
CONF_ADDRESS: 0x10,
|
||||
"register_type": register_type,
|
||||
CONF_OFFSET: offset,
|
||||
}
|
||||
|
||||
|
||||
def _output_config(register_type: str, offset: int) -> dict:
|
||||
return {
|
||||
CONF_ID: "test_output",
|
||||
CONF_ADDRESS: 0x10,
|
||||
"register_type": register_type,
|
||||
CONF_OFFSET: offset,
|
||||
}
|
||||
|
||||
|
||||
def test_odd_offset_on_holding_switch_rejected() -> None:
|
||||
with pytest.raises((Invalid, MultipleInvalid), match="odd"):
|
||||
SWITCH_CONFIG_SCHEMA(_switch_config("holding", 3))
|
||||
|
||||
|
||||
def test_even_offset_on_holding_switch_accepted() -> None:
|
||||
config = SWITCH_CONFIG_SCHEMA(_switch_config("holding", 2))
|
||||
assert config[CONF_OFFSET] == 2
|
||||
|
||||
|
||||
def test_odd_offset_on_coil_switch_accepted() -> None:
|
||||
"""A coil offset is a coil count, so odd values are fine."""
|
||||
config = SWITCH_CONFIG_SCHEMA(_switch_config("coil", 3))
|
||||
assert config[CONF_OFFSET] == 3
|
||||
|
||||
|
||||
def test_odd_byte_offset_on_holding_switch_rejected() -> None:
|
||||
"""byte_offset is the alias the validator must also catch."""
|
||||
config = _switch_config("holding", 0)
|
||||
del config[CONF_OFFSET]
|
||||
config["byte_offset"] = 3
|
||||
with pytest.raises((Invalid, MultipleInvalid), match="byte_offset"):
|
||||
SWITCH_CONFIG_SCHEMA(config)
|
||||
|
||||
|
||||
def test_odd_offset_on_holding_output_rejected() -> None:
|
||||
with pytest.raises((Invalid, MultipleInvalid), match="odd"):
|
||||
OUTPUT_CONFIG_SCHEMA(_output_config("holding", 3))
|
||||
|
||||
|
||||
def test_even_offset_on_holding_output_accepted() -> None:
|
||||
config = OUTPUT_CONFIG_SCHEMA(_output_config("holding", 2))
|
||||
assert config[CONF_OFFSET] == 2
|
||||
|
||||
|
||||
def test_odd_offset_on_coil_output_accepted() -> None:
|
||||
config = OUTPUT_CONFIG_SCHEMA(_output_config("coil", 3))
|
||||
assert config[CONF_OFFSET] == 3
|
||||
@@ -5,7 +5,11 @@ from __future__ import annotations
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.noise import decode_encryption_key, validate_encryption_key
|
||||
from esphome.components.noise import (
|
||||
decode_encryption_key,
|
||||
is_reserved_key,
|
||||
validate_encryption_key,
|
||||
)
|
||||
|
||||
KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
|
||||
@@ -35,3 +39,8 @@ def test_decode_encryption_key_rejects_short_decode() -> None:
|
||||
a zero padded PSK on the device."""
|
||||
with pytest.raises(cv.Invalid, match="32 bytes"):
|
||||
decode_encryption_key("AAECAw==")
|
||||
|
||||
|
||||
def test_is_reserved_key() -> None:
|
||||
assert is_reserved_key("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
|
||||
assert not is_reserved_key(KEY)
|
||||
|
||||
@@ -8,17 +8,25 @@ from typing import Any
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.esphome.ota import ota_esphome_final_validate
|
||||
from esphome.components.esphome.ota import (
|
||||
AUTO_LOAD,
|
||||
FILTER_SOURCE_FILES,
|
||||
_validate_no_password_with_encryption,
|
||||
ota_esphome_final_validate,
|
||||
)
|
||||
from esphome.const import (
|
||||
CONF_API,
|
||||
CONF_ENCRYPTION,
|
||||
CONF_ESPHOME,
|
||||
CONF_ID,
|
||||
CONF_KEY,
|
||||
CONF_OTA,
|
||||
CONF_PASSWORD,
|
||||
CONF_PLATFORM,
|
||||
CONF_PORT,
|
||||
CONF_VERSION,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.core import CORE, ID
|
||||
import esphome.final_validate as fv
|
||||
|
||||
|
||||
@@ -103,3 +111,305 @@ def test_non_esphome_ota_unaffected() -> None:
|
||||
assert len(updated[CONF_OTA]) == 3
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
API_KEY = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
OTHER_KEY = "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA="
|
||||
ZEROS_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
|
||||
|
||||
def test_encryption_key_inherited_from_api() -> None:
|
||||
"""A bare encryption block resolves to the api encryption key."""
|
||||
full_conf = {
|
||||
CONF_API: {CONF_ENCRYPTION: {CONF_KEY: API_KEY}},
|
||||
CONF_OTA: [_make_ota_config(port=3232, **{CONF_ENCRYPTION: {}})],
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
ota_esphome_final_validate({})
|
||||
updated = fv.full_config.get()
|
||||
assert updated[CONF_OTA][0][CONF_ENCRYPTION][CONF_KEY] == API_KEY
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_encryption_explicit_key_matching_api_accepted() -> None:
|
||||
"""An explicit ota key equal to the api key validates."""
|
||||
full_conf = {
|
||||
CONF_API: {CONF_ENCRYPTION: {CONF_KEY: API_KEY}},
|
||||
CONF_OTA: [
|
||||
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: API_KEY}})
|
||||
],
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
ota_esphome_final_validate({})
|
||||
updated = fv.full_config.get()
|
||||
assert updated[CONF_OTA][0][CONF_ENCRYPTION][CONF_KEY] == API_KEY
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_encryption_key_differing_from_api_rejected() -> None:
|
||||
"""There is one key per device; an ota key differing from the api key raises."""
|
||||
full_conf = {
|
||||
CONF_API: {CONF_ENCRYPTION: {CONF_KEY: API_KEY}},
|
||||
CONF_OTA: [
|
||||
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}})
|
||||
],
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
with pytest.raises(cv.Invalid, match="must match the 'api' encryption key"):
|
||||
ota_esphome_final_validate({})
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_encryption_explicit_key_without_api_encryption_accepted() -> None:
|
||||
"""An explicit ota key with a plaintext api has nothing to match; it stands."""
|
||||
full_conf = {
|
||||
CONF_API: {},
|
||||
CONF_OTA: [
|
||||
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}})
|
||||
],
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
ota_esphome_final_validate({})
|
||||
updated = fv.full_config.get()
|
||||
assert updated[CONF_OTA][0][CONF_ENCRYPTION][CONF_KEY] == OTHER_KEY
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_encryption_without_any_key_rejected() -> None:
|
||||
"""A bare encryption block with no api key to inherit raises."""
|
||||
full_conf = {
|
||||
CONF_API: {},
|
||||
CONF_OTA: [_make_ota_config(port=3232, **{CONF_ENCRYPTION: {}})],
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
with pytest.raises(cv.Invalid, match="no 'api' encryption key to inherit"):
|
||||
ota_esphome_final_validate({})
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_encryption_explicit_all_zeros_key_rejected() -> None:
|
||||
"""The all-zeros key is the provisioning sentinel; the device would treat
|
||||
it as no PSK and accept plaintext, so it must fail validation."""
|
||||
full_conf = {
|
||||
CONF_OTA: [
|
||||
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: ZEROS_KEY}})
|
||||
],
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
with pytest.raises(cv.Invalid, match="all-zeros key is reserved"):
|
||||
ota_esphome_final_validate({})
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_encryption_inherited_all_zeros_key_rejected() -> None:
|
||||
"""An all-zeros api key must not silently disable ota encryption either."""
|
||||
full_conf = {
|
||||
CONF_API: {CONF_ENCRYPTION: {CONF_KEY: ZEROS_KEY}},
|
||||
CONF_OTA: [_make_ota_config(port=3232, **{CONF_ENCRYPTION: {}})],
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
with pytest.raises(cv.Invalid, match="all-zeros key is reserved"):
|
||||
ota_esphome_final_validate({})
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_encryption_key_mismatch_between_merged_configs_rejected() -> None:
|
||||
"""Same-port configs with different encryption keys raise."""
|
||||
full_conf = {
|
||||
CONF_OTA: [
|
||||
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: API_KEY}}),
|
||||
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}),
|
||||
]
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
with pytest.raises(cv.Invalid, match="encryption is inconsistent"):
|
||||
ota_esphome_final_validate({})
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("keyed_first", [True, False])
|
||||
def test_encryption_bare_and_keyed_blocks_merge(keyed_first: bool) -> None:
|
||||
"""A bare encryption block (package/device split) is compatible with a
|
||||
keyed one on the same port; the merge resolves to the keyed result."""
|
||||
keyed = _make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}})
|
||||
bare = _make_ota_config(port=3232, **{CONF_ENCRYPTION: {}})
|
||||
full_conf = {
|
||||
CONF_OTA: [keyed, bare] if keyed_first else [bare, keyed],
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
ota_esphome_final_validate({})
|
||||
updated = fv.full_config.get()
|
||||
assert len(updated[CONF_OTA]) == 1
|
||||
assert updated[CONF_OTA][0][CONF_ENCRYPTION][CONF_KEY] == OTHER_KEY
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_encryption_runtime_provisioned_api_key_not_inheritable() -> None:
|
||||
"""A keyless api encryption block provisions its key at runtime; a bare
|
||||
ota encryption block cannot inherit it and the message says so."""
|
||||
full_conf = {
|
||||
CONF_API: {CONF_ENCRYPTION: {}},
|
||||
CONF_OTA: [_make_ota_config(port=3232, **{CONF_ENCRYPTION: {}})],
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
with pytest.raises(cv.Invalid, match="provisioned at runtime"):
|
||||
ota_esphome_final_validate({})
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_encryption_explicit_key_with_runtime_provisioned_api_accepted() -> None:
|
||||
"""The documented remedy for a runtime-provisioned api key: set an
|
||||
explicit ota key."""
|
||||
full_conf = {
|
||||
CONF_API: {CONF_ENCRYPTION: {}},
|
||||
CONF_OTA: [
|
||||
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}})
|
||||
],
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
ota_esphome_final_validate({})
|
||||
updated = fv.full_config.get()
|
||||
assert updated[CONF_OTA][0][CONF_ENCRYPTION][CONF_KEY] == OTHER_KEY
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_encryption_with_web_server_ota_warns(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""With the web_server component the plaintext /update endpoint is always
|
||||
on; the combination validates with a warning."""
|
||||
full_conf = {
|
||||
"web_server": {},
|
||||
CONF_OTA: [
|
||||
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}),
|
||||
{CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)},
|
||||
],
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
ota_esphome_final_validate({})
|
||||
assert any("plaintext /update" in record.message for record in caplog.records)
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_encryption_with_captive_portal_web_server_ota_warns(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""captive_portal auto-loads the web_server ota platform without the
|
||||
web_server component; encryption stays usable and only warns, so the
|
||||
fallback AP recovery path is not lost."""
|
||||
full_conf = {
|
||||
"captive_portal": {},
|
||||
CONF_OTA: [
|
||||
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: OTHER_KEY}}),
|
||||
{CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)},
|
||||
],
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
ota_esphome_final_validate({})
|
||||
assert any("captive_portal" in record.message for record in caplog.records)
|
||||
esphome_conf = next(
|
||||
conf
|
||||
for conf in fv.full_config.get()[CONF_OTA]
|
||||
if conf.get(CONF_PLATFORM) == CONF_ESPHOME
|
||||
)
|
||||
assert esphome_conf[CONF_ENCRYPTION][CONF_KEY] == OTHER_KEY
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_web_server_ota_without_encryption_unaffected() -> None:
|
||||
"""web_server ota stays valid alongside an unencrypted esphome entry."""
|
||||
full_conf = {
|
||||
CONF_OTA: [
|
||||
_make_ota_config(port=3232),
|
||||
{CONF_PLATFORM: "web_server", CONF_ID: ID("ota_ws", is_manual=False)},
|
||||
],
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
ota_esphome_final_validate({})
|
||||
assert len(fv.full_config.get()[CONF_OTA]) == 2
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
|
||||
def test_auto_load_pulls_noise_only_for_encryption() -> None:
|
||||
"""A plain ota entry must never pull noise-c into the build."""
|
||||
assert AUTO_LOAD({CONF_PORT: 3232}) == ["sha256", "socket"]
|
||||
assert "noise" in AUTO_LOAD({CONF_ENCRYPTION: {}})
|
||||
# Tooling probes must get the maximal set: None from dependency
|
||||
# resolution, {} from the components-graph platform probe
|
||||
assert "noise" in AUTO_LOAD(None)
|
||||
assert "noise" in AUTO_LOAD({})
|
||||
|
||||
|
||||
def test_filter_source_files_excludes_noise_without_encryption() -> None:
|
||||
"""The noise transport source compiles only for encrypted builds."""
|
||||
old_config = CORE.config
|
||||
try:
|
||||
CORE.config = {CONF_OTA: [_make_ota_config(port=3232)]}
|
||||
assert FILTER_SOURCE_FILES() == ["ota_esphome_noise.cpp"]
|
||||
CORE.config = {
|
||||
CONF_OTA: [
|
||||
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: API_KEY}})
|
||||
]
|
||||
}
|
||||
assert FILTER_SOURCE_FILES() == []
|
||||
finally:
|
||||
CORE.config = old_config
|
||||
|
||||
|
||||
def test_password_with_encryption_rejected() -> None:
|
||||
"""The password and encryption options are mutually exclusive."""
|
||||
config = {CONF_PASSWORD: "pw", CONF_ENCRYPTION: {CONF_KEY: API_KEY}}
|
||||
with pytest.raises(cv.Invalid, match="cannot be combined"):
|
||||
_validate_no_password_with_encryption(config)
|
||||
|
||||
|
||||
def test_password_alone_accepted() -> None:
|
||||
"""A password without encryption still validates."""
|
||||
config = {CONF_PASSWORD: "pw"}
|
||||
assert _validate_no_password_with_encryption(config) is config
|
||||
|
||||
|
||||
def test_merged_password_and_encryption_rejected() -> None:
|
||||
"""A password block and an encryption block merged on one port raise."""
|
||||
full_conf = {
|
||||
CONF_OTA: [
|
||||
_make_ota_config(port=3232, **{CONF_PASSWORD: "pw"}),
|
||||
_make_ota_config(port=3232, **{CONF_ENCRYPTION: {CONF_KEY: API_KEY}}),
|
||||
]
|
||||
}
|
||||
token = fv.full_config.set(full_conf)
|
||||
try:
|
||||
with pytest.raises(cv.Invalid, match="cannot be combined"):
|
||||
ota_esphome_final_validate({})
|
||||
finally:
|
||||
fv.full_config.reset(token)
|
||||
|
||||
@@ -11,6 +11,7 @@ from esphome.components.provisioning import (
|
||||
CONFIG_SCHEMA,
|
||||
FINAL_VALIDATE_SCHEMA,
|
||||
register_source,
|
||||
report_ap_without_sta,
|
||||
report_hardcoded_credentials,
|
||||
)
|
||||
from esphome.const import CONF_TIMEOUT, PlatformFramework
|
||||
@@ -66,6 +67,32 @@ def test_provisioning_no_warning_without_hardcoded_credentials(
|
||||
assert "credentials" not in caplog.text
|
||||
|
||||
|
||||
def test_provisioning_warns_on_ap_without_sta(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""An access point with no station credentials triggers a reachability warning."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
register_source("network")
|
||||
report_ap_without_sta()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
FINAL_VALIDATE_SCHEMA({})
|
||||
assert "access point" in caplog.text
|
||||
assert "unreachable" in caplog.text
|
||||
|
||||
|
||||
def test_provisioning_no_warning_without_ap(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""No reachability warning when no AP-without-station setup is reported."""
|
||||
set_core_config(PlatformFramework.ESP32_IDF)
|
||||
register_source("network")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
FINAL_VALIDATE_SCHEMA({})
|
||||
assert "access point" not in caplog.text
|
||||
|
||||
|
||||
def test_provisioning_rejects_zero_timeout(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Tests for SPI PSRAM DMA configuration validation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.esp32 import (
|
||||
KEY_BOARD,
|
||||
KEY_VARIANT,
|
||||
VARIANT_ESP32,
|
||||
VARIANT_ESP32S3,
|
||||
VARIANT_ESP32S31,
|
||||
)
|
||||
from esphome.components.spi import (
|
||||
CONF_INTERFACE_INDEX,
|
||||
CONF_PSRAM_DMA,
|
||||
_final_validate,
|
||||
spi_device_schema,
|
||||
)
|
||||
from esphome.config import Config
|
||||
from esphome.const import CONF_ID, CONF_SPI_ID, KEY_FRAMEWORK_VERSION, PlatformFramework
|
||||
from esphome.core import CORE, ID
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
|
||||
def _schema() -> cv.Schema:
|
||||
return spi_device_schema(
|
||||
cs_pin_required=False,
|
||||
default_data_rate="1MHz",
|
||||
default_mode="MODE0",
|
||||
)
|
||||
|
||||
|
||||
def _stage(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
platform_framework: PlatformFramework,
|
||||
variant: str,
|
||||
version: cv.Version,
|
||||
) -> None:
|
||||
set_core_config(
|
||||
platform_framework,
|
||||
core_data={KEY_FRAMEWORK_VERSION: version},
|
||||
platform_data={KEY_BOARD: "test-board", KEY_VARIANT: variant},
|
||||
)
|
||||
CORE.loaded_integrations.add("psram")
|
||||
|
||||
|
||||
def test_psram_dma_accepts_supported_idf_target(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
_stage(
|
||||
set_core_config,
|
||||
PlatformFramework.ESP32_IDF,
|
||||
VARIANT_ESP32S3,
|
||||
cv.Version(5, 5, 3),
|
||||
)
|
||||
|
||||
config = _schema()({CONF_PSRAM_DMA: True})
|
||||
|
||||
assert config[CONF_PSRAM_DMA] is True
|
||||
|
||||
|
||||
def test_psram_dma_accepts_esp32s31(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
_stage(
|
||||
set_core_config,
|
||||
PlatformFramework.ESP32_IDF,
|
||||
VARIANT_ESP32S31,
|
||||
cv.Version(6, 0, 0),
|
||||
)
|
||||
|
||||
config = _schema()({CONF_PSRAM_DMA: True})
|
||||
|
||||
assert config[CONF_PSRAM_DMA] is True
|
||||
|
||||
|
||||
def test_psram_dma_rejects_arduino(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
_stage(
|
||||
set_core_config,
|
||||
PlatformFramework.ESP32_ARDUINO,
|
||||
VARIANT_ESP32S3,
|
||||
cv.Version(5, 5, 3),
|
||||
)
|
||||
|
||||
with pytest.raises(cv.Invalid, match="only available with framework"):
|
||||
_schema()({CONF_PSRAM_DMA: True})
|
||||
|
||||
|
||||
def test_psram_dma_rejects_target_without_capability(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
_stage(
|
||||
set_core_config,
|
||||
PlatformFramework.ESP32_IDF,
|
||||
VARIANT_ESP32,
|
||||
cv.Version(5, 5, 3),
|
||||
)
|
||||
|
||||
with pytest.raises(cv.Invalid, match="PSRAM DMA is only available"):
|
||||
_schema()({CONF_PSRAM_DMA: True})
|
||||
|
||||
|
||||
def test_psram_dma_false_is_portable(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
_stage(
|
||||
set_core_config,
|
||||
PlatformFramework.ESP32_ARDUINO,
|
||||
VARIANT_ESP32,
|
||||
cv.Version(5, 5, 2),
|
||||
)
|
||||
|
||||
config = _schema()({CONF_PSRAM_DMA: False})
|
||||
|
||||
assert config[CONF_PSRAM_DMA] is False
|
||||
|
||||
|
||||
def test_psram_dma_rejects_older_idf(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
_stage(
|
||||
set_core_config,
|
||||
PlatformFramework.ESP32_IDF,
|
||||
VARIANT_ESP32S3,
|
||||
cv.Version(5, 5, 2),
|
||||
)
|
||||
|
||||
with pytest.raises(cv.Invalid, match="requires at least framework version 5.5.3"):
|
||||
_schema()({CONF_PSRAM_DMA: True})
|
||||
|
||||
|
||||
def test_psram_dma_requires_psram_component(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
_stage(
|
||||
set_core_config,
|
||||
PlatformFramework.ESP32_IDF,
|
||||
VARIANT_ESP32S3,
|
||||
cv.Version(5, 5, 3),
|
||||
)
|
||||
CORE.loaded_integrations.remove("psram")
|
||||
|
||||
with pytest.raises(cv.Invalid, match="requires component psram"):
|
||||
_schema()({CONF_PSRAM_DMA: True})
|
||||
|
||||
|
||||
def _full_spi_config(*, hardware: bool, with_device: bool = True) -> tuple[Config, ID]:
|
||||
bus_id = ID("spi_bus", is_declaration=True, type="SPIComponent")
|
||||
bus = {CONF_ID: bus_id}
|
||||
if hardware:
|
||||
bus[CONF_INTERFACE_INDEX] = 0
|
||||
full = Config()
|
||||
full["spi"] = [bus]
|
||||
if with_device:
|
||||
full["spi_device_test"] = {
|
||||
CONF_SPI_ID: ID("spi_bus"),
|
||||
CONF_PSRAM_DMA: True,
|
||||
}
|
||||
full.declare_ids.append((bus_id, ["spi", 0, CONF_ID]))
|
||||
return full, ID("spi_bus", is_declaration=False, type="SPIComponent")
|
||||
|
||||
|
||||
def test_psram_dma_accepts_hardware_spi(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
full_config, _ = _full_spi_config(hardware=True)
|
||||
set_core_config(PlatformFramework.ESP32_IDF, full_config=full_config)
|
||||
|
||||
_final_validate(full_config["spi"])
|
||||
|
||||
|
||||
def test_psram_dma_rejects_software_spi(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
full_config, _ = _full_spi_config(hardware=False)
|
||||
set_core_config(PlatformFramework.ESP32_IDF, full_config=full_config)
|
||||
|
||||
with pytest.raises(cv.Invalid, match="psram_dma requires a hardware SPI") as error:
|
||||
_final_validate(full_config["spi"])
|
||||
|
||||
assert error.value.path[-2:] == ["spi_device_test", CONF_PSRAM_DMA]
|
||||
|
||||
|
||||
def test_spi_bus_rejects_psram_dma_device_without_component_final_validation(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
full_config, bus_id = _full_spi_config(hardware=False, with_device=False)
|
||||
full_config["device_without_final_validation"] = {
|
||||
CONF_SPI_ID: bus_id,
|
||||
CONF_PSRAM_DMA: True,
|
||||
}
|
||||
set_core_config(PlatformFramework.ESP32_IDF, full_config=full_config)
|
||||
|
||||
with pytest.raises(cv.Invalid, match="psram_dma requires a hardware SPI") as error:
|
||||
_final_validate(full_config["spi"])
|
||||
|
||||
assert error.value.path[-2:] == [
|
||||
"device_without_final_validation",
|
||||
CONF_PSRAM_DMA,
|
||||
]
|
||||
|
||||
|
||||
def test_psram_dma_accepts_hardware_device_with_mixed_buses(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
software_bus_id = ID("software_bus", is_declaration=True, type="SPIComponent")
|
||||
hardware_bus_id = ID("hardware_bus", is_declaration=True, type="SPIComponent")
|
||||
full_config = Config()
|
||||
full_config["spi"] = [
|
||||
{CONF_ID: software_bus_id},
|
||||
{CONF_ID: hardware_bus_id, CONF_INTERFACE_INDEX: 0},
|
||||
]
|
||||
full_config["spi_device_test"] = {
|
||||
CONF_SPI_ID: ID("hardware_bus"),
|
||||
CONF_PSRAM_DMA: True,
|
||||
}
|
||||
full_config.declare_ids.extend(
|
||||
(
|
||||
(software_bus_id, ["spi", 0, CONF_ID]),
|
||||
(hardware_bus_id, ["spi", 1, CONF_ID]),
|
||||
)
|
||||
)
|
||||
set_core_config(PlatformFramework.ESP32_IDF, full_config=full_config)
|
||||
|
||||
_final_validate(full_config["spi"])
|
||||
@@ -61,8 +61,12 @@ api:
|
||||
reboot_timeout: 0min
|
||||
actions:
|
||||
- action: hello_world
|
||||
description: Log a greeting
|
||||
variables:
|
||||
name: string
|
||||
name:
|
||||
type: string
|
||||
description: Name to greet
|
||||
example: World
|
||||
then:
|
||||
- logger.log:
|
||||
format: Hello World %s!
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<<: !include common-base.yaml
|
||||
packages:
|
||||
base: !include common-base.yaml
|
||||
|
||||
api:
|
||||
encryption:
|
||||
|
||||
@@ -54,7 +54,7 @@ static void verify_mac(uint64_t mac, size_t expected_bytes) {
|
||||
size_t ref_len = reference_encode(mac, ref_buf);
|
||||
|
||||
APIBuffer api_buf;
|
||||
api_buf.resize(16);
|
||||
ASSERT_TRUE(api_buf.resize(16));
|
||||
uint8_t *pos = api_buf.data();
|
||||
#ifdef ESPHOME_DEBUG_API
|
||||
uint8_t *proto_debug_end_ = api_buf.data() + api_buf.size();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
|
||||
#include "esphome/core/alloc_helpers.h"
|
||||
@@ -280,4 +281,71 @@ TEST(Base64, Rfc4648Vectors) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- step_to_accuracy_decimals() ---
|
||||
|
||||
TEST(StepToAccuracyDecimals, TypicalSteps) {
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.001f), 3);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.005f), 3);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.01f), 2);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.025f), 3);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.05f), 2);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.1f), 1);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.25f), 2);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.5f), 1);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(1.5f), 1);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(2.5f), 1);
|
||||
}
|
||||
|
||||
TEST(StepToAccuracyDecimals, WholeSteps) {
|
||||
EXPECT_EQ(step_to_accuracy_decimals(1.0f), 0);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(2.0f), 0);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(5.0f), 0);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(10.0f), 0);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(100.0f), 0);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(1000.0f), 0);
|
||||
}
|
||||
|
||||
TEST(StepToAccuracyDecimals, FiveSignificantDigits) {
|
||||
EXPECT_EQ(step_to_accuracy_decimals(1.23456f), 4);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(12.345f), 3);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(123.45f), 2);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(1234.5f), 1);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(12345.0f), 0);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.33333f), 5);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.0001f), 4);
|
||||
}
|
||||
|
||||
TEST(StepToAccuracyDecimals, TrailingZerosDropped) {
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.3f), 1);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.7f), 1);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.125f), 3);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.0625f), 4);
|
||||
}
|
||||
|
||||
TEST(StepToAccuracyDecimals, RoundsUpToWholeNumber) {
|
||||
// Rounds to five significant digits first, so this becomes 10 with no decimals.
|
||||
EXPECT_EQ(step_to_accuracy_decimals(9.999999f), 0);
|
||||
}
|
||||
|
||||
TEST(StepToAccuracyDecimals, OutsideFixedNotationRange) {
|
||||
// %.5g would print these in exponent form; the count is now the real one rather than a parse of "1e-05".
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.00001f), 5);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.000125f), 6);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(123456.0f), 0);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(1000000.0f), 0);
|
||||
}
|
||||
|
||||
TEST(StepToAccuracyDecimals, SignIgnored) {
|
||||
EXPECT_EQ(step_to_accuracy_decimals(-0.1f), 1);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(-0.25f), 2);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(-1.0f), 0);
|
||||
}
|
||||
|
||||
TEST(StepToAccuracyDecimals, NonFiniteAndZero) {
|
||||
EXPECT_EQ(step_to_accuracy_decimals(0.0f), 0);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(NAN), 0);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(INFINITY), 0);
|
||||
EXPECT_EQ(step_to_accuracy_decimals(-INFINITY), 0);
|
||||
}
|
||||
|
||||
} // namespace esphome::core::testing
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
sensor:
|
||||
- platform: d01
|
||||
name: D01 PM2.5 Concentration
|
||||
@@ -0,0 +1,7 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO4
|
||||
rx_pin: GPIO5
|
||||
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
|
||||
d01: !include common.yaml
|
||||
@@ -0,0 +1,7 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO0
|
||||
rx_pin: GPIO2
|
||||
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml
|
||||
d01: !include common.yaml
|
||||
@@ -0,0 +1,7 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO4
|
||||
rx_pin: GPIO5
|
||||
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml
|
||||
d01: !include common.yaml
|
||||
@@ -0,0 +1,3 @@
|
||||
sensor:
|
||||
- platform: ds1603l
|
||||
name: ds1603l Distance
|
||||
@@ -0,0 +1,7 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO1
|
||||
rx_pin: GPIO3
|
||||
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
|
||||
ds1603l: !include common.yaml
|
||||
@@ -0,0 +1,7 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO0
|
||||
rx_pin: GPIO2
|
||||
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml
|
||||
ds1603l: !include common.yaml
|
||||
@@ -255,3 +255,45 @@ display:
|
||||
it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE);
|
||||
it.circle(it.get_width() / 2, it.get_height() / 2, 100, Color::BLACK);
|
||||
it.circle(it.get_width() / 2, it.get_height() / 2, 60, Color(255, 0, 0));
|
||||
|
||||
# Waveshare 7.5" V2 mono (800x480, UC8179 controller, EPD_7in5_V2)
|
||||
# full_update_every > 1 exercises the fast/partial refresh paths
|
||||
- platform: epaper_spi
|
||||
spi_id: spi_bus
|
||||
model: waveshare-7.5in-v2
|
||||
full_update_every: 4
|
||||
cs_pin:
|
||||
allow_other_uses: true
|
||||
number: GPIO5
|
||||
dc_pin:
|
||||
allow_other_uses: true
|
||||
number: GPIO17
|
||||
reset_pin:
|
||||
allow_other_uses: true
|
||||
number: GPIO16
|
||||
busy_pin:
|
||||
allow_other_uses: true
|
||||
number: GPIO4
|
||||
inverted: true
|
||||
lambda: |-
|
||||
it.filled_rectangle(0, 0, it.get_width(), it.get_height(), Color::WHITE);
|
||||
it.circle(it.get_width() / 2, it.get_height() / 2, 100, Color::BLACK);
|
||||
|
||||
# Seeed reTerminal E1001 - 7.5" mono e-paper (800x480, UC8179)
|
||||
# Pins overridden to avoid conflicts with the E1002 defaults above
|
||||
- platform: epaper_spi
|
||||
spi_id: spi_bus
|
||||
model: seeed-reterminal-e1001
|
||||
cs_pin:
|
||||
allow_other_uses: true
|
||||
number: GPIO5
|
||||
dc_pin:
|
||||
allow_other_uses: true
|
||||
number: GPIO17
|
||||
reset_pin:
|
||||
allow_other_uses: true
|
||||
number: GPIO16
|
||||
busy_pin:
|
||||
allow_other_uses: true
|
||||
number: GPIO4
|
||||
inverted: true
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# The builder test compares against the Improv library's build_rpc_response,
|
||||
# so the library must be part of the unit test build.
|
||||
# Keep the version in sync with the pin in esphome/components/improv_base/__init__.py.
|
||||
esphome:
|
||||
libraries:
|
||||
- improv/Improv@1.2.7
|
||||
@@ -0,0 +1,102 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <improv.h>
|
||||
|
||||
namespace esphome::improv_base::testing {
|
||||
|
||||
namespace {
|
||||
|
||||
std::vector<uint8_t> build_with_builder(improv::Command command, const std::vector<std::string> &datum,
|
||||
bool add_checksum) {
|
||||
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
|
||||
improv::RpcResponseBuilder builder(buf, command);
|
||||
for (const auto &str : datum) {
|
||||
EXPECT_TRUE(builder.add_string(str.c_str(), str.size()));
|
||||
}
|
||||
auto out = builder.finish(add_checksum);
|
||||
return {out.begin(), out.end()};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// The serial path sends builder output where build_rpc_response bytes went before,
|
||||
// so the two must match exactly, including the trailing 0x00 when checksums are off.
|
||||
TEST(RpcResponseBuilder, ByteIdenticalToBuildRpcResponse) {
|
||||
const std::vector<std::string> device_info = {"ESPHome", "2026.9.0", "ESP32", "test-device"};
|
||||
const std::vector<std::string> network = {"MySSID", "-67", "YES"};
|
||||
const std::vector<std::string> empty = {};
|
||||
const std::vector<std::string> max_payload = {std::string(254, 'x')};
|
||||
|
||||
for (bool add_checksum : {false, true}) {
|
||||
for (const auto *datum : {&device_info, &network, &empty, &max_payload}) {
|
||||
EXPECT_EQ(build_with_builder(improv::GET_DEVICE_INFO, *datum, add_checksum),
|
||||
improv::build_rpc_response(improv::GET_DEVICE_INFO, *datum, add_checksum));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Golden bytes independent of the library: command, data length, string entries,
|
||||
// then the trailing byte (0x00 without checksum, additive checksum with).
|
||||
TEST(RpcResponseBuilder, GoldenBytes) {
|
||||
EXPECT_EQ(build_with_builder(improv::GET_WIFI_NETWORKS, {}, false), (std::vector<uint8_t>{0x04, 0x00, 0x00}));
|
||||
EXPECT_EQ(build_with_builder(improv::GET_WIFI_NETWORKS, {"ab"}, false),
|
||||
(std::vector<uint8_t>{0x04, 0x03, 0x02, 'a', 'b', 0x00}));
|
||||
// Checksum: 0x04 + 0x03 + 0x02 + 'a' + 'b' = 0xCC
|
||||
EXPECT_EQ(build_with_builder(improv::GET_WIFI_NETWORKS, {"ab"}, true),
|
||||
(std::vector<uint8_t>{0x04, 0x03, 0x02, 'a', 'b', 0xCC}));
|
||||
}
|
||||
|
||||
// esp32_improv calls finish() and build_rpc_response() with no checksum flag,
|
||||
// so the two defaults must agree
|
||||
TEST(RpcResponseBuilder, DefaultChecksumFlagMatches) {
|
||||
const std::vector<std::string> urls = {"https://example.com"};
|
||||
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
|
||||
improv::RpcResponseBuilder builder(buf, improv::WIFI_SETTINGS);
|
||||
for (const auto &str : urls) {
|
||||
EXPECT_TRUE(builder.add_string(str.c_str(), str.size()));
|
||||
}
|
||||
auto out = builder.finish();
|
||||
EXPECT_EQ(std::vector<uint8_t>(out.begin(), out.end()), improv::build_rpc_response(improv::WIFI_SETTINGS, urls));
|
||||
}
|
||||
|
||||
TEST(RpcResponseBuilder, PayloadBudget) {
|
||||
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
|
||||
|
||||
// 254 byte string fills the payload exactly; a second entry no longer fits
|
||||
improv::RpcResponseBuilder full(buf, improv::GET_DEVICE_INFO);
|
||||
const std::string big(254, 'x');
|
||||
EXPECT_TRUE(full.add_string(big.c_str(), big.size()));
|
||||
EXPECT_FALSE(full.add_string("y", 1));
|
||||
|
||||
// 255 byte string can never fit (its length byte would exceed the budget)
|
||||
improv::RpcResponseBuilder over(buf, improv::GET_DEVICE_INFO);
|
||||
const std::string too_big(255, 'y');
|
||||
EXPECT_FALSE(over.add_string(too_big.c_str(), too_big.size()));
|
||||
// A wildly out of range length must not wrap the position arithmetic
|
||||
EXPECT_FALSE(over.add_string("z", static_cast<size_t>(-1)));
|
||||
auto out = over.finish(false);
|
||||
EXPECT_EQ(std::vector<uint8_t>(out.begin(), out.end()), (std::vector<uint8_t>{0x03, 0x00, 0x00}));
|
||||
}
|
||||
|
||||
TEST(RpcResponseBuilder, FinishIsIdempotent) {
|
||||
std::array<uint8_t, improv::RPC_RESPONSE_MAX_SIZE> buf;
|
||||
improv::RpcResponseBuilder builder(buf, improv::GET_DEVICE_INFO);
|
||||
EXPECT_TRUE(builder.add_string("abc", 3));
|
||||
auto first = builder.finish(true);
|
||||
const std::vector<uint8_t> expected(first.begin(), first.end());
|
||||
|
||||
EXPECT_FALSE(builder.add_string("late", 4));
|
||||
auto again = builder.finish(true);
|
||||
EXPECT_EQ(std::vector<uint8_t>(again.begin(), again.end()), expected);
|
||||
// The checksum flag on a later call is ignored
|
||||
auto no_checksum = builder.finish(false);
|
||||
EXPECT_EQ(std::vector<uint8_t>(no_checksum.begin(), no_checksum.end()), expected);
|
||||
}
|
||||
|
||||
} // namespace esphome::improv_base::testing
|
||||
@@ -0,0 +1,17 @@
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: 19
|
||||
mosi_pin: 21
|
||||
miso_pin: 17
|
||||
cs_pin: 18
|
||||
interrupt_pin: 36
|
||||
reset_pin: 12
|
||||
clock_speed: 10Mhz
|
||||
|
||||
logger:
|
||||
hardware_uart: UART0
|
||||
|
||||
# Exercises the per-interface webserver URL collection at compile time
|
||||
web_server:
|
||||
|
||||
improv_serial:
|
||||
@@ -0,0 +1,11 @@
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
# Serial logging off; on a dedicated UART bus improv_serial must not
|
||||
# require the logger's serial settings
|
||||
logger:
|
||||
baud_rate: 0
|
||||
|
||||
improv_serial:
|
||||
uart_id: uart_bus
|
||||
@@ -5,4 +5,6 @@ wifi:
|
||||
logger:
|
||||
hardware_uart: UART0
|
||||
|
||||
# next_url compiles the USE_IMPROV_SERIAL_NEXT_URL branch and add_next_url_
|
||||
improv_serial:
|
||||
next_url: https://example.com/?device_name={{device_name}}&ip_address={{ip_address}}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
improv_serial: !include common-ethernet.yaml
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
|
||||
improv_serial: !include common-uart-bus.yaml
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml
|
||||
improv_serial: !include common-uart-bus.yaml
|
||||
@@ -0,0 +1,92 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
#include "esphome/components/light/esp_color_correction.h"
|
||||
|
||||
namespace esphome::light::testing {
|
||||
|
||||
namespace {
|
||||
|
||||
// A representative fixture for ESPColorCorrection/gamma_table_reverse_search tests below --
|
||||
// not a spec for generate_gamma_table() itself, which the Python tests own.
|
||||
std::array<uint16_t, 256> build_gamma_table(double gamma) {
|
||||
std::array<uint16_t, 256> table{};
|
||||
table[0] = 0;
|
||||
for (int i = 1; i < 256; i++) {
|
||||
double raw = std::round(std::pow(i / 255.0, gamma) * 65535.0);
|
||||
table[i] = static_cast<uint16_t>(std::max(1.0, std::min(65535.0, raw)));
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
// Bundles a table with an ESPColorCorrection pointing at it, since the correction only holds
|
||||
// a raw pointer into the table and doesn't own it.
|
||||
struct GammaFixture {
|
||||
explicit GammaFixture(double gamma) : table(build_gamma_table(gamma)) { correction.set_gamma_table(table.data()); }
|
||||
std::array<uint16_t, 256> table;
|
||||
ESPColorCorrection correction;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// Regression test for esphome/esphome#18842: ESPColorCorrection's own 16-bit -> 8-bit
|
||||
// conversion must never round a non-zero table entry down to a zero 8-bit output.
|
||||
TEST(GammaCorrection, NonZeroInputsSurviveConversion) {
|
||||
for (double gamma : {1.0, 1.8, 2.0, 2.2, 2.8, 3.0, 4.0}) {
|
||||
GammaFixture fixture(gamma);
|
||||
for (int i = 1; i < 256; i++) {
|
||||
EXPECT_GE(fixture.correction.color_correct_red(i), 1) << "gamma=" << gamma << " index=" << i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(GammaCorrection, ZeroInputStaysZero) {
|
||||
for (double gamma : {1.0, 2.2, 2.8, 4.0}) {
|
||||
GammaFixture fixture(gamma);
|
||||
EXPECT_EQ(fixture.correction.color_correct_red(0), 0) << "gamma=" << gamma;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(GammaCorrection, FullBrightnessStaysFull) {
|
||||
for (double gamma : {1.0, 2.2, 2.8, 4.0}) {
|
||||
GammaFixture fixture(gamma);
|
||||
EXPECT_EQ(fixture.correction.color_correct_red(255), 255) << "gamma=" << gamma;
|
||||
}
|
||||
}
|
||||
|
||||
// Reproduces the reporter's own numbers from esphome/esphome#18842 at gamma=2.8: codes
|
||||
// 1-27 previously collapsed to an 8-bit output of 0 and must now be non-zero.
|
||||
TEST(GammaCorrection, DeadZoneFixedAtGamma28) {
|
||||
GammaFixture fixture(2.8);
|
||||
for (int i = 1; i < 28; i++) {
|
||||
EXPECT_GE(fixture.correction.color_correct_red(i), 1) << "index=" << i << " still collapses to 0";
|
||||
}
|
||||
}
|
||||
|
||||
TEST(GammaCorrection, ReverseSearchFindsLargestIndexLessEqualTarget) {
|
||||
auto table = build_gamma_table(2.8);
|
||||
for (uint16_t target : {0, 128, 129, 135, 1000, 32768, 65535}) {
|
||||
uint8_t lo = gamma_table_reverse_search(table.data(), target);
|
||||
EXPECT_LE(table[lo], target) << "target=" << target;
|
||||
if (lo < 255) {
|
||||
EXPECT_GT(table[lo + 1], target) << "target=" << target;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// color_uncorrect_* binary-searches the table via gamma_table_reverse_search().
|
||||
TEST(GammaCorrection, UncorrectStaysMonotonic) {
|
||||
GammaFixture fixture(2.8);
|
||||
uint8_t prev = 0;
|
||||
for (int i = 1; i < 256; i++) {
|
||||
uint8_t result = fixture.correction.color_uncorrect_red(i);
|
||||
EXPECT_GE(result, prev) << "index=" << i;
|
||||
prev = result;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::light::testing
|
||||
@@ -188,6 +188,8 @@ lvgl:
|
||||
dark_mode: true
|
||||
obj:
|
||||
border_width: 1
|
||||
user_1:
|
||||
bg_color: black
|
||||
|
||||
gradients:
|
||||
- id: color_bar
|
||||
@@ -209,6 +211,63 @@ lvgl:
|
||||
position: 212
|
||||
- color: 0xFF0000
|
||||
position: 255
|
||||
- id: linear_grad
|
||||
direction: LINEAR
|
||||
linear:
|
||||
from_x: 0%
|
||||
from_y: 0%
|
||||
to_x: 100%
|
||||
to_y: 0%
|
||||
extend: REFLECT
|
||||
stops:
|
||||
- color: 0xFF0000
|
||||
position: 0
|
||||
- color: 0x0000FF
|
||||
position: 255
|
||||
- id: radial_grad
|
||||
direction: RADIAL
|
||||
radial:
|
||||
center_x: 50%
|
||||
center_y: 50%
|
||||
to_x: 100%
|
||||
to_y: 50%
|
||||
extend: PAD
|
||||
stops:
|
||||
- color: 0xFFFFFF
|
||||
position: 0
|
||||
- color: 0x000000
|
||||
position: 255
|
||||
- id: radial_focal_grad
|
||||
direction: RADIAL
|
||||
radial:
|
||||
center_x: 50%
|
||||
center_y: 50%
|
||||
to_x: 100%
|
||||
to_y: 50%
|
||||
focal_x: 40%
|
||||
focal_y: 40%
|
||||
focal_radius: 10
|
||||
extend: REPEAT
|
||||
stops:
|
||||
- color: 0xFF0000
|
||||
position: 0
|
||||
- color: 0x0000FF
|
||||
position: 255
|
||||
- id: conical_grad
|
||||
direction: CONICAL
|
||||
conical:
|
||||
center_x: 50%
|
||||
center_y: 50%
|
||||
start_angle: 0
|
||||
end_angle: 360
|
||||
extend: PAD
|
||||
stops:
|
||||
- color: 0xFF0000
|
||||
position: 0
|
||||
- color: 0x00FF00
|
||||
position: 127
|
||||
- color: 0xFF0000
|
||||
position: 255
|
||||
|
||||
style_definitions:
|
||||
- id: style_test
|
||||
@@ -660,6 +719,30 @@ lvgl:
|
||||
id: button_with_text
|
||||
text: Clicked
|
||||
|
||||
# Exercises the LV_STATE_USER_1..USER_4 states: setting them at creation
|
||||
# (both literal and lambda), styling each of them individually, and
|
||||
# setting/clearing them at runtime with lvgl.widget.update.
|
||||
- button:
|
||||
id: user_flags_button
|
||||
text: User flags
|
||||
state:
|
||||
user_1: true
|
||||
user_2: !lambda return true;
|
||||
user_1:
|
||||
bg_color: 0xFF00FF
|
||||
user_2:
|
||||
bg_color: 0x00FFFF
|
||||
user_3:
|
||||
bg_color: 0xFFFF00
|
||||
user_4:
|
||||
bg_color: 0x808080
|
||||
on_click:
|
||||
- lvgl.widget.update:
|
||||
id: user_flags_button
|
||||
state:
|
||||
user_3: true
|
||||
user_4: !lambda return !lv_obj_has_state(id(user_flags_button), LV_STATE_USER_4);
|
||||
|
||||
- button:
|
||||
layout: 2x1
|
||||
id: button_button
|
||||
@@ -1070,6 +1153,14 @@ lvgl:
|
||||
logger.log:
|
||||
format: Slider released at %d/%d with value %.0f
|
||||
args: ['(int) point.x', '(int) point.y', x]
|
||||
|
||||
# Exercises the style-application path for a complex gradient, not just its
|
||||
# lv_grad_*_init() codegen: the other new gradients are only ever declared.
|
||||
- obj:
|
||||
bg_opa: cover
|
||||
bg_grad: conical_grad
|
||||
width: 40
|
||||
height: 40
|
||||
- button:
|
||||
styles: spin_button
|
||||
id: spin_up
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
mk2pvrouter:
|
||||
id: test_mk2pvrouter
|
||||
uart_id: uart_bus
|
||||
|
||||
sensor:
|
||||
- platform: mk2pvrouter
|
||||
name: Power
|
||||
tag: P
|
||||
mk2pvrouter_id: test_mk2pvrouter
|
||||
unit_of_measurement: W
|
||||
device_class: power
|
||||
state_class: measurement
|
||||
accuracy_decimals: 0
|
||||
|
||||
- platform: mk2pvrouter
|
||||
name: Voltage
|
||||
tag: V
|
||||
mk2pvrouter_id: test_mk2pvrouter
|
||||
unit_of_measurement: V
|
||||
device_class: voltage
|
||||
state_class: measurement
|
||||
accuracy_decimals: 2
|
||||
filters:
|
||||
# Device sends voltage * 100
|
||||
- multiply: 0.01
|
||||
|
||||
- platform: mk2pvrouter
|
||||
name: Energy
|
||||
tag: E
|
||||
mk2pvrouter_id: test_mk2pvrouter
|
||||
unit_of_measurement: Wh
|
||||
device_class: energy
|
||||
state_class: total_increasing
|
||||
accuracy_decimals: 0
|
||||
|
||||
- platform: mk2pvrouter
|
||||
name: Temperature
|
||||
tag: T1
|
||||
mk2pvrouter_id: test_mk2pvrouter
|
||||
unit_of_measurement: "°C"
|
||||
device_class: temperature
|
||||
state_class: measurement
|
||||
accuracy_decimals: 2
|
||||
filters:
|
||||
# Device sends temperature * 100
|
||||
- multiply: 0.01
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
uart_9600_even_7bits: !include ../../test_build_components/common/uart_9600_even_7bits/esp32-idf.yaml
|
||||
mk2pvrouter: !include common.yaml
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
uart_9600_even_7bits: !include ../../test_build_components/common/uart_9600_even_7bits/esp8266-ard.yaml
|
||||
mk2pvrouter: !include common.yaml
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
uart_9600_even_7bits: !include ../../test_build_components/common/uart_9600_even_7bits/rp2040-ard.yaml
|
||||
mk2pvrouter: !include common.yaml
|
||||
@@ -11,7 +11,14 @@ namespace esphome::modbus::testing {
|
||||
// A UART that discards all writes, for tests that never inspect the wire.
|
||||
class NullUART : public uart::UARTComponent {
|
||||
public:
|
||||
NullUART() { this->set_baud_rate(115200); }
|
||||
// 8N1, matching what the uart schema emits for a real hub; the framing drives the modbus
|
||||
// interframe timing, so leaving data/stop bits at their zero defaults would not be representative.
|
||||
NullUART() {
|
||||
this->set_baud_rate(115200);
|
||||
this->set_data_bits(8);
|
||||
this->set_stop_bits(1);
|
||||
this->set_parity(uart::UART_CONFIG_PARITY_NONE);
|
||||
}
|
||||
void write_array(const uint8_t *data, size_t len) override {}
|
||||
bool peek_byte(uint8_t *data) override { return false; }
|
||||
bool read_array(uint8_t *data, size_t len) override { return false; }
|
||||
|
||||
@@ -775,7 +775,7 @@ TEST(ModbusClientHubBroadcast, DeliversNoTerminalToTypedDevice) {
|
||||
|
||||
// A broadcast is only meaningful for a command that changes state; a broadcast READ could never be
|
||||
// answered, so the hub refuses it at the door (false return, no entry queued) rather than silently
|
||||
// retiring it. Writes, 0x17, and custom codes still go through (covered above).
|
||||
// retiring it. Writes and custom/unknown codes still go through (covered in the neighboring tests).
|
||||
TEST(ModbusClientHubBroadcast, RefusesReadBroadcast) {
|
||||
NullUART uart;
|
||||
NoResponseProbeHub hub;
|
||||
@@ -814,9 +814,8 @@ TEST(ModbusClientHubBroadcast, AcceptsCustomBroadcast) {
|
||||
EXPECT_EQ(hub.entries(), 0u); // the entry is gone
|
||||
}
|
||||
|
||||
// An exception-flagged custom code (0x80 bit set) is not a real request: is_function_code_custom() masks
|
||||
// the bit away and would accept it, but the broadcast guard excludes it, matching classify()'s handling
|
||||
// of an exception-flagged write.
|
||||
// An exception-flagged code (0x80 bit set) is never a valid request - that bit is response-only - so
|
||||
// queue_pdu refuses it up front, before the broadcast guard, whatever its base code.
|
||||
TEST(ModbusClientHubBroadcast, RefusesExceptionFlaggedCustomBroadcast) {
|
||||
NullUART uart;
|
||||
NoResponseProbeHub hub;
|
||||
@@ -833,6 +832,50 @@ TEST(ModbusClientHubBroadcast, RefusesExceptionFlaggedCustomBroadcast) {
|
||||
EXPECT_EQ(device.sent_count_, 0); // never transmitted
|
||||
}
|
||||
|
||||
// FC23 (read/write multiple) has a read half that expects a reply, so the Modbus spec does not allow it
|
||||
// as a broadcast. is_function_code_read() covers it, so the broadcast guard refuses it despite its write
|
||||
// half.
|
||||
TEST(ModbusClientHubBroadcast, RefusesReadWriteMultipleBroadcast) {
|
||||
NullUART uart;
|
||||
NoResponseProbeHub hub;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.setup();
|
||||
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
|
||||
|
||||
// fc, read start+qty, write start+qty, byte count, one data word.
|
||||
const uint8_t read_write_multiple[] = {0x17, 0x00, 0x00, 0x00, 0x01, 0x00, 0x10, 0x00, 0x01, 0x02, 0xBE, 0xEF};
|
||||
EXPECT_FALSE(device.queue_pdu(read_write_multiple)); // its read half could never be answered
|
||||
EXPECT_EQ(hub.entries(), 0u);
|
||||
}
|
||||
|
||||
// FC 0x18 (read FIFO queue) is not a "read" by is_function_code_read(), but the hub has an explicit
|
||||
// response-length rule for it - it demonstrably expects a reply, so it cannot broadcast.
|
||||
TEST(ModbusClientHubBroadcast, RefusesKnownLengthNonWriteBroadcast) {
|
||||
NullUART uart;
|
||||
NoResponseProbeHub hub;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.setup();
|
||||
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
|
||||
|
||||
const uint8_t read_fifo[] = {0x18, 0x00, 0x10}; // fc, FIFO pointer address
|
||||
EXPECT_FALSE(device.queue_pdu(read_fifo));
|
||||
EXPECT_EQ(hub.entries(), 0u);
|
||||
}
|
||||
|
||||
// A code that is neither a read nor exception-flagged (here 0x63, unassigned) is fire-and-forget on a
|
||||
// broadcast: the hub can't know it isn't a vendor write, so it is accepted and delivered to all devices.
|
||||
TEST(ModbusClientHubBroadcast, AcceptsNonReadUnknownBroadcast) {
|
||||
NullUART uart;
|
||||
NoResponseProbeHub hub;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.setup();
|
||||
BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS);
|
||||
|
||||
const uint8_t unknown[] = {0x63, 0x00, 0x01};
|
||||
EXPECT_TRUE(device.queue_pdu(unknown)); // not a read, so not refused
|
||||
EXPECT_EQ(hub.entries(), 1u);
|
||||
}
|
||||
|
||||
namespace {
|
||||
// tx_blocked() clear for send_next_frame_'s gate, then blocked for send_frame_'s post-delay re-check.
|
||||
class RejectPostDelayHub : public NoResponseProbeHub {
|
||||
@@ -1882,30 +1925,20 @@ TEST(ModbusClientHubPriority, ResendFromOnResponseAbsorbsIntoCompletingCommand)
|
||||
EXPECT_FALSE(hub.queued(0).options.continuous); // the one-shot re-send downgraded the poll
|
||||
}
|
||||
|
||||
// An exception-flagged function code is never silently re-sendable, even though the read check
|
||||
// masks the exception bit: its duplicate takes the drop path like any other non-read.
|
||||
TEST(ModbusClientHubPriority, ExceptionFlaggedDuplicateDroppedNotPromoted) {
|
||||
// The exception bit marks a response, so a request carrying it is refused outright.
|
||||
TEST(ModbusClientHubPriority, ExceptionFlaggedPduRefused) {
|
||||
NoResponseProbeHub hub;
|
||||
SentCountingDevice device(&hub, 0x02);
|
||||
|
||||
const uint8_t weird[] = {0x83, 0x01, 0x00, 0x00, 0x02}; // read-shaped but exception-flagged
|
||||
EXPECT_TRUE(device.queue_pdu(weird));
|
||||
EXPECT_FALSE(device.queue_pdu(weird)); // non-requeueable: cap of one, so the duplicate is refused
|
||||
// The 0x80 exception flag is a response-only bit; a request must never set it. queue_pdu refuses an
|
||||
// exception-flagged PDU up front - nothing is queued - whether its base code reads (0x83 = 0x03 | 0x80)
|
||||
// or writes (0x86 = 0x06 | 0x80).
|
||||
const uint8_t read_shaped[] = {0x83, 0x01, 0x00, 0x00, 0x02};
|
||||
const uint8_t write_shaped[] = {0x86, 0x00, 0x10, 0xBE, 0xEF};
|
||||
EXPECT_FALSE(device.queue_pdu(read_shaped));
|
||||
EXPECT_FALSE(device.queue_pdu(write_shaped));
|
||||
hub.sweep_for_test();
|
||||
|
||||
ASSERT_EQ(hub.queued_frames(), 1u);
|
||||
EXPECT_EQ(hub.queued(0).pending, 1u);
|
||||
EXPECT_EQ(device.not_sent_count_, 0);
|
||||
|
||||
// The write-shaped twin (0x86 masks to WRITE_SINGLE_REGISTER) must not take WRITE-class
|
||||
// ordering either: exception-flagged codes are excluded from the mutates classification.
|
||||
const uint8_t weird_write[] = {0x86, 0x00, 0x10, 0xBE, 0xEF};
|
||||
device.queue_pdu(weird_write);
|
||||
ASSERT_EQ(hub.queued_frames(), 2u);
|
||||
EXPECT_EQ(hub.queued(1).priority(), CommandPriority::READ); // not WRITE
|
||||
const ModbusDeviceCommand *next = hub.next_ready();
|
||||
ASSERT_NE(next, nullptr);
|
||||
EXPECT_EQ(next->frame.pdu()[0], 0x83); // FIFO by age: it did not jump the older entry
|
||||
EXPECT_EQ(hub.queued_frames(), 0u);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "common.h"
|
||||
#include "esphome/components/modbus/modbus.h"
|
||||
|
||||
namespace esphome::modbus::testing {
|
||||
|
||||
namespace {
|
||||
|
||||
// Exposes the timing values setup() derives from the UART framing.
|
||||
class FramingProbeHub : public ModbusClientHub {
|
||||
public:
|
||||
uint32_t bits_per_char() const { return this->bits_per_char_; }
|
||||
uint32_t frame_delay_us() const { return this->frame_delay_us_; }
|
||||
};
|
||||
|
||||
class FramedUART : public NullUART {
|
||||
public:
|
||||
FramedUART(uint32_t baud_rate, uint8_t data_bits, uint8_t stop_bits, uart::UARTParityOptions parity) {
|
||||
this->set_baud_rate(baud_rate);
|
||||
this->set_data_bits(data_bits);
|
||||
this->set_stop_bits(stop_bits);
|
||||
this->set_parity(parity);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// 8N1 is 10 bits on the wire, so t3.5 at 9600 baud is 3.5 * 10 / 9600 = 3645.8us.
|
||||
TEST(ModbusFraming, EightNoneOneDerivesTenBits) {
|
||||
FramedUART uart(9600, 8, 1, uart::UART_CONFIG_PARITY_NONE);
|
||||
FramingProbeHub hub;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.setup();
|
||||
|
||||
EXPECT_EQ(hub.bits_per_char(), 10u);
|
||||
EXPECT_EQ(hub.frame_delay_us(), 3646u);
|
||||
}
|
||||
|
||||
// Spec-conformant RTU framing is 11 bits, which lengthens the interframe gap to
|
||||
// 3.5 * 11 / 9600 = 4010.4us, rounded up.
|
||||
TEST(ModbusFraming, EightEvenOneDerivesElevenBits) {
|
||||
FramedUART uart(9600, 8, 1, uart::UART_CONFIG_PARITY_EVEN);
|
||||
FramingProbeHub hub;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.setup();
|
||||
|
||||
EXPECT_EQ(hub.bits_per_char(), 11u);
|
||||
EXPECT_EQ(hub.frame_delay_us(), 4011u);
|
||||
}
|
||||
|
||||
// Above 19200 baud the spec's fixed 1750us floor governs instead of 3.5 characters.
|
||||
TEST(ModbusFraming, FastBaudUsesSpecFloor) {
|
||||
FramedUART uart(115200, 8, 1, uart::UART_CONFIG_PARITY_NONE);
|
||||
FramingProbeHub hub;
|
||||
hub.set_uart_parent(&uart);
|
||||
hub.setup();
|
||||
|
||||
EXPECT_EQ(hub.frame_delay_us(), 1750u);
|
||||
}
|
||||
|
||||
} // namespace esphome::modbus::testing
|
||||
@@ -63,6 +63,12 @@ TEST(ModbusClientFrameLength, TooShortReturnsMinimum) {
|
||||
EXPECT_EQ(client_frame_length(frame, 1), MIN_FRAME_SIZE);
|
||||
}
|
||||
|
||||
TEST(ModbusClientFrameLength, ExceptionFlaggedIsTheExceptionShape) {
|
||||
// Sized at 2 so an exception-flagged request fails its CRC at once instead of being scanned for.
|
||||
const uint8_t exception_request[] = {0x83, 0x02};
|
||||
EXPECT_EQ(client_pdu_length(exception_request, sizeof(exception_request)), 2);
|
||||
}
|
||||
|
||||
TEST(ModbusClientFrameLength, ReadAndWriteSingleAreFixed) {
|
||||
// basic_register request fixture is a read-holding request -> 8 bytes
|
||||
const uint8_t read[] = {0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A};
|
||||
@@ -421,11 +427,132 @@ TEST(ModbusHelpersTest, RegistersToNumberMatchesPayloadToNumber) {
|
||||
}
|
||||
}
|
||||
|
||||
TEST(ModbusHelpersTest, RegistersToNumberMatchesPayloadToNumberForQwords) {
|
||||
// The word shuffle the QWORD_R decode replaces is the least obvious code in the byte path, so pin
|
||||
// it against that path rather than against registers_to_value(). The top bit is set, which is where
|
||||
// U_QWORD's unsigned value and this function's int64_t return deliberately diverge.
|
||||
const uint16_t registers[] = {0xF123, 0x4567, 0x89AB, 0xCDEF};
|
||||
const std::vector<uint8_t> bytes{0xF1, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF};
|
||||
for (auto value_type :
|
||||
{SensorValueType::U_QWORD, SensorValueType::S_QWORD, SensorValueType::U_QWORD_R, SensorValueType::S_QWORD_R}) {
|
||||
EXPECT_EQ(registers_to_number(registers, 4, value_type),
|
||||
payload_to_number(std::span<const uint8_t>(bytes), value_type, 0, 0xFFFFFFFF))
|
||||
<< "value_type=" << static_cast<int>(value_type);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(ModbusHelpersTest, RegistersToNumberTreatsRawAndBitAsNothingToDecode) {
|
||||
// Both have no fixed-width number, so they decode to 0 whatever the span holds - including none.
|
||||
const uint16_t registers[] = {0x1234};
|
||||
EXPECT_EQ(registers_to_number(registers, 1, SensorValueType::RAW), std::optional<int64_t>(0));
|
||||
EXPECT_EQ(registers_to_number(registers, 0, SensorValueType::RAW), std::optional<int64_t>(0));
|
||||
EXPECT_EQ(registers_to_number(registers, 0, SensorValueType::BIT), std::optional<int64_t>(0));
|
||||
}
|
||||
|
||||
TEST(ModbusHelpersTest, RegistersToNumberRejectsTruncatedMultiRegisterValue) {
|
||||
const uint16_t registers[] = {0x1234};
|
||||
EXPECT_FALSE(registers_to_number(registers, 1, SensorValueType::U_DWORD).has_value());
|
||||
}
|
||||
|
||||
// --- registers_to_value ----------------------------------------------------
|
||||
// registers_to_number() dispatches to registers_to_value(), so this checks the dispatch table picks
|
||||
// the right specialisation for each type, not that two implementations agree. The independent check
|
||||
// against the byte decoder is RegistersToNumberMatchesPayloadToNumber below.
|
||||
|
||||
template<SensorValueType VALUE_TYPE> void expect_matches_registers_to_number(const uint16_t *registers) {
|
||||
const auto expected = registers_to_number(registers, register_width_for(VALUE_TYPE), VALUE_TYPE);
|
||||
// Plain control flow rather than ASSERT_TRUE: the optional analysis does not see through the macro.
|
||||
if (!expected.has_value()) {
|
||||
ADD_FAILURE() << "registers_to_number() returned no value for value_type=" << static_cast<int>(VALUE_TYPE);
|
||||
return;
|
||||
}
|
||||
const int64_t number = expected.value();
|
||||
if constexpr (VALUE_TYPE == SensorValueType::FP32 || VALUE_TYPE == SensorValueType::FP32_R) {
|
||||
EXPECT_FLOAT_EQ(registers_to_value<VALUE_TYPE>(registers), bit_cast<float>(static_cast<uint32_t>(number)))
|
||||
<< "value_type=" << static_cast<int>(VALUE_TYPE);
|
||||
} else {
|
||||
EXPECT_EQ(static_cast<int64_t>(registers_to_value<VALUE_TYPE>(registers)), number)
|
||||
<< "value_type=" << static_cast<int>(VALUE_TYPE);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(ModbusHelpersTest, RegistersToValueMatchesRegistersToNumber) {
|
||||
// A high bit in each word exercises sign handling and word order together.
|
||||
const uint16_t registers[] = {0x8001, 0xFE02};
|
||||
expect_matches_registers_to_number<SensorValueType::U_WORD>(registers);
|
||||
expect_matches_registers_to_number<SensorValueType::S_WORD>(registers);
|
||||
expect_matches_registers_to_number<SensorValueType::U_WORD_S>(registers);
|
||||
expect_matches_registers_to_number<SensorValueType::S_WORD_S>(registers);
|
||||
expect_matches_registers_to_number<SensorValueType::U_DWORD>(registers);
|
||||
expect_matches_registers_to_number<SensorValueType::U_DWORD_R>(registers);
|
||||
expect_matches_registers_to_number<SensorValueType::S_DWORD>(registers);
|
||||
expect_matches_registers_to_number<SensorValueType::S_DWORD_R>(registers);
|
||||
expect_matches_registers_to_number<SensorValueType::FP32>(registers);
|
||||
expect_matches_registers_to_number<SensorValueType::FP32_R>(registers);
|
||||
}
|
||||
|
||||
TEST(ModbusHelpersTest, RegistersToUint32CombinesWordsHighFirst) {
|
||||
EXPECT_EQ(registers_to_uint32(0x1234, 0x5678), 0x12345678u);
|
||||
}
|
||||
|
||||
// --- value_at ---------------------------------------------------------------
|
||||
// Addresses are absolute; anything not wholly inside the response yields nullopt.
|
||||
|
||||
TEST(ModbusHelpersTest, ValueAtDecodesByAbsoluteAddress) {
|
||||
const uint16_t registers[] = {0x1111, 0x2222, 0x3333};
|
||||
const std::span<const uint16_t> span(registers, 3);
|
||||
EXPECT_EQ(value_at<SensorValueType::U_WORD>(span, 100, 100), std::optional<uint16_t>(0x1111));
|
||||
EXPECT_EQ(value_at<SensorValueType::U_WORD>(span, 100, 102), std::optional<uint16_t>(0x3333));
|
||||
EXPECT_EQ(value_at<SensorValueType::U_DWORD>(span, 100, 101), std::optional<uint32_t>(0x22223333u));
|
||||
// Types whose RegisterValueType<> is not an unsigned integer, and the widest bounds check.
|
||||
const uint16_t floats[] = {0x4048, 0xF5C3, 0xF5C3, 0x4048};
|
||||
const std::span<const uint16_t> float_span(floats, 4);
|
||||
EXPECT_FLOAT_EQ(value_at<SensorValueType::FP32>(float_span, 10, 10).value_or(0.0f), 3.14f);
|
||||
EXPECT_FLOAT_EQ(value_at<SensorValueType::FP32_R>(float_span, 10, 12).value_or(0.0f), 3.14f);
|
||||
EXPECT_EQ(value_at<SensorValueType::U_QWORD>(float_span, 10, 10), std::optional<uint64_t>(0x4048F5C3F5C34048ULL));
|
||||
EXPECT_FALSE(value_at<SensorValueType::U_QWORD>(float_span, 10, 11).has_value());
|
||||
}
|
||||
|
||||
TEST(ModbusHelpersTest, ValueAtIsUsableInAConstantExpression) {
|
||||
static constexpr uint16_t REGISTERS[] = {0x1234, 0x5678};
|
||||
static_assert(value_at<SensorValueType::U_DWORD>(REGISTERS, 7, 7).value_or(0) == 0x12345678u);
|
||||
static_assert(!value_at<SensorValueType::U_DWORD>(REGISTERS, 7, 6).has_value());
|
||||
}
|
||||
|
||||
TEST(ModbusHelpersTest, ValueAtRejectsAddressesOutsideTheResponse) {
|
||||
const uint16_t registers[] = {0x1111, 0x2222, 0x3333};
|
||||
const std::span<const uint16_t> span(registers, 3);
|
||||
// Below the response: must not wrap when the subtraction would go negative.
|
||||
EXPECT_FALSE(value_at<SensorValueType::U_WORD>(span, 100, 99).has_value());
|
||||
EXPECT_FALSE(value_at<SensorValueType::U_WORD>(span, 100, 0).has_value());
|
||||
// Past the end, and a multi-register value truncated by the end of the response.
|
||||
EXPECT_FALSE(value_at<SensorValueType::U_WORD>(span, 100, 103).has_value());
|
||||
EXPECT_FALSE(value_at<SensorValueType::U_DWORD>(span, 100, 102).has_value());
|
||||
EXPECT_TRUE(value_at<SensorValueType::U_DWORD>(span, 100, 101).has_value());
|
||||
}
|
||||
|
||||
TEST(ModbusHelpersTest, ValueAtHandlesAnEmptyResponse) {
|
||||
EXPECT_FALSE(value_at<SensorValueType::U_WORD>(std::span<const uint16_t>(), 0, 0).has_value());
|
||||
}
|
||||
|
||||
// --- QWORD decoding ---------------------------------------------------------
|
||||
|
||||
TEST(ModbusHelpersTest, RegistersToValueDecodesQwordBothWordOrders) {
|
||||
const uint16_t registers[] = {0x0123, 0x4567, 0x89AB, 0xCDEF};
|
||||
EXPECT_EQ(registers_to_value<SensorValueType::U_QWORD>(registers), 0x0123456789ABCDEFULL);
|
||||
const uint16_t reversed[] = {0xCDEF, 0x89AB, 0x4567, 0x0123};
|
||||
EXPECT_EQ(registers_to_value<SensorValueType::U_QWORD_R>(reversed), 0x0123456789ABCDEFULL);
|
||||
// Signed reading of the same bits, and the sign-extreme case.
|
||||
EXPECT_EQ(registers_to_value<SensorValueType::S_QWORD>(registers), 0x0123456789ABCDEFLL);
|
||||
const uint16_t negative[] = {0xFFFF, 0xFFFF, 0xFFFF, 0xFFFE};
|
||||
EXPECT_EQ(registers_to_value<SensorValueType::S_QWORD>(negative), -2);
|
||||
EXPECT_EQ(registers_to_value<SensorValueType::U_QWORD>(negative), 0xFFFFFFFFFFFFFFFEULL);
|
||||
}
|
||||
|
||||
TEST(ModbusHelpersTest, RegistersToUint64CombinesWordsHighFirst) {
|
||||
EXPECT_EQ(registers_to_uint64(0x0123, 0x4567, 0x89AB, 0xCDEF), 0x0123456789ABCDEFULL);
|
||||
}
|
||||
|
||||
// --- packed bit helpers ------------------------------------------------------
|
||||
|
||||
TEST(ModbusHelpersTest, PackBitsAppendsToContainer) {
|
||||
@@ -483,6 +610,28 @@ TEST(ModbusTypedBuilders, WriteRegistersPduRejectsOverLimit) {
|
||||
EXPECT_FALSE(create_write_registers_pdu(0x0000, values).empty());
|
||||
}
|
||||
|
||||
TEST(ModbusTypedBuilders, WriteFewRegistersPduMatchesFullSizeBuilder) {
|
||||
static_assert(sizeof(WriteFewRegistersPdu) < sizeof(PduBuffer) / 4,
|
||||
"WriteFewRegistersPdu must be meaningfully smaller");
|
||||
const uint16_t values[] = {0x000B, 0x0016, 0xABCD, 0xFF00};
|
||||
for (size_t count = 1; count <= MAX_FEW_REGISTERS; count++) {
|
||||
auto small = create_write_few_registers_pdu(0x0102, std::span<const uint16_t>(values, count));
|
||||
auto full = create_write_registers_pdu(0x0102, std::span<const uint16_t>(values, count));
|
||||
EXPECT_EQ(std::vector<uint8_t>(small.begin(), small.end()), std::vector<uint8_t>(full.begin(), full.end()))
|
||||
<< count << " registers";
|
||||
EXPECT_EQ(small.size(), 6u + 2 * count);
|
||||
EXPECT_TRUE(is_client_pdu_standard(small.data(), small.size()));
|
||||
}
|
||||
}
|
||||
|
||||
TEST(ModbusTypedBuilders, WriteFewRegistersPduRejectsInvalidInput) {
|
||||
const uint16_t values[MAX_FEW_REGISTERS + 1] = {0xAAAA, 0xAAAA, 0xAAAA, 0xAAAA, 0xAAAA};
|
||||
EXPECT_TRUE(create_write_few_registers_pdu(0x0000, values).empty());
|
||||
EXPECT_FALSE(create_write_few_registers_pdu(0x0000, std::span<const uint16_t>(values, MAX_FEW_REGISTERS)).empty());
|
||||
EXPECT_TRUE(create_write_few_registers_pdu(0x0000, std::span<const uint16_t>()).empty());
|
||||
EXPECT_TRUE(create_write_few_registers_pdu(0xFFFF, std::span<const uint16_t>(values, 2)).empty());
|
||||
}
|
||||
|
||||
TEST(ModbusTypedBuilders, ReadWriteMultipleRegistersPduWireBytes) {
|
||||
const uint16_t write_values[] = {0x000B, 0x0016};
|
||||
// Read 2 registers at 0x0010, write 2 registers at 0x0020.
|
||||
|
||||
@@ -54,7 +54,8 @@ class TestServerHub : public ModbusServerHub {
|
||||
|
||||
// The frame-length parsers have explicit cases for exactly these 13 codes; every other value - the
|
||||
// assigned-but-unimplemented management codes, both user-defined ranges, and all unassigned codes -
|
||||
// must classify as unknown length. The exception flag masks off first.
|
||||
// must classify as unknown length. Exception replies are always the 2-byte spec shape, so every
|
||||
// 0x80-set code is known length.
|
||||
TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) {
|
||||
for (uint8_t fc : {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x0F, 0x10, 0x14, 0x15, 0x16, 0x17, 0x18}) {
|
||||
EXPECT_FALSE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc);
|
||||
@@ -62,11 +63,13 @@ TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) {
|
||||
for (uint8_t fc : {0x07, 0x08, 0x0B, 0x0C, 0x11, 0x2A, 0x41, 0x48, 0x49, 0x64, 0x6E, 0x00, 0x7F}) {
|
||||
EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << int(fc);
|
||||
}
|
||||
// Exception replies classify by their base code.
|
||||
// Every exception-flagged code is known length (the 2-byte spec exception shape), whatever its base.
|
||||
EXPECT_FALSE(helpers::is_function_code_unknown_length(0x83));
|
||||
EXPECT_TRUE(helpers::is_function_code_unknown_length(0x87));
|
||||
// Strictly wider than the user-defined ranges: every custom code is unknown-length, but not vice versa.
|
||||
for (int fc = 0; fc <= 0xFF; fc++) {
|
||||
EXPECT_FALSE(helpers::is_function_code_unknown_length(0x87));
|
||||
EXPECT_FALSE(helpers::is_function_code_unknown_length(0xC9));
|
||||
// Strictly wider than the user-defined ranges below 0x80: every non-exception custom code is
|
||||
// unknown-length, but not vice versa.
|
||||
for (int fc = 0; fc <= 0x7F; fc++) {
|
||||
if (helpers::is_function_code_custom(fc))
|
||||
EXPECT_TRUE(helpers::is_function_code_unknown_length(fc)) << "fc 0x" << std::hex << fc;
|
||||
}
|
||||
@@ -75,10 +78,10 @@ TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) {
|
||||
// Derived contract check: the helper must say "unknown" exactly when both length parsers fall
|
||||
// through to default. With a zero-filled max-size PDU every explicit case returns at least 2
|
||||
// (file records bottom out at 2, FIFO at 3) and only default returns MIN_PDU_SIZE, so comparing
|
||||
// against MIN_PDU_SIZE detects a case added to either switch without updating the helper. The
|
||||
// loop stops at 0x7F: above it the helper masks the exception flag off while client_pdu_length()
|
||||
// switches on the unmasked byte and server_pdu_length() early-returns the exception length.
|
||||
for (int fc = 0; fc <= 0x7F; fc++) {
|
||||
// against MIN_PDU_SIZE detects a case added to either switch without updating the helper. Both
|
||||
// parsers early-return the 2-byte exception shape above 0x7F, which the helper's own exception
|
||||
// early-return mirrors, so the whole byte range is covered.
|
||||
for (int fc = 0; fc <= 0xFF; fc++) {
|
||||
const uint8_t pdu[MAX_PDU_SIZE] = {static_cast<uint8_t>(fc)}; // zero header fields
|
||||
EXPECT_EQ(helpers::is_function_code_unknown_length(fc),
|
||||
helpers::client_pdu_length(pdu, sizeof(pdu)) == MIN_PDU_SIZE)
|
||||
@@ -89,6 +92,17 @@ TEST(ModbusUnknownFunction, HelperMatchesParserCoverage) {
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcastable = writes plus unknown codes (possible vendor writes); everything known to expect a
|
||||
// reply is not. Classifies the underlying code: the exception bit masks off first (0x85 as 0x05).
|
||||
TEST(ModbusUnknownFunction, BroadcastableClassification) {
|
||||
for (uint8_t fc : {0x05, 0x06, 0x0F, 0x10, 0x16, 0x49, 0x63, 0x6E, 0x85, 0xC9}) {
|
||||
EXPECT_TRUE(helpers::is_function_code_broadcastable(fc)) << "fc 0x" << std::hex << int(fc);
|
||||
}
|
||||
for (uint8_t fc : {0x01, 0x02, 0x03, 0x04, 0x14, 0x15, 0x17, 0x18, 0x83, 0x97}) {
|
||||
EXPECT_FALSE(helpers::is_function_code_broadcastable(fc)) << "fc 0x" << std::hex << int(fc);
|
||||
}
|
||||
}
|
||||
|
||||
// A response with a function code outside the user-defined ranges (0x49) has no length case in
|
||||
// server_pdu_length(), so the parser must find the frame end by CRC scan - the same way it already
|
||||
// handles user-defined codes. Frame: address + FC 0x49 + 3 data bytes + CRC = 7 bytes. Without the
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
|
||||
#include "esphome/components/modbus_controller/modbus_controller.h"
|
||||
|
||||
// These tests pin the behaviour of the deprecated ModbusCommandItem until its removal.
|
||||
// Remove with ModbusCommandItem before 2027.3.0.
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
|
||||
|
||||
namespace esphome::modbus_controller::testing {
|
||||
|
||||
// The coil write factory packs into an exact-size payload. Pinned at one past the protocol maximum
|
||||
@@ -29,3 +34,5 @@ TEST(ModbusCommandPayload, CoilWritePacksLsbFirstWithZeroPad) {
|
||||
}
|
||||
|
||||
} // namespace esphome::modbus_controller::testing
|
||||
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
@@ -21,6 +21,7 @@ binary_sensor:
|
||||
name: Test Binary Sensor with Lambda
|
||||
register_type: input
|
||||
address: 0x3201
|
||||
reuse_previous_range: false
|
||||
lambda: |-
|
||||
return x;
|
||||
|
||||
@@ -85,6 +86,7 @@ select:
|
||||
name: Test Select with Lambda
|
||||
address: 1001
|
||||
value_type: U_WORD
|
||||
reuse_previous_range: auto
|
||||
optionsmap:
|
||||
"Off": 0
|
||||
"On": 1
|
||||
@@ -140,9 +142,10 @@ sensor:
|
||||
register_type: holding
|
||||
address: 0x9002
|
||||
value_type: U_WORD
|
||||
reuse_previous_range: true
|
||||
lambda: |-
|
||||
return x / 10.0;
|
||||
# Non-mergeable sensor sharing the start address of modbus_sensor1 (different register_count):
|
||||
# Non-mergeable sensor sharing the start address of modbus_sensor1 (different value type width):
|
||||
# must join the same range, never open a second range keyed on the same (address, type).
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller1
|
||||
@@ -187,8 +190,8 @@ sensor:
|
||||
value_type: U_WORD
|
||||
lambda: |-
|
||||
return modbus_controller::get_data<uint16_t>(data, item->offset) * 0.1f;
|
||||
# force_new_range sensors sort before plain ones, so this high-address forced sensor is grouped
|
||||
# first and the lower-address plain sensors above must still get their own ranges.
|
||||
# The deprecated force_new_range migrates to reuse_previous_range: false, so this sensor never
|
||||
# joins a range built before it and the lower-address sensors above keep their own ranges.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller1
|
||||
id: modbus_sensor_forced_high
|
||||
@@ -224,7 +227,6 @@ text_sensor:
|
||||
name: Test Text Sensor
|
||||
register_type: holding
|
||||
address: 0x9013
|
||||
register_count: 3
|
||||
raw_encode: HEXBYTES
|
||||
response_size: 6
|
||||
- platform: modbus_controller
|
||||
@@ -233,12 +235,13 @@ text_sensor:
|
||||
name: Test Text Sensor with Lambda
|
||||
register_type: holding
|
||||
address: 0x9014
|
||||
register_count: 2
|
||||
response_size: 4
|
||||
lambda: |-
|
||||
return "Modified: " + x;
|
||||
# A register reporting FEWER bytes than 2*register_count (response_size: 3 for 2 registers), followed
|
||||
# by a contiguous sensor: the follower's byte position must track the actual 3 bytes, not underflow.
|
||||
# A register reporting FEWER bytes than two per register (response_size: 3 over 2 registers), followed
|
||||
# by a contiguous reuse:true sensor (auto never joins past a response_size register): the follower's
|
||||
# byte position must track the actual 3 bytes, not underflow.
|
||||
# register_count matches the derived width, so it migrates with a deprecation warning.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller1
|
||||
id: modbus_text_sensor_narrow
|
||||
@@ -255,4 +258,5 @@ text_sensor:
|
||||
register_type: holding
|
||||
address: 0x9032
|
||||
register_count: 1
|
||||
reuse_previous_range: true
|
||||
raw_encode: HEXBYTES
|
||||
|
||||
@@ -62,6 +62,12 @@ image:
|
||||
url: http://www.faqs.org/images/library.jpg
|
||||
format: AUTO
|
||||
type: RGB565
|
||||
- platform: online_image
|
||||
id: online_qoi_image
|
||||
url: https://www.example.org/image.qoi
|
||||
format: QOI
|
||||
type: RGB
|
||||
transparency: alpha_channel
|
||||
|
||||
# Check the set_url action
|
||||
esphome:
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
port: 3288
|
||||
encryption:
|
||||
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
@@ -0,0 +1,12 @@
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
port: 3289
|
||||
encryption:
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
ota: !include encryption.yaml
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
ota: !include encryption.yaml
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
ota: !include encryption.yaml
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
ota: !include encryption_inherit.yaml
|
||||
@@ -1,6 +1,7 @@
|
||||
# Exercises the provisioning window: api registers as a provisioning source
|
||||
# (encryption enabled, no key), the on_timeout automation, and the wifi +
|
||||
# esp32_improv cross-component guards. improv_serial is intentionally NOT gated.
|
||||
# (encryption enabled, no key), the on_timeout automation, and the wifi (AP +
|
||||
# captive portal) and esp32_improv cross-component guards. improv_serial is
|
||||
# intentionally NOT gated.
|
||||
provisioning:
|
||||
timeout: 1min
|
||||
on_timeout:
|
||||
@@ -13,6 +14,10 @@ api:
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
ap:
|
||||
ssid: MyAP
|
||||
|
||||
captive_portal:
|
||||
|
||||
improv_serial:
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Provisioning window on ESP8266 (no BLE Improv): api as a provisioning source
|
||||
# and the wifi reboot guard. improv_serial is present and intentionally NOT gated.
|
||||
# and the wifi (AP + captive portal) guards. improv_serial is present and
|
||||
# intentionally NOT gated.
|
||||
provisioning:
|
||||
timeout: 1min
|
||||
on_timeout:
|
||||
@@ -12,5 +13,9 @@ api:
|
||||
wifi:
|
||||
ssid: MySSID
|
||||
password: password1
|
||||
ap:
|
||||
ssid: MyAP
|
||||
|
||||
captive_portal:
|
||||
|
||||
improv_serial:
|
||||
|
||||
@@ -9,7 +9,8 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# tests have two decoder types and every retained decoder is under test.
|
||||
async def to_code_testing(config: ConfigType) -> None:
|
||||
enable_format("BMP")
|
||||
enable_format("PNG")
|
||||
enable_format("JPEG")
|
||||
enable_format("PNG")
|
||||
enable_format("QOI")
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
|
||||
@@ -70,11 +70,36 @@ static const uint8_t PNG_RGB_EXPECTED[4][4][3] = {
|
||||
{{0x12, 0x34, 0x56}, {0x65, 0x43, 0x21}, {0xFE, 0xDC, 0xBA}, {0xAB, 0xCD, 0xEF}},
|
||||
};
|
||||
|
||||
// 3x3 QOI, exercising all possible chunk types
|
||||
static const uint8_t QOI_RGBA[] = {
|
||||
0x71, 0x6F, 0x69, 0x66, // Header: 'qoif'
|
||||
0x00, 0x00, 0x00, 0x03, // Width: 3
|
||||
0x00, 0x00, 0x00, 0x03, // Height: 3
|
||||
0x04, // Channels: 4 (RGBA)
|
||||
0x00, // Colorspace: 0 (SRGB)
|
||||
0xC1, // 1. QOI_OP_RUN
|
||||
0x79, // 2. QOI_OP_DIFF
|
||||
0xAA, 0x79, // 3. QOI_OP_LUMA
|
||||
0xFE, 0xC8, 0x64, 0x32, // 4. QOI_OP_RGB
|
||||
0xFF, 0x78, 0x50, 0x28,
|
||||
0x64, // 5. QOI_OP_RGBA
|
||||
0x31, // 6. QOI_OP_INDEX
|
||||
0xC1, // 7. QOI_OP_RUN
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01 // End Marker
|
||||
};
|
||||
|
||||
static const uint8_t QOI_EXPECTED_RGBA[3][3][4] = {
|
||||
{{0x00, 0x00, 0x00, 0xFF}, {0x00, 0x00, 0x00, 0xFF}, {0x01, 0x00, 0xFF, 0xFF}},
|
||||
{{0x0A, 0x0A, 0x0A, 0xFF}, {0xC8, 0x64, 0x32, 0xFF}, {0x78, 0x50, 0x28, 0x64}},
|
||||
{{0x01, 0x00, 0xFF, 0xFF}, {0x01, 0x00, 0xFF, 0xFF}, {0x01, 0x00, 0xFF, 0xFF}}
|
||||
|
||||
};
|
||||
|
||||
/// Exposes the protected decoder machinery so reuse and eviction can be observed directly.
|
||||
class TestableRuntimeImage : public RuntimeImage {
|
||||
public:
|
||||
explicit TestableRuntimeImage(ImageFormat format)
|
||||
: RuntimeImage(format, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE, nullptr, false, 0, 0) {}
|
||||
explicit TestableRuntimeImage(ImageFormat format, image::Transparency transparency = image::TRANSPARENCY_OPAQUE)
|
||||
: RuntimeImage(format, image::IMAGE_TYPE_RGB, transparency, nullptr, false, 0, 0) {}
|
||||
|
||||
ImageDecoder *decoder() { return this->decoder_.get(); }
|
||||
};
|
||||
@@ -132,6 +157,19 @@ template<size_t H, size_t W> static void expect_pixels(TestableRuntimeImage &img
|
||||
}
|
||||
}
|
||||
|
||||
template<size_t H, size_t W>
|
||||
static void expect_pixels_rgba(TestableRuntimeImage &img, const uint8_t (&expected)[H][W][4]) {
|
||||
ASSERT_EQ(img.get_width(), static_cast<int>(W));
|
||||
ASSERT_EQ(img.get_height(), static_cast<int>(H));
|
||||
for (size_t y = 0; y < H; y++) {
|
||||
for (size_t x = 0; x < W; x++) {
|
||||
SCOPED_TRACE(::testing::Message() << "pixel (" << x << "," << y << ")");
|
||||
Color color = img.get_pixel(x, y);
|
||||
EXPECT_THAT((std::array<uint8_t, 4>{color.r, color.g, color.b, color.w}),
|
||||
::testing::ElementsAreArray(expected[y][x]));
|
||||
}
|
||||
}
|
||||
}
|
||||
TEST(RuntimeImageDecoder, DecoderStaysWarmAcrossDecodes) {
|
||||
TestableRuntimeImage img(BMP);
|
||||
|
||||
@@ -337,6 +375,33 @@ TEST(RuntimeImageDecoder, JpegDecoderStaysWarmAcrossDecodes) {
|
||||
}
|
||||
#endif // USE_RUNTIME_IMAGE_JPEG
|
||||
|
||||
TEST(RuntimeImageDecoder, QoiDecoderStaysWarmAcrossDecodes) {
|
||||
TestableRuntimeImage img(QOI, image::TRANSPARENCY_ALPHA_CHANNEL);
|
||||
|
||||
ASSERT_TRUE(decode_all(img, QOI_RGBA, sizeof(QOI_RGBA)));
|
||||
expect_pixels_rgba(img, QOI_EXPECTED_RGBA);
|
||||
ImageDecoder *first = img.decoder();
|
||||
ASSERT_NE(first, nullptr);
|
||||
|
||||
ASSERT_TRUE(decode_all(img, QOI_RGBA, sizeof(QOI_RGBA)));
|
||||
expect_pixels_rgba(img, QOI_EXPECTED_RGBA);
|
||||
EXPECT_EQ(img.decoder(), first) << "decoder must be reused, not reallocated";
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, QoiChunkedFeedDecodesLikeDownloadLoop) {
|
||||
TestableRuntimeImage img(QOI, image::TRANSPARENCY_ALPHA_CHANNEL);
|
||||
|
||||
ASSERT_TRUE(decode_chunked(img, QOI_RGBA, sizeof(QOI_RGBA), 10));
|
||||
expect_pixels_rgba(img, QOI_EXPECTED_RGBA);
|
||||
ImageDecoder *first = img.decoder();
|
||||
|
||||
// Chunked again on the warm decoder: the cross-call resume state
|
||||
// (current_index_ / paint_index_) must have been fully reset.
|
||||
ASSERT_TRUE(decode_chunked(img, QOI_RGBA, sizeof(QOI_RGBA), 10));
|
||||
expect_pixels_rgba(img, QOI_EXPECTED_RGBA);
|
||||
EXPECT_EQ(img.decoder(), first);
|
||||
}
|
||||
|
||||
TEST(RuntimeImageDecoder, SessionFlagsTrackLifecycle) {
|
||||
TestableRuntimeImage img(BMP);
|
||||
std::vector<uint8_t> buffer(BMP_24BPP, BMP_24BPP + sizeof(BMP_24BPP));
|
||||
|
||||
@@ -10,12 +10,14 @@ TEST(RuntimeImageMime, FormatForKnownMimeTypes) {
|
||||
EXPECT_EQ(get_format_for_mime_type("image/bmp"), BMP);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/x-ms-bmp"), BMP);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/x-bmp"), BMP);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/png"), PNG);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/x-png"), PNG);
|
||||
#ifdef USE_RUNTIME_IMAGE_JPEG
|
||||
EXPECT_EQ(get_format_for_mime_type("image/jpeg"), JPEG);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/jpg"), JPEG);
|
||||
#endif // USE_RUNTIME_IMAGE_JPEG
|
||||
EXPECT_EQ(get_format_for_mime_type("image/png"), PNG);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/x-png"), PNG);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/qoi"), QOI);
|
||||
EXPECT_EQ(get_format_for_mime_type("image/x-qoi"), QOI);
|
||||
}
|
||||
|
||||
TEST(RuntimeImageMime, FormatMatchingIsCaseInsensitive) {
|
||||
@@ -39,20 +41,22 @@ TEST(RuntimeImageMime, UnknownMimeTypeHasNoFormat) {
|
||||
|
||||
TEST(RuntimeImageMime, MimeTypeForFormatRoundTrip) {
|
||||
EXPECT_STREQ(get_mime_type_for_format(BMP), "image/bmp");
|
||||
EXPECT_STREQ(get_mime_type_for_format(PNG), "image/png");
|
||||
#ifdef USE_RUNTIME_IMAGE_JPEG
|
||||
EXPECT_STREQ(get_mime_type_for_format(JPEG), "image/jpeg");
|
||||
#endif // USE_RUNTIME_IMAGE_JPEG
|
||||
EXPECT_STREQ(get_mime_type_for_format(PNG), "image/png");
|
||||
EXPECT_STREQ(get_mime_type_for_format(QOI), "image/qoi");
|
||||
// AUTO has no single MIME type and falls back to the wildcard
|
||||
EXPECT_STREQ(get_mime_type_for_format(AUTO), "image/*");
|
||||
|
||||
// Every decodable format must resolve back to itself through its MIME type
|
||||
for (ImageFormat format : {
|
||||
BMP,
|
||||
PNG,
|
||||
#ifdef USE_RUNTIME_IMAGE_JPEG
|
||||
JPEG,
|
||||
#endif // USE_RUNTIME_IMAGE_JPEG
|
||||
PNG,
|
||||
QOI,
|
||||
}) {
|
||||
EXPECT_EQ(get_format_for_mime_type(get_mime_type_for_format(format)), format) << format;
|
||||
}
|
||||
|
||||
@@ -28,8 +28,21 @@ sensor:
|
||||
accuracy_decimals: 1
|
||||
nox_index:
|
||||
name: NOx Index
|
||||
algorithm_tuning:
|
||||
index_offset: 8
|
||||
learning_time_offset_hours: 6
|
||||
learning_time_gain_hours: 24
|
||||
gating_max_duration_minutes: 900
|
||||
gain_factor: 180
|
||||
voc_index:
|
||||
name: VOC Index
|
||||
algorithm_tuning:
|
||||
index_offset: 120
|
||||
learning_time_offset_hours: 6
|
||||
learning_time_gain_hours: 24
|
||||
gating_max_duration_minutes: 240
|
||||
std_initial: 75
|
||||
gain_factor: 180
|
||||
co2:
|
||||
name: Carbon Dioxide
|
||||
formaldehyde:
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Config-only: partial algorithm_tuning blocks, so the schema defaults fill in the
|
||||
# keys that are left out.
|
||||
packages:
|
||||
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
|
||||
|
||||
sensor:
|
||||
- platform: sen6x
|
||||
id: sen6x_partial_tuning
|
||||
type: SEN65
|
||||
i2c_id: i2c_bus
|
||||
voc_index:
|
||||
name: VOC Index
|
||||
algorithm_tuning:
|
||||
index_offset: 60
|
||||
nox_index:
|
||||
name: NOx Index
|
||||
algorithm_tuning:
|
||||
gain_factor: 45
|
||||
@@ -0,0 +1,10 @@
|
||||
packages:
|
||||
spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml
|
||||
common: !include common.yaml
|
||||
psram:
|
||||
mode: octal
|
||||
spi_device:
|
||||
- id: spi_device_psram_dma_test
|
||||
psram_dma: true
|
||||
data_rate: 1MHz
|
||||
spi_mode: 0
|
||||
@@ -80,3 +80,24 @@ switch:
|
||||
- platform: tuya
|
||||
id: tuya_switch
|
||||
switch_datapoint: 1
|
||||
|
||||
water_heater:
|
||||
- platform: tuya
|
||||
id: tuya_water_heater
|
||||
name: Tuya Water Heater
|
||||
switch_datapoint: 1
|
||||
current_temperature_datapoint: 3
|
||||
target_temperature_datapoint: 2
|
||||
current_temperature_multiplier: 0.5
|
||||
target_temperature_multiplier: 0.5
|
||||
mode_datapoint: 4
|
||||
eco_value: 0
|
||||
electric_value: 2
|
||||
supported_modes:
|
||||
- "OFF"
|
||||
- ECO
|
||||
- ELECTRIC
|
||||
visual:
|
||||
min_temperature: 30
|
||||
max_temperature: 75
|
||||
target_temperature_step: 1
|
||||
|
||||
@@ -23,6 +23,7 @@ import pytest_asyncio
|
||||
|
||||
import esphome.config
|
||||
from esphome.core import CORE
|
||||
from esphome.helpers import get_usable_cpu_count
|
||||
from esphome.platformio.toolchain import get_idedata
|
||||
|
||||
from .const import (
|
||||
@@ -67,6 +68,14 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
|
||||
env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps" / worker)
|
||||
# Prevent cache cleaning during integration tests
|
||||
env["ESPHOME_SKIP_CLEAN_BUILD"] = "1"
|
||||
# Cap each compile's -j so several xdist workers do not each spawn a
|
||||
# full-width compiler fan-out on the same machine. An explicit env wins.
|
||||
if "ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT" not in os.environ:
|
||||
workers = int(os.environ.get("PYTEST_XDIST_WORKER_COUNT", "1"))
|
||||
# Floor of 2 keeps a lone tail compile from running fully serial
|
||||
env["ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT"] = str(
|
||||
max(2, get_usable_cpu_count() // workers)
|
||||
)
|
||||
# Compile with THIS tree's esphome sources, not wherever the venv's editable
|
||||
# install points (which may be a different git worktree or checkout).
|
||||
repo_root = str(Path(__file__).resolve().parent.parent.parent)
|
||||
@@ -78,7 +87,8 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]:
|
||||
@pytest.fixture(scope="session")
|
||||
def shared_platformio_cache() -> Generator[Path]:
|
||||
"""Initialize a shared PlatformIO cache for all integration tests."""
|
||||
# Use a dedicated directory for integration tests to avoid conflicts
|
||||
# Use a dedicated directory for integration tests to avoid conflicts.
|
||||
# CI caches parts of this path; keep in sync with ci.yml integration-tests.
|
||||
test_cache_dir = Path.home() / ".esphome-integration-tests"
|
||||
cache_dir = test_cache_dir / "platformio"
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
esphome:
|
||||
name: api-action-metadata-test
|
||||
host:
|
||||
api:
|
||||
batch_delay: 0ms
|
||||
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:
|
||||
format: "Buzzer: %s"
|
||||
args: [song_str.c_str()]
|
||||
- action: plain_action
|
||||
variables:
|
||||
value: int
|
||||
then:
|
||||
- logger.log: "Plain action called"
|
||||
|
||||
logger:
|
||||
@@ -0,0 +1,59 @@
|
||||
esphome:
|
||||
name: ha-bs-initial
|
||||
|
||||
host:
|
||||
|
||||
api:
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
binary_sensor:
|
||||
# trigger_on_initial_state: true must fire on_press for the first state from HA
|
||||
- platform: homeassistant
|
||||
name: Initial On
|
||||
entity_id: binary_sensor.initial_on
|
||||
trigger_on_initial_state: true
|
||||
on_press:
|
||||
- logger.log: "initial_on on_press"
|
||||
on_release:
|
||||
- logger.log: "initial_on on_release"
|
||||
|
||||
# Default (false) must not fire on the first state, only on later changes
|
||||
- platform: homeassistant
|
||||
name: Default
|
||||
entity_id: binary_sensor.default
|
||||
on_press:
|
||||
- logger.log: "default on_press"
|
||||
on_release:
|
||||
- logger.log: "default on_release"
|
||||
|
||||
# Real HA startup shape: 'unavailable' arrives before the first real state
|
||||
- platform: homeassistant
|
||||
name: Unavailable First
|
||||
entity_id: binary_sensor.unavailable_first
|
||||
trigger_on_initial_state: true
|
||||
on_press:
|
||||
- logger.log: "unavailable_first on_press"
|
||||
on_release:
|
||||
- logger.log: "unavailable_first on_release"
|
||||
|
||||
# Initial 'off' must fire on_release when trigger_on_initial_state is set
|
||||
- platform: homeassistant
|
||||
name: Initial Off
|
||||
entity_id: binary_sensor.initial_off
|
||||
trigger_on_initial_state: true
|
||||
on_press:
|
||||
- logger.log: "initial_off on_press"
|
||||
on_release:
|
||||
- logger.log: "initial_off on_release"
|
||||
|
||||
# Same 'unavailable' first shape without the flag; must stay quiet on the
|
||||
# first real state and only fire on the later change
|
||||
- platform: homeassistant
|
||||
name: Default Unavailable First
|
||||
entity_id: binary_sensor.default_unavail
|
||||
on_press:
|
||||
- logger.log: "default_unavail on_press"
|
||||
on_release:
|
||||
- logger.log: "default_unavail on_release"
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Host-only stub of the wifi component for integration tests.
|
||||
|
||||
HOST-ONLY TEST COMPONENT: this shadows the real wifi component for EVERY
|
||||
fixture that uses the shared external_components directory. Any host fixture
|
||||
with a wifi block gets this stub, not the real component: fixed scan results,
|
||||
is_connected() hardwired true, and save_wifi_sta that only logs. See
|
||||
wifi_component.h for the full behavior.
|
||||
"""
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_PASSWORD, CONF_SSID, CONF_USE_ADDRESS
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@esphome/tests"]
|
||||
|
||||
wifi_ns = cg.esphome_ns.namespace("wifi")
|
||||
WiFiComponent = wifi_ns.class_("WiFiComponent", cg.Component)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(WiFiComponent),
|
||||
# Accepted for fixture realism; the stub ignores them
|
||||
cv.Optional(CONF_SSID): cv.string,
|
||||
cv.Optional(CONF_PASSWORD): cv.string,
|
||||
# Read by StorageJSON via CORE.address whenever a wifi block exists
|
||||
cv.Optional(CONF_USE_ADDRESS, default="localhost"): cv.string,
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
def check_placeholder_credentials(config: ConfigType) -> None:
|
||||
"""Compile-time hook the esphome CLI imports from the wifi module; no-op here."""
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
cg.add_define("USE_WIFI")
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../esphome/components/wifi/scan_list.h
|
||||
@@ -0,0 +1,42 @@
|
||||
#include "wifi_component.h"
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::wifi {
|
||||
|
||||
static const char *const TAG = "wifi_stub";
|
||||
|
||||
WiFiComponent *global_wifi_component = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
WiFiComponent::WiFiComponent() { global_wifi_component = this; }
|
||||
|
||||
void WiFiComponent::setup() { ESP_LOGI(TAG, "Stub wifi ready"); }
|
||||
|
||||
void WiFiComponent::dump_config() { ESP_LOGCONFIG(TAG, "Stub wifi"); }
|
||||
|
||||
void WiFiComponent::start_scanning() {
|
||||
// Duplicate TestNet entry (weaker) and a hidden entry exercise the
|
||||
// should_show_scan_entry dedup and filtering logic
|
||||
this->scan_result_.clear();
|
||||
this->scan_result_.emplace_back("TestNet", -50, true, false);
|
||||
this->scan_result_.emplace_back("TestNet", -60, true, false);
|
||||
this->scan_result_.emplace_back("OpenNet", -70, false, false);
|
||||
this->scan_result_.emplace_back("", -40, false, true);
|
||||
ESP_LOGI(TAG, "Scan complete with %zu results", this->scan_result_.size());
|
||||
}
|
||||
|
||||
void WiFiComponent::set_sta(const WiFiAP &ap) { ESP_LOGI(TAG, "set_sta ssid=%s", ap.get_ssid().c_str()); }
|
||||
|
||||
void WiFiComponent::start_connecting(const WiFiAP &ap) {
|
||||
ESP_LOGI(TAG, "start_connecting ssid=%s", ap.get_ssid().c_str());
|
||||
// Connecting succeeds immediately, so the requested network is the connected one
|
||||
this->connected_ssid_ = ap.get_ssid().c_str();
|
||||
}
|
||||
|
||||
void WiFiComponent::clear_sta() { ESP_LOGI(TAG, "clear_sta"); }
|
||||
|
||||
void WiFiComponent::save_wifi_sta(StringRef ssid, StringRef password) {
|
||||
ESP_LOGI(TAG, "save_wifi_sta ssid=%s password_len=%zu", ssid.c_str(), password.size());
|
||||
}
|
||||
|
||||
} // namespace esphome::wifi
|
||||
@@ -0,0 +1,88 @@
|
||||
#pragma once
|
||||
|
||||
// ============================================================================
|
||||
// HOST-ONLY TEST COMPONENT — DO NOT COPY TO PRODUCTION CODE
|
||||
//
|
||||
// Stub of the real wifi component with just enough API surface for
|
||||
// improv_serial to build and run on the host platform. Scan results are
|
||||
// fixed, "connecting" succeeds immediately, and save_wifi_sta only logs so
|
||||
// tests can assert on the log output.
|
||||
// ============================================================================
|
||||
|
||||
#include "esphome/components/network/ip_address.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace esphome::wifi {
|
||||
|
||||
static constexpr size_t SSID_BUFFER_SIZE = 33;
|
||||
|
||||
class WiFiAP {
|
||||
public:
|
||||
void set_ssid(const char *ssid) { this->ssid_ = ssid; }
|
||||
void set_password(const char *password) { this->password_ = password; }
|
||||
StringRef get_ssid() const { return StringRef(this->ssid_); }
|
||||
StringRef get_password() const { return StringRef(this->password_); }
|
||||
|
||||
protected:
|
||||
std::string ssid_;
|
||||
std::string password_;
|
||||
};
|
||||
|
||||
class WiFiScanResult {
|
||||
public:
|
||||
WiFiScanResult(const char *ssid, int8_t rssi, bool with_auth, bool hidden)
|
||||
: ssid_(ssid), rssi_(rssi), with_auth_(with_auth), hidden_(hidden) {}
|
||||
StringRef get_ssid() const { return StringRef(this->ssid_); }
|
||||
int8_t get_rssi() const { return this->rssi_; }
|
||||
bool get_with_auth() const { return this->with_auth_; }
|
||||
bool get_is_hidden() const { return this->hidden_; }
|
||||
bool ssid_equals(const WiFiScanResult &other) const { return this->ssid_ == other.ssid_; }
|
||||
|
||||
protected:
|
||||
std::string ssid_;
|
||||
int8_t rssi_;
|
||||
bool with_auth_;
|
||||
bool hidden_;
|
||||
};
|
||||
|
||||
class WiFiComponent : public Component {
|
||||
public:
|
||||
WiFiComponent();
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override { return setup_priority::WIFI; }
|
||||
|
||||
bool has_sta() const { return false; }
|
||||
bool is_disabled() const { return false; }
|
||||
// Always connected so network::is_connected() keeps the API server accepting clients
|
||||
bool is_connected() const { return true; }
|
||||
// Reports the network start_connecting() was last asked for, so a consumer checking that it
|
||||
// joined the network it requested (rather than an earlier one) sees the connect succeed
|
||||
const char *wifi_ssid_to(std::span<char, SSID_BUFFER_SIZE> buffer) {
|
||||
snprintf(buffer.data(), buffer.size(), "%s", this->connected_ssid_.c_str());
|
||||
return buffer.data();
|
||||
}
|
||||
void start_scanning();
|
||||
const std::vector<WiFiScanResult> &get_scan_result() const { return this->scan_result_; }
|
||||
void set_sta(const WiFiAP &ap);
|
||||
void start_connecting(const WiFiAP &ap);
|
||||
void clear_sta();
|
||||
void save_wifi_sta(StringRef ssid, StringRef password);
|
||||
// Called by network::util on any USE_WIFI build
|
||||
const char *get_use_address() const { return "localhost"; }
|
||||
network::IPAddresses get_ip_addresses() { return {}; }
|
||||
|
||||
protected:
|
||||
std::vector<WiFiScanResult> scan_result_;
|
||||
std::string connected_ssid_;
|
||||
};
|
||||
|
||||
extern WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
} // namespace esphome::wifi
|
||||
@@ -0,0 +1,11 @@
|
||||
esphome:
|
||||
name: host-ota-test
|
||||
host:
|
||||
api:
|
||||
ota:
|
||||
- platform: esphome
|
||||
port: __OTA_PORT__
|
||||
encryption:
|
||||
key: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
logger:
|
||||
level: DEBUG
|
||||
@@ -0,0 +1,42 @@
|
||||
esphome:
|
||||
# Short name keeps the device info payload under uart_mock's 64 byte log cap
|
||||
name: improv-uart
|
||||
|
||||
host:
|
||||
api:
|
||||
actions:
|
||||
- action: uart_inject
|
||||
variables:
|
||||
payload: int[]
|
||||
then:
|
||||
- uart_mock.inject_rx:
|
||||
id: mock_uart
|
||||
data: !lambda return std::vector<uint8_t>(payload.begin(), payload.end());
|
||||
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Host-only stub shadowing the real wifi component (see external_components/wifi)
|
||||
wifi:
|
||||
ssid: TestNet
|
||||
password: password1
|
||||
|
||||
# Dummy uart entry so the uart component sources are part of the build; the
|
||||
# actual bus used by improv_serial is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
id: mock_uart
|
||||
baud_rate: 115200
|
||||
|
||||
improv_serial:
|
||||
uart_id: mock_uart
|
||||
# Deterministic on host: only the device name placeholder is used
|
||||
next_url: https://example.com/?device={{device_name}}
|
||||
@@ -27,8 +27,8 @@ uart_mock:
|
||||
# so these also pin the grouping: an extra or differently shaped read fails the test.
|
||||
- expect_tx: [0x01, 0x01, 0x00, 0x10, 0x00, 0x02, 0xBC, 0x0E] # coils 0x10 count 2
|
||||
inject_rx: [0x01, 0x01, 0x01, 0x01, 0x90, 0x48] # bit0 set, bit1 clear
|
||||
- expect_tx: [0x01, 0x03, 0x01, 0x60, 0x00, 0x01, 0x85, 0xE8] # holding 0x160 count 1
|
||||
inject_rx: [0x01, 0x03, 0x02, 0x01, 0x60, 0xB9, 0xFC] # 352
|
||||
- expect_tx: [0x01, 0x03, 0x01, 0x60, 0x00, 0x02, 0xC5, 0xE9] # holding 0x160 count 2
|
||||
inject_rx: [0x01, 0x03, 0x04, 0x01, 0x60, 0x01, 0x61, 0x3B, 0xA9] # 352, 353
|
||||
- expect_tx: [0x01, 0x03, 0x01, 0x00, 0x00, 0x01, 0x85, 0xF6] # holding 0x100 count 1
|
||||
inject_rx: [0x01, 0x03, 0x04, 0x01, 0x11, 0x02, 0x22, 0x2A, 0xB3] # 4 bytes: 273 then 546
|
||||
- expect_tx: [0x01, 0x03, 0x01, 0x20, 0x00, 0x04, 0x44, 0x3F] # holding 0x120 count 4
|
||||
@@ -49,8 +49,6 @@ uart_mock:
|
||||
inject_rx: [0x01, 0x03, 0x02, 0x33, 0x33, 0xEC, 0xA1] # 13107
|
||||
- expect_tx: [0x01, 0x03, 0x01, 0x70, 0x00, 0x03, 0x05, 0xEC] # holding 0x170 count 3
|
||||
inject_rx: [0x01, 0x03, 0x06, 0x00, 0x2A, 0x1B, 0x2C, 0x03, 0x0D, 0x3E, 0xAB] # 6 bytes
|
||||
- expect_tx: [0x01, 0x03, 0x01, 0x61, 0x00, 0x01, 0xD4, 0x28] # holding 0x161 count 1
|
||||
inject_rx: [0x01, 0x03, 0x02, 0x01, 0x61, 0x78, 0x3C] # 353
|
||||
|
||||
modbus:
|
||||
uart_id: virtual_uart_dev
|
||||
@@ -104,8 +102,9 @@ sensor:
|
||||
value_type: U_DWORD
|
||||
modbus_controller_id: modbus_controller_ok
|
||||
|
||||
# D - a wide (response_size) register followed by a contiguous one: the follower must start after the
|
||||
# bytes the wide register actually returned, not after 2 * register_count.
|
||||
# D - a wide (response_size) register followed by a contiguous reuse:true one (auto never joins past
|
||||
# a response_size register): the follower must start after the bytes the wide register actually
|
||||
# returned, not after two per register.
|
||||
- platform: modbus_controller
|
||||
name: "wide_first"
|
||||
address: 0x130
|
||||
@@ -118,6 +117,7 @@ sensor:
|
||||
address: 0x131
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
reuse_previous_range: true
|
||||
modbus_controller_id: modbus_controller_ok
|
||||
|
||||
# E - a gap: these must never share a range.
|
||||
@@ -195,13 +195,14 @@ sensor:
|
||||
value_type: U_WORD
|
||||
modbus_controller_id: modbus_controller_ok
|
||||
|
||||
# H - a sensor pinned to its own range, followed by a contiguous one.
|
||||
# H - a sensor that never joins the range built before it (reuse_previous_range: false), followed
|
||||
# by a contiguous plain item that extends the new range it started.
|
||||
- platform: modbus_controller
|
||||
name: "forced_first"
|
||||
address: 0x160
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
force_new_range: true
|
||||
reuse_previous_range: false
|
||||
modbus_controller_id: modbus_controller_ok
|
||||
- platform: modbus_controller
|
||||
name: "forced_next"
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-lambda-invert
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
uart_mock:
|
||||
- id: virtual_uart_server
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_controller
|
||||
data: !lambda return data;
|
||||
- id: virtual_uart_controller
|
||||
baud_rate: 9600
|
||||
auto_start: true
|
||||
debug:
|
||||
on_tx:
|
||||
- then:
|
||||
- uart_mock.inject_rx:
|
||||
id: virtual_uart_server
|
||||
data: !lambda return data;
|
||||
|
||||
globals:
|
||||
- id: reg40
|
||||
type: uint16_t
|
||||
initial_value: "5"
|
||||
|
||||
modbus:
|
||||
- uart_id: virtual_uart_server
|
||||
id: virtual_modbus_server
|
||||
role: server
|
||||
- uart_id: virtual_uart_controller
|
||||
id: virtual_modbus_controller
|
||||
role: client
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_controller
|
||||
id: modbus_controller_1
|
||||
update_interval: 1s
|
||||
|
||||
modbus_server:
|
||||
- address: 1
|
||||
modbus_id: virtual_modbus_server
|
||||
id: modbus_server_1
|
||||
registers:
|
||||
- address: 0x40
|
||||
value_type: U_WORD
|
||||
read_lambda: return id(reg40);
|
||||
write_lambda: id(reg40) = x; return true;
|
||||
|
||||
# An active-low holding switch: the write_lambda inverts the wire value, but the entity must still
|
||||
# report the REQUESTED state. assumed_state keeps the register unpolled, so the published state comes
|
||||
# only from write_state() - turning ON writes 0x0000 yet the switch shows ON.
|
||||
switch:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "invert_switch"
|
||||
register_type: holding
|
||||
address: 0x40
|
||||
assumed_state: true
|
||||
write_lambda: |-
|
||||
return !x;
|
||||
|
||||
sensor:
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: modbus_controller_1
|
||||
name: "reg_40"
|
||||
address: 0x40
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
# This test does not have anything to start (mock is autostart)
|
||||
@@ -0,0 +1,227 @@
|
||||
esphome:
|
||||
name: uart-mock-modbus-ranges-test
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: VERBOSE
|
||||
|
||||
external_components:
|
||||
- source:
|
||||
type: local
|
||||
path: EXTERNAL_COMPONENT_PATH
|
||||
|
||||
# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"]
|
||||
# The actual UART bus used is the uart_mock component below
|
||||
uart:
|
||||
baud_rate: 115200
|
||||
port: /dev/null
|
||||
|
||||
# Each expect_tx below pins the exact read request the controller's range builder emits, so this
|
||||
# fixture is a wire-level test of reuse_previous_range (auto/yes/no), gap joins, same-register reuse,
|
||||
# response_size surplus accounting, and RAW/text block reads.
|
||||
uart_mock:
|
||||
- id: virtual_uart_dev
|
||||
baud_rate: 9600
|
||||
rx_full_threshold: 120
|
||||
rx_timeout: 2
|
||||
# auto_start must be false to avoid races: the test presses the
|
||||
# "Start Scenario" button only after subscribing to states.
|
||||
auto_start: false
|
||||
debug:
|
||||
responses:
|
||||
- expect_tx: [0x01, 0x03, 0x00, 0x00, 0x00, 0x03, 0x05, 0xCB] # auto adjacency: one read covers 0x00-0x02
|
||||
inject_rx: [0x01, 0x03, 0x06, 0x00, 0x01, 0x00, 0x02, 0x00, 0x03, 0xFD, 0x74]
|
||||
- expect_tx: [0x01, 0x03, 0x00, 0x10, 0x00, 0x01, 0x85, 0xCF] # auto gap: 0x10 alone
|
||||
inject_rx: [0x01, 0x03, 0x02, 0x00, 0x04, 0xB9, 0x87]
|
||||
- expect_tx: [0x01, 0x03, 0x00, 0x13, 0x00, 0x01, 0x75, 0xCF] # auto gap: 0x13 alone
|
||||
inject_rx: [0x01, 0x03, 0x02, 0x00, 0x05, 0x78, 0x47]
|
||||
- expect_tx: [0x01, 0x03, 0x00, 0x20, 0x00, 0x04, 0x45, 0xC3] # yes across gap: one read 0x20-0x23, gap registers ignored
|
||||
inject_rx: [0x01, 0x03, 0x08, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09, 0x33, 0xD1]
|
||||
- expect_tx: [0x01, 0x03, 0x00, 0x30, 0x00, 0x01, 0x84, 0x05] # no isolation: 0x30 alone despite adjacency
|
||||
inject_rx: [0x01, 0x03, 0x02, 0x00, 0x0A, 0x38, 0x43]
|
||||
- expect_tx: [0x01, 0x03, 0x00, 0x31, 0x00, 0x01, 0xD5, 0xC5] # no isolation: 0x31 alone
|
||||
inject_rx: [0x01, 0x03, 0x02, 0x00, 0x0B, 0xF9, 0x83]
|
||||
- expect_tx: [0x01, 0x03, 0x00, 0x3F, 0x00, 0x01, 0xB4, 0x06] # open NEVER: 0x3F alone (the reuse:false item split off)
|
||||
inject_rx: [0x01, 0x03, 0x02, 0x00, 0x0C, 0xB8, 0x41]
|
||||
- expect_tx: [0x01, 0x03, 0x00, 0x40, 0x00, 0x02, 0xC5, 0xDF] # open NEVER: 0x40 (reuse: false) still extended by the auto item at 0x41
|
||||
inject_rx: [0x01, 0x03, 0x04, 0x00, 0x0D, 0x00, 0x0E, 0xEA, 0x34]
|
||||
- expect_tx: [0x01, 0x03, 0x00, 0x50, 0x00, 0x01, 0x84, 0x1B] # same-address reuse: one read, two sensors on 0x50
|
||||
inject_rx: [0x01, 0x03, 0x02, 0x12, 0x34, 0xB5, 0x33]
|
||||
- expect_tx: [0x01, 0x03, 0x00, 0x60, 0x00, 0x04, 0x44, 0x17] # text block + adjacent word: one read 0x60-0x63
|
||||
inject_rx: [0x01, 0x03, 0x08, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x00, 0x0F, 0x78, 0x0E]
|
||||
- expect_tx: [0x01, 0x03, 0x00, 0x70, 0x00, 0x02, 0xC5, 0xD0] # response_size surplus: 0x70 answers 4 bytes, reuse:true word at 0x71 shifted along
|
||||
inject_rx: [0x01, 0x03, 0x06, 0x00, 0x10, 0xAA, 0xBB, 0x00, 0x11, 0x71, 0x47]
|
||||
- expect_tx: [0x01, 0x03, 0x00, 0x90, 0x00, 0x01, 0x84, 0x27] # auto after surplus: 0x90 alone (auto never joins past response_size)
|
||||
inject_rx: [0x01, 0x03, 0x04, 0x00, 0x18, 0xCC, 0xDD, 0xEF, 0x6D]
|
||||
- expect_tx: [0x01, 0x03, 0x00, 0x91, 0x00, 0x01, 0xD5, 0xE7] # auto after surplus: 0x91 alone
|
||||
inject_rx: [0x01, 0x03, 0x02, 0x00, 0x19, 0x79, 0x8E]
|
||||
- expect_tx: [0x01, 0x03, 0x00, 0x80, 0x00, 0x04, 0x45, 0xE1] # RAW block via response_size: 8 bytes = 4 registers in one read
|
||||
inject_rx: [0x01, 0x03, 0x08, 0x00, 0x14, 0x00, 0x15, 0x00, 0x16, 0x00, 0x17, 0x6D, 0xDF]
|
||||
|
||||
modbus:
|
||||
uart_id: virtual_uart_dev
|
||||
send_wait_time: 200ms
|
||||
turnaround_time: 10ms
|
||||
|
||||
modbus_controller:
|
||||
- address: 1
|
||||
id: ranges_controller
|
||||
max_cmd_retries: 0
|
||||
# The test triggers a single poll by pressing the "Start Scenario" button
|
||||
update_interval: never
|
||||
|
||||
sensor:
|
||||
# Case 1: three adjacent registers merge into one read (auto default)
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ranges_controller
|
||||
name: "adjacent_a"
|
||||
register_type: holding
|
||||
address: 0x00
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ranges_controller
|
||||
name: "adjacent_b"
|
||||
register_type: holding
|
||||
address: 0x01
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ranges_controller
|
||||
name: "adjacent_c"
|
||||
register_type: holding
|
||||
address: 0x02
|
||||
|
||||
# Case 2: a gap keeps auto items apart
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ranges_controller
|
||||
name: "gap_a"
|
||||
register_type: holding
|
||||
address: 0x10
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ranges_controller
|
||||
name: "gap_b"
|
||||
register_type: holding
|
||||
address: 0x13
|
||||
|
||||
# Case 3: reuse_previous_range: true bridges the gap into one read
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ranges_controller
|
||||
name: "bridge_a"
|
||||
register_type: holding
|
||||
address: 0x20
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ranges_controller
|
||||
name: "bridge_b"
|
||||
register_type: holding
|
||||
address: 0x23
|
||||
reuse_previous_range: true
|
||||
|
||||
# Case 4: reuse_previous_range: false splits adjacent registers
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ranges_controller
|
||||
name: "split_a"
|
||||
register_type: holding
|
||||
address: 0x30
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ranges_controller
|
||||
name: "split_b"
|
||||
register_type: holding
|
||||
address: 0x31
|
||||
reuse_previous_range: false
|
||||
|
||||
# Case 5: a reuse:false item starts its own range but stays open for later auto items
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ranges_controller
|
||||
name: "open_prev"
|
||||
register_type: holding
|
||||
address: 0x3F
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ranges_controller
|
||||
name: "open_never"
|
||||
register_type: holding
|
||||
address: 0x40
|
||||
reuse_previous_range: false
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ranges_controller
|
||||
name: "open_tagalong"
|
||||
register_type: holding
|
||||
address: 0x41
|
||||
|
||||
# Case 6: two sensors on the same register share one read
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ranges_controller
|
||||
name: "shared_lo"
|
||||
register_type: holding
|
||||
address: 0x50
|
||||
bitmask: 0x00FF
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ranges_controller
|
||||
name: "shared_hi"
|
||||
register_type: holding
|
||||
address: 0x50
|
||||
bitmask: 0xFF00
|
||||
|
||||
# Case 10 (text block, see text_sensor below) shares the range with this word at 0x63
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ranges_controller
|
||||
name: "after_text"
|
||||
register_type: holding
|
||||
address: 0x63
|
||||
|
||||
# Case 11: response_size surplus — the device answers 4 bytes for this single register, so the
|
||||
# following sensor's data sits 2 bytes later than its address alone implies. Joining past a
|
||||
# non-standard response_size takes an explicit reuse_previous_range: true.
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ranges_controller
|
||||
name: "surplus"
|
||||
register_type: holding
|
||||
address: 0x70
|
||||
response_size: 4
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ranges_controller
|
||||
name: "after_surplus"
|
||||
register_type: holding
|
||||
address: 0x71
|
||||
reuse_previous_range: true
|
||||
|
||||
# Case 13: auto never joins past a response_size register — despite adjacency these poll separately
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ranges_controller
|
||||
name: "surplus_split"
|
||||
register_type: holding
|
||||
address: 0x90
|
||||
response_size: 4
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ranges_controller
|
||||
name: "after_surplus_split"
|
||||
register_type: holding
|
||||
address: 0x91
|
||||
|
||||
# Case 12: RAW + response_size reads a block of ceil(8/2) = 4 registers
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ranges_controller
|
||||
name: "raw_block"
|
||||
register_type: holding
|
||||
address: 0x80
|
||||
value_type: RAW
|
||||
response_size: 8
|
||||
lambda: |-
|
||||
return (float) data.size();
|
||||
|
||||
text_sensor:
|
||||
# Case 10: text sensor reads 3 registers (response_size 6)
|
||||
- platform: modbus_controller
|
||||
modbus_controller_id: ranges_controller
|
||||
name: "text_block"
|
||||
register_type: holding
|
||||
address: 0x60
|
||||
response_size: 6
|
||||
raw_encode: NONE
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: "Start Scenario"
|
||||
id: start_scenario_btn
|
||||
on_press:
|
||||
- lambda: |-
|
||||
id(virtual_uart_dev).start_scenario();
|
||||
id(ranges_controller).set_update_interval(1000);
|
||||
id(ranges_controller).start_poller();
|
||||
@@ -100,7 +100,7 @@ switch:
|
||||
offset: 2
|
||||
assumed_state: true
|
||||
# A holding-register switch that READS its state. Byte offset 6 -> register 0x10 + 6/2 = 0x13. Post-fix
|
||||
# the switch itself resolves to 0x13 (whole registers fold into the address, residual byte stays) and
|
||||
# the switch itself resolves to 0x13 (the even byte offset folds into the address as whole registers) and
|
||||
# joins the 0x10..0x13 range, so no separate 0x13 sensor is needed. Pre-fix the whole byte offset folds
|
||||
# into the address (0x16), where the server answers ILLEGAL_DATA_ADDRESS and the switch never publishes.
|
||||
- platform: modbus_controller
|
||||
|
||||
@@ -32,9 +32,9 @@ uart_mock:
|
||||
# duplicate or overlapping range would put an extra frame on the bus and fail to match.
|
||||
- expect_tx: [0x01, 0x03, 0x90, 0x01, 0x00, 0x02, 0xB8, 0xCB] # Read holding 0x9001 count 2 on device 1
|
||||
inject_rx: [0x01, 0x03, 0x04, 0x03, 0x97, 0x02, 0x91, 0x8B, 0x57] # 0x9001=0x0397, 0x9002=0x0291
|
||||
# A force_new_range sensor at a HIGH address (0x30) sorts before the plain sensor at a LOW address
|
||||
# (0x10). The two must poll as separate ranges: the covered branch's lower-bound check prevents the
|
||||
# 0x10 sensor from being absorbed into the forced 0x30 range with a wrapped byte offset.
|
||||
# A sensor at 0x30 with the deprecated force_new_range (migrates to reuse_previous_range: false)
|
||||
# and a plain sensor at 0x10. The two must poll as separate ranges: the 0x10 sensor must not be
|
||||
# absorbed into the isolated 0x30 range.
|
||||
- expect_tx: [0x01, 0x03, 0x00, 0x30, 0x00, 0x01, 0x84, 0x05] # Read holding 0x30 count 1 (forced range)
|
||||
inject_rx: [0x01, 0x03, 0x02, 0x01, 0x11, 0x79, 0xD8] # 0x30 = 0x0111 = 273
|
||||
- expect_tx: [0x01, 0x03, 0x00, 0x10, 0x00, 0x01, 0x85, 0xCF] # Read holding 0x10 count 1 (own range)
|
||||
@@ -87,7 +87,7 @@ sensor:
|
||||
register_type: holding
|
||||
value_type: U_WORD
|
||||
modbus_controller_id: modbus_controller_ok
|
||||
# Forced sensor at a high address: sorts first, opens its own isolated range
|
||||
# Isolated sensor (deprecated spelling, migrates to reuse_previous_range: false): own range
|
||||
- platform: modbus_controller
|
||||
name: "forced_high"
|
||||
address: 0x30
|
||||
@@ -95,7 +95,7 @@ sensor:
|
||||
value_type: U_WORD
|
||||
force_new_range: true
|
||||
modbus_controller_id: modbus_controller_ok
|
||||
# Plain sensor at a lower address: must get its own range, never absorbed into the forced one
|
||||
# Plain sensor at a lower address: must get its own range, never absorbed into the isolated one
|
||||
- platform: modbus_controller
|
||||
name: "plain_low"
|
||||
address: 0x10
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"tests/integration/test_action_concurrent_reentry.py": 57.91,
|
||||
"tests/integration/test_addressable_light_transition.py": 21.25,
|
||||
"tests/integration/test_alarm_control_panel_state_transitions.py": 70.71,
|
||||
"tests/integration/test_api_action_metadata.py": 66.6,
|
||||
"tests/integration/test_api_action_responses.py": 36.1,
|
||||
"tests/integration/test_api_action_timeout.py": 68.86,
|
||||
"tests/integration/test_api_conditional_memory.py": 15.48,
|
||||
"tests/integration/test_api_custom_services.py": 18.77,
|
||||
"tests/integration/test_api_get_time_response_timezone.py": 21.08,
|
||||
"tests/integration/test_api_homeassistant.py": 65.59,
|
||||
"tests/integration/test_api_homeassistant_action_no_subscriber.py": 18.44,
|
||||
"tests/integration/test_api_homeassistant_binary_sensor_initial_state.py": 15.05,
|
||||
"tests/integration/test_api_list_entities_backpressure.py": 13.88,
|
||||
"tests/integration/test_api_message_size_batching.py": 29.98,
|
||||
"tests/integration/test_api_reboot_timeout.py": 16.05,
|
||||
"tests/integration/test_api_string_lambda.py": 15.31,
|
||||
"tests/integration/test_api_vv_logging.py": 19.28,
|
||||
"tests/integration/test_api_zero_psk_provisioning.py": 31.5,
|
||||
"tests/integration/test_areas_and_devices.py": 24.95,
|
||||
"tests/integration/test_automation_wait_actions.py": 20.92,
|
||||
"tests/integration/test_automations.py": 35.19,
|
||||
"tests/integration/test_batch_delay_zero_rapid_transitions.py": 17.99,
|
||||
"tests/integration/test_binary_sensor_autorepeat_filter.py": 20.39,
|
||||
"tests/integration/test_binary_sensor_invalidate_state.py": 18.41,
|
||||
"tests/integration/test_blocking_warning_log_time_not_charged_to_next_operation.py": 24.69,
|
||||
"tests/integration/test_build_info.py": 18.7,
|
||||
"tests/integration/test_camera_mock.py": 16.23,
|
||||
"tests/integration/test_climate_control_action.py": 21.14,
|
||||
"tests/integration/test_climate_custom_modes.py": 20.74,
|
||||
"tests/integration/test_continuation_actions.py": 16.81,
|
||||
"tests/integration/test_cover_control_action.py": 20.34,
|
||||
"tests/integration/test_crc8_helper.py": 9.36,
|
||||
"tests/integration/test_device_id_in_state.py": 44.67,
|
||||
"tests/integration/test_duplicate_entities.py": 23.58,
|
||||
"tests/integration/test_entity_icon.py": 34.35,
|
||||
"tests/integration/test_fan_turn_on_action.py": 24.23,
|
||||
"tests/integration/test_fnv1_hash_object_id.py": 16.21,
|
||||
"tests/integration/test_fnv1a_hash.py": 13.38,
|
||||
"tests/integration/test_gpio_expander_cache.py": 13.06,
|
||||
"tests/integration/test_host_logger_thread_safety.py": 23.66,
|
||||
"tests/integration/test_host_mode_basic.py": 8.01,
|
||||
"tests/integration/test_host_mode_batch_delay.py": 21.0,
|
||||
"tests/integration/test_host_mode_climate_basic_state.py": 22.14,
|
||||
"tests/integration/test_host_mode_climate_control.py": 19.39,
|
||||
"tests/integration/test_host_mode_empty_string_options.py": 21.76,
|
||||
"tests/integration/test_host_mode_entity_fields.py": 29.61,
|
||||
"tests/integration/test_host_mode_fan_preset.py": 20.01,
|
||||
"tests/integration/test_host_mode_many_entities.py": 39.08,
|
||||
"tests/integration/test_host_mode_many_entities_multiple_connections.py": 23.92,
|
||||
"tests/integration/test_host_mode_noise_encryption.py": 42.42,
|
||||
"tests/integration/test_host_mode_reconnect.py": 3.41,
|
||||
"tests/integration/test_host_mode_sensor.py": 22.96,
|
||||
"tests/integration/test_host_ota.py": 29.5,
|
||||
"tests/integration/test_host_preferences.py": 16.06,
|
||||
"tests/integration/test_host_preferences_suspend_resume.py": 18.71,
|
||||
"tests/integration/test_improv_serial_uart.py": 20.22,
|
||||
"tests/integration/test_large_message_batching.py": 26.56,
|
||||
"tests/integration/test_legacy_area.py": 22.72,
|
||||
"tests/integration/test_legacy_climate_compat.py": 14.13,
|
||||
"tests/integration/test_legacy_fan_compat.py": 14.33,
|
||||
"tests/integration/test_light_automations.py": 18.81,
|
||||
"tests/integration/test_light_binary_effect_off_phase.py": 8.38,
|
||||
"tests/integration/test_light_calls.py": 21.88,
|
||||
"tests/integration/test_light_constant_brightness.py": 59.45,
|
||||
"tests/integration/test_light_control_action.py": 31.91,
|
||||
"tests/integration/test_light_dim_relative_action.py": 14.43,
|
||||
"tests/integration/test_light_effect_zero_brightness.py": 25.05,
|
||||
"tests/integration/test_light_initial_state.py": 18.97,
|
||||
"tests/integration/test_light_toggle_action.py": 17.44,
|
||||
"tests/integration/test_lock_automations.py": 18.9,
|
||||
"tests/integration/test_logger_buffered_recursion_guard.py": 18.2,
|
||||
"tests/integration/test_loop_disable_enable.py": 63.35,
|
||||
"tests/integration/test_loop_interval_decoupling.py": 17.7,
|
||||
"tests/integration/test_loop_interval_default_not_pulled_forward.py": 21.56,
|
||||
"tests/integration/test_micros_to_millis.py": 15.89,
|
||||
"tests/integration/test_multi_click_trigger.py": 17.23,
|
||||
"tests/integration/test_multi_device_preferences.py": 19.4,
|
||||
"tests/integration/test_noise_encryption_key_protection.py": 72.59,
|
||||
"tests/integration/test_object_id_api_verification.py": 19.22,
|
||||
"tests/integration/test_object_id_friendly_name_no_mac_suffix.py": 16.77,
|
||||
"tests/integration/test_object_id_no_friendly_name.py": 45.8,
|
||||
"tests/integration/test_online_image_auto_detects_image_bmp_mime.py": 86.73,
|
||||
"tests/integration/test_online_image_auto_detects_redirected_image_bmp_mime.py": 40.4,
|
||||
"tests/integration/test_online_image_bmp.py": 37.24,
|
||||
"tests/integration/test_oversized_payloads.py": 55.75,
|
||||
"tests/integration/test_preference_key_stability.py": 25.49,
|
||||
"tests/integration/test_runtime_stats.py": 29.81,
|
||||
"tests/integration/test_safe_mode_loop_runs.py": 6.26,
|
||||
"tests/integration/test_scheduler_blocking_warning.py": 37.98,
|
||||
"tests/integration/test_scheduler_bulk_cleanup.py": 18.67,
|
||||
"tests/integration/test_scheduler_defer_cancel.py": 18.46,
|
||||
"tests/integration/test_scheduler_defer_cancel_regular.py": 16.34,
|
||||
"tests/integration/test_scheduler_defer_fifo_simple.py": 18.26,
|
||||
"tests/integration/test_scheduler_defer_stress.py": 17.74,
|
||||
"tests/integration/test_scheduler_heap_stress.py": 3.89,
|
||||
"tests/integration/test_scheduler_internal_id_no_collision.py": 20.01,
|
||||
"tests/integration/test_scheduler_interval_reschedule.py": 16.29,
|
||||
"tests/integration/test_scheduler_interval_zero_coerced.py": 16.09,
|
||||
"tests/integration/test_scheduler_null_name.py": 14.69,
|
||||
"tests/integration/test_scheduler_numeric_id_test.py": 17.08,
|
||||
"tests/integration/test_scheduler_pool.py": 19.88,
|
||||
"tests/integration/test_scheduler_rapid_cancellation.py": 4.42,
|
||||
"tests/integration/test_scheduler_recursive_timeout.py": 4.3,
|
||||
"tests/integration/test_scheduler_removed_item_race.py": 15.49,
|
||||
"tests/integration/test_scheduler_self_keyed.py": 25.77,
|
||||
"tests/integration/test_scheduler_simultaneous_callbacks.py": 14.84,
|
||||
"tests/integration/test_scheduler_string_test.py": 15.42,
|
||||
"tests/integration/test_script_array_params.py": 12.73,
|
||||
"tests/integration/test_script_delay_params.py": 12.69,
|
||||
"tests/integration/test_script_queued.py": 20.38,
|
||||
"tests/integration/test_script_queued_idle_loop.py": 25.06,
|
||||
"tests/integration/test_script_wait_on_boot.py": 15.67,
|
||||
"tests/integration/test_select_stringref_trigger.py": 19.48,
|
||||
"tests/integration/test_sensor_filters_delta.py": 27.62,
|
||||
"tests/integration/test_sensor_filters_ring_buffer.py": 20.27,
|
||||
"tests/integration/test_sensor_filters_sliding_window.py": 56.28,
|
||||
"tests/integration/test_sensor_filters_value_list.py": 20.6,
|
||||
"tests/integration/test_sensor_timeout_filter.py": 22.21,
|
||||
"tests/integration/test_socket_wake_gate_tcp.py": 16.37,
|
||||
"tests/integration/test_status_flags.py": 29.68,
|
||||
"tests/integration/test_strftime_to.py": 17.42,
|
||||
"tests/integration/test_syslog.py": 18.39,
|
||||
"tests/integration/test_template_alarm_control_panel_many_sensors.py": 25.61,
|
||||
"tests/integration/test_template_text_save.py": 19.16,
|
||||
"tests/integration/test_text_command.py": 16.43,
|
||||
"tests/integration/test_text_sensor_raw_state.py": 17.19,
|
||||
"tests/integration/test_uart_mock_ld2410.py": 37.0,
|
||||
"tests/integration/test_uart_mock_ld2412.py": 40.82,
|
||||
"tests/integration/test_uart_mock_ld2420.py": 32.7,
|
||||
"tests/integration/test_uart_mock_ld2450.py": 32.84,
|
||||
"tests/integration/test_uart_mock_modbus.py": 548.87,
|
||||
"tests/integration/test_udp.py": 16.67,
|
||||
"tests/integration/test_use_address_runtime.py": 27.26,
|
||||
"tests/integration/test_valve_control_action.py": 24.58,
|
||||
"tests/integration/test_varint_five_byte_device_id.py": 22.5,
|
||||
"tests/integration/test_wait_until_mid_loop_timing.py": 22.05,
|
||||
"tests/integration/test_wait_until_on_boot.py": 10.37,
|
||||
"tests/integration/test_wait_until_ordering.py": 18.23,
|
||||
"tests/integration/test_wait_until_reentrant_restart.py": 19.35,
|
||||
"tests/integration/test_wake_loop_forces_phase_b.py": 17.83,
|
||||
"tests/integration/test_water_heater_template.py": 25.7
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Helpers for asserting on log output in integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
|
||||
class LineWaiter:
|
||||
"""Collects log lines and lets a test await one containing all needles.
|
||||
|
||||
Pass ``callback`` as ``run_compiled``'s ``line_callback``; the callback runs
|
||||
on the test's own event loop, so futures are resolved directly. Only one
|
||||
``wait_for`` may be outstanding at a time (tests await sequentially).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.lines: list[str] = []
|
||||
self._needles: tuple[str, ...] = ()
|
||||
self._future: asyncio.Future | None = None
|
||||
|
||||
def callback(self, line: str) -> None:
|
||||
self.lines.append(line)
|
||||
if (
|
||||
self._future is not None
|
||||
and not self._future.done()
|
||||
and all(n in line for n in self._needles)
|
||||
):
|
||||
self._future.set_result(line)
|
||||
self._future = None
|
||||
|
||||
async def wait_for_each(self, *texts: str, timeout: float = 10.0) -> None:
|
||||
"""Await each text in turn; a text may match a line already received."""
|
||||
for text in texts:
|
||||
await self.wait_for(text, timeout=timeout)
|
||||
|
||||
async def wait_for(self, *needles: str, timeout: float = 10.0) -> str:
|
||||
"""Return the first line, past or future, containing every needle."""
|
||||
for line in self.lines:
|
||||
if all(n in line for n in needles):
|
||||
return line
|
||||
assert self._future is None or self._future.done(), "concurrent wait_for"
|
||||
self._needles = needles
|
||||
self._future = asyncio.get_running_loop().create_future()
|
||||
try:
|
||||
return await asyncio.wait_for(self._future, timeout)
|
||||
finally:
|
||||
self._future = None
|
||||
self._needles = ()
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Integration test for user-defined action field metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.helpers import fnv1_hash
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_action_metadata(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Action and argument metadata reach the client and the actions still run."""
|
||||
loop = asyncio.get_running_loop()
|
||||
buzzer_called = loop.create_future()
|
||||
plain_called = loop.create_future()
|
||||
buzzer_pattern = re.compile(r"Buzzer: two_short")
|
||||
plain_pattern = re.compile(r"Plain action called")
|
||||
|
||||
def check_output(line: str) -> None:
|
||||
if not buzzer_called.done() and buzzer_pattern.search(line):
|
||||
buzzer_called.set_result(True)
|
||||
elif not plain_called.done() and plain_pattern.search(line):
|
||||
plain_called.set_result(True)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=check_output),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
_, services = await client.list_entities_services()
|
||||
|
||||
by_name = {service.name: service for service in services}
|
||||
assert set(by_name) == {"play_buzzer", "plain_action"}
|
||||
# Keys are hashed at codegen time and must match what the client expects
|
||||
for name, service in by_name.items():
|
||||
assert service.key == fnv1_hash(name), name
|
||||
|
||||
buzzer = by_name["play_buzzer"]
|
||||
assert buzzer.description == "Play an RTTTL melody on the buzzer"
|
||||
args = {arg.name: arg for arg in buzzer.args}
|
||||
assert args["song_str"].description == "RTTTL melody string"
|
||||
assert args["song_str"].example == "two_short:d=4,o=5,b=100:16e6,16e6"
|
||||
# An arg without metadata sends empty strings
|
||||
assert args["volume"].description == ""
|
||||
assert args["volume"].example == ""
|
||||
|
||||
# An action without metadata sends empty strings
|
||||
plain = by_name["plain_action"]
|
||||
assert plain.description == ""
|
||||
assert plain.args[0].description == ""
|
||||
|
||||
await client.execute_service(
|
||||
buzzer, {"song_str": "two_short:d=4,o=5,b=100:16e6,16e6", "volume": 3}
|
||||
)
|
||||
await client.execute_service(plain, {"value": 1})
|
||||
await asyncio.wait_for(buzzer_called, timeout=5.0)
|
||||
await asyncio.wait_for(plain_called, timeout=5.0)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Test on_press/on_release for homeassistant binary sensors on the first HA state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from .log_utils import LineWaiter
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
ENTITIES = (
|
||||
"binary_sensor.initial_on",
|
||||
"binary_sensor.default",
|
||||
"binary_sensor.unavailable_first",
|
||||
"binary_sensor.initial_off",
|
||||
"binary_sensor.default_unavail",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_homeassistant_binary_sensor_initial_state(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""The first state from HA fires on_press only with trigger_on_initial_state."""
|
||||
loop = asyncio.get_running_loop()
|
||||
waiter = LineWaiter()
|
||||
subscribed: set[str] = set()
|
||||
all_subscribed = loop.create_future()
|
||||
|
||||
def on_state_sub(entity_id: str, _attribute: str | None) -> None:
|
||||
subscribed.add(entity_id)
|
||||
if not all_subscribed.done() and subscribed.issuperset(ENTITIES):
|
||||
all_subscribed.set_result(None)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=waiter.callback),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
client.subscribe_home_assistant_states(on_state_sub)
|
||||
try:
|
||||
await asyncio.wait_for(all_subscribed, timeout=5.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(f"never subscribed: {set(ENTITIES) - subscribed}")
|
||||
|
||||
# First state from HA
|
||||
client.send_home_assistant_state("binary_sensor.initial_on", "", "on")
|
||||
client.send_home_assistant_state("binary_sensor.default", "", "on")
|
||||
client.send_home_assistant_state(
|
||||
"binary_sensor.unavailable_first", "", "unavailable"
|
||||
)
|
||||
client.send_home_assistant_state("binary_sensor.unavailable_first", "", "on")
|
||||
client.send_home_assistant_state(
|
||||
"binary_sensor.default_unavail", "", "unavailable"
|
||||
)
|
||||
client.send_home_assistant_state("binary_sensor.default_unavail", "", "on")
|
||||
client.send_home_assistant_state("binary_sensor.initial_off", "", "off")
|
||||
|
||||
await waiter.wait_for("initial_on on_press", timeout=5.0)
|
||||
await waiter.wait_for("unavailable_first on_press", timeout=5.0)
|
||||
# Pin that the 'unavailable' message actually arrived and was rejected
|
||||
await waiter.wait_for("Can't convert 'unavailable'", timeout=5.0)
|
||||
# initial_off is the last state sent, so this wait also proves the
|
||||
# earlier 'default' initial state was already processed
|
||||
await waiter.wait_for("initial_off on_release", timeout=5.0)
|
||||
# Both 'unavailable' senders must have been seen and rejected
|
||||
assert sum("Can't convert 'unavailable'" in line for line in waiter.lines) == 2
|
||||
# Guard every phase 2 needle against being satisfied by a stale
|
||||
# phase 1 line, and pin that the initial states fired nothing else
|
||||
for absent in (
|
||||
"initial_on on_release",
|
||||
"default on_press",
|
||||
"default on_release",
|
||||
"default_unavail on_press",
|
||||
"default_unavail on_release",
|
||||
"unavailable_first on_release",
|
||||
"initial_off on_press",
|
||||
):
|
||||
assert not any(absent in line for line in waiter.lines), (
|
||||
f"unexpected trigger before the second state change: {absent}"
|
||||
)
|
||||
|
||||
# A later change fires for all of them
|
||||
client.send_home_assistant_state("binary_sensor.initial_on", "", "off")
|
||||
client.send_home_assistant_state("binary_sensor.default", "", "off")
|
||||
client.send_home_assistant_state("binary_sensor.unavailable_first", "", "off")
|
||||
client.send_home_assistant_state("binary_sensor.initial_off", "", "on")
|
||||
client.send_home_assistant_state("binary_sensor.default_unavail", "", "off")
|
||||
await waiter.wait_for_each(
|
||||
"initial_on on_release",
|
||||
"default on_release",
|
||||
"default_unavail on_release",
|
||||
"unavailable_first on_release",
|
||||
"initial_off on_press",
|
||||
timeout=5.0,
|
||||
)
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
import functools
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
@@ -111,6 +112,62 @@ async def test_host_ota_self_update(
|
||||
assert proc.pid == pid_before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_ota_encrypted(
|
||||
yaml_config: str,
|
||||
write_yaml_config: ConfigWriter,
|
||||
compile_esphome: CompileFunction,
|
||||
reserved_tcp_port: tuple[int, socket.socket],
|
||||
) -> None:
|
||||
"""Encrypted self-OTA succeeds; a plaintext upload to the same device fails."""
|
||||
pytest.importorskip("aioesphomeapi.noise")
|
||||
noise_psk = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
api_port, api_socket = reserved_tcp_port
|
||||
with _reserve_port() as (ota_port, ota_socket):
|
||||
yaml_config = yaml_config.replace("__OTA_PORT__", str(ota_port))
|
||||
config_path = await write_yaml_config(yaml_config)
|
||||
binary_path = await compile_esphome(config_path)
|
||||
api_socket.close()
|
||||
ota_socket.close()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
rebooted = loop.create_future()
|
||||
|
||||
def on_log(line: str) -> None:
|
||||
if not rebooted.done() and "Rebooting safely" in line:
|
||||
rebooted.set_result(True)
|
||||
|
||||
async with run_binary(binary_path, line_callback=on_log) as (proc, _lines):
|
||||
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
|
||||
pid_before = proc.pid
|
||||
|
||||
# A plaintext upload must be refused with the device unharmed
|
||||
rc, _ = await loop.run_in_executor(
|
||||
None, espota2.run_ota, LOCALHOST, ota_port, None, binary_path
|
||||
)
|
||||
assert rc == 1, "plaintext upload to an encrypted device must fail"
|
||||
await asyncio.sleep(0.5)
|
||||
assert proc.returncode is None, "process died on rejected plaintext OTA"
|
||||
|
||||
# The encrypted upload goes through and the device re-execs
|
||||
rc, _ = await loop.run_in_executor(
|
||||
None,
|
||||
functools.partial(
|
||||
espota2.run_ota,
|
||||
LOCALHOST,
|
||||
ota_port,
|
||||
None,
|
||||
binary_path,
|
||||
noise_psk=noise_psk,
|
||||
),
|
||||
)
|
||||
assert rc == 0, "encrypted OTA reported failure"
|
||||
await asyncio.wait_for(rebooted, timeout=10.0)
|
||||
await _wait_for_port(LOCALHOST, api_port, PORT_WAIT_TIMEOUT)
|
||||
assert proc.returncode is None, "process exited instead of execing"
|
||||
assert proc.pid == pid_before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_host_ota_rejects_garbage(
|
||||
yaml_config: str,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Integration test for improv_serial over a mocked UART bus.
|
||||
|
||||
Drives the improv serial protocol end to end on the host platform:
|
||||
the fixture wires improv_serial to a uart_mock bus and shadows the wifi
|
||||
component with a host stub. The test injects improv frames through an API
|
||||
action and asserts on the framed responses that uart_mock logs as TX lines.
|
||||
|
||||
Covered:
|
||||
1. Get Current State reports AUTHORIZED
|
||||
2. Get Device Info returns the firmware/device info RPC response
|
||||
3. Get Wi-Fi Networks returns deduplicated scan results and a terminator
|
||||
4. Wi-Fi Settings provisions: saves credentials and reports PROVISIONED
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from .log_utils import LineWaiter
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
# Improv serial framing (improv_serial_component.h)
|
||||
IMPROV_HEADER = b"IMPROV"
|
||||
IMPROV_VERSION = 1
|
||||
TYPE_CURRENT_STATE = 0x01
|
||||
TYPE_RPC = 0x03
|
||||
TYPE_RPC_RESPONSE = 0x04
|
||||
|
||||
# improv::Command values
|
||||
CMD_GET_CURRENT_STATE = 0x02
|
||||
CMD_GET_DEVICE_INFO = 0x03
|
||||
CMD_GET_WIFI_NETWORKS = 0x04
|
||||
CMD_WIFI_SETTINGS = 0x01
|
||||
|
||||
|
||||
def build_rpc_frame(command: int, data: bytes = b"") -> list[int]:
|
||||
"""Build a full improv serial frame carrying one RPC command."""
|
||||
payload = bytes([command, len(data)]) + data
|
||||
frame = IMPROV_HEADER + bytes([IMPROV_VERSION, TYPE_RPC, len(payload)]) + payload
|
||||
checksum = sum(frame) & 0xFF
|
||||
return list(frame + bytes([checksum]) + b"\n")
|
||||
|
||||
|
||||
def state_frame_hex(state: int) -> str:
|
||||
"""Full 12 byte current-state frame as hex, checksum and newline included."""
|
||||
frame = IMPROV_HEADER + bytes([IMPROV_VERSION, TYPE_CURRENT_STATE, 1, state])
|
||||
checksum = sum(frame) & 0xFF
|
||||
return ":".join(f"{b:02X}" for b in frame + bytes([checksum]) + b"\n")
|
||||
|
||||
|
||||
def rpc_footer_hex(payload: bytes) -> str:
|
||||
"""Checksum and newline footer written after an RPC response payload."""
|
||||
header = IMPROV_HEADER + bytes([IMPROV_VERSION, TYPE_RPC_RESPONSE, len(payload)])
|
||||
checksum = (sum(header) + sum(payload)) & 0xFF
|
||||
return f"{checksum:02X}:0A"
|
||||
|
||||
|
||||
def wifi_settings_data(ssid: str, password: str) -> bytes:
|
||||
ssid_b = ssid.encode()
|
||||
pass_b = password.encode()
|
||||
return bytes([len(ssid_b)]) + ssid_b + bytes([len(pass_b)]) + pass_b
|
||||
|
||||
|
||||
def hex_of(text: str) -> str:
|
||||
"""Colon separated uppercase hex as logged by format_hex_pretty."""
|
||||
return ":".join(f"{b:02X}" for b in text.encode())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_improv_serial_uart(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
waiter = LineWaiter()
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=waiter.callback),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
_entities, services = await client.list_entities_services()
|
||||
inject = next(s for s in services if s.name == "uart_inject")
|
||||
|
||||
# 1. Get Current State: expect the complete current-state frame reporting
|
||||
# AUTHORIZED (0x02), checksum and newline included
|
||||
await client.execute_service(
|
||||
inject, {"payload": build_rpc_frame(CMD_GET_CURRENT_STATE)}
|
||||
)
|
||||
await waiter.wait_for("uart_mock", f"TX 12 bytes: {state_frame_hex(0x02)}")
|
||||
|
||||
# 2. Get Device Info: the always logged 9 byte response header, then the
|
||||
# payload with the firmware name (must stay under uart_mock's 64 byte
|
||||
# hex dump cap or the payload line reads "too large to log")
|
||||
await client.execute_service(
|
||||
inject, {"payload": build_rpc_frame(CMD_GET_DEVICE_INFO)}
|
||||
)
|
||||
await waiter.wait_for("uart_mock", "TX 9 bytes: 49:4D:50:52:4F:56:01:04")
|
||||
await waiter.wait_for("uart_mock", "TX ", hex_of("ESPHome"))
|
||||
|
||||
# 3. Get Wi-Fi Networks: stub scan has TestNet twice (dedup keeps the
|
||||
# stronger), OpenNet, and a hidden entry (filtered). Expect one response
|
||||
# per visible network plus the empty terminator.
|
||||
await client.execute_service(
|
||||
inject, {"payload": build_rpc_frame(CMD_GET_WIFI_NETWORKS)}
|
||||
)
|
||||
await waiter.wait_for("uart_mock", hex_of("TestNet"))
|
||||
await waiter.wait_for("uart_mock", hex_of("OpenNet"))
|
||||
# Terminator: all three writes of the response frame; 9 byte header,
|
||||
# payload [0x04, 0x00, 0x00], then the checksum and newline footer
|
||||
await waiter.wait_for("uart_mock", "TX 9 bytes: 49:4D:50:52:4F:56:01:04:03")
|
||||
await waiter.wait_for("uart_mock", "TX 3 bytes: 04:00:00")
|
||||
await waiter.wait_for(
|
||||
"uart_mock", f"TX 2 bytes: {rpc_footer_hex(bytes([0x04, 0x00, 0x00]))}"
|
||||
)
|
||||
testnet_count = sum(
|
||||
1
|
||||
for line in waiter.lines
|
||||
if "uart_mock" in line and "TX " in line and hex_of("TestNet") in line
|
||||
)
|
||||
assert testnet_count == 1, (
|
||||
f"Duplicate scan entry not deduplicated: {testnet_count} TestNet responses"
|
||||
)
|
||||
|
||||
# 4. Wi-Fi Settings: stub connects immediately; expect the credentials
|
||||
# saved, the PROVISIONED state frame (0x04), and the settings response
|
||||
await client.execute_service(
|
||||
inject,
|
||||
{
|
||||
"payload": build_rpc_frame(
|
||||
CMD_WIFI_SETTINGS, wifi_settings_data("NewNet", "secret123")
|
||||
)
|
||||
},
|
||||
)
|
||||
await waiter.wait_for("save_wifi_sta ssid=NewNet")
|
||||
await waiter.wait_for("uart_mock", f"TX 12 bytes: {state_frame_hex(0x04)}")
|
||||
# Settings RPC response carries the formatted next_url and its footer
|
||||
next_url = b"https://example.com/?device=improv-uart"
|
||||
payload = (
|
||||
bytes([CMD_WIFI_SETTINGS, len(next_url) + 1, len(next_url)])
|
||||
+ next_url
|
||||
+ b"\x00"
|
||||
)
|
||||
await waiter.wait_for(
|
||||
"uart_mock",
|
||||
f"TX {len(payload)} bytes: " + ":".join(f"{b:02X}" for b in payload),
|
||||
)
|
||||
await waiter.wait_for("uart_mock", f"TX 2 bytes: {rpc_footer_hex(payload)}")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user