Merge branch 'dev' into jesserockz-2026-503

This commit is contained in:
Jesse Hills
2026-09-02 11:12:53 +12:00
committed by GitHub
1889 changed files with 60990 additions and 21606 deletions
@@ -3,7 +3,7 @@ light:
id: led_matrix_32x8
default_transition_length: 500ms
chipset: ws2812
rgb_order: GRB
channel_colors: GRB
num_leds: 256
pin: ${pin}
@@ -3,7 +3,7 @@ light:
id: led_matrix_32x8
default_transition_length: 500ms
chipset: ws2812
rgb_order: GRB
channel_colors: GRB
num_leds: 256
pin: ${pin}
@@ -0,0 +1,21 @@
# `platform: animation` entry exercising the shared `defaults:`/`files:` expansion.
display:
- platform: sdl
id: animation_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
image:
- platform: animation
defaults:
type: rgb565
transparency: opaque
resize: 50x50
files:
- id: platform_defaults_animation
file: $component_dir/anim.gif
- id: platform_defaults_animation_rgb
file: $component_dir/anim.apng
type: rgb
+13 -1
View File
@@ -9,6 +9,14 @@ esphome:
event: esphome.button_pressed
data:
message: Button was pressed
- homeassistant.event:
event: esphome.button_pressed_with_variables
data_template:
message: Button {{ button_name }} ({{ button_index }}) was pressed from {{ button_source }}
variables:
button_name: !lambda 'return std::string("test_button");'
button_index: !lambda 'return 1;'
button_source: static_value
- homeassistant.action:
action: notify.html5
data:
@@ -53,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!
+2 -1
View File
@@ -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
View File
@@ -4,4 +4,5 @@ media_source:
- platform: audio_http
id: audio_http_source
buffer_size: 100000
persistent_ring_buffer: true
task_stack_in_psram: true
@@ -1,6 +1,6 @@
light:
- platform: beken_spi_led_strip
rgb_order: GRB
channel_colors: GRB
pin: P16
num_leds: 30
chipset: ws2812
@@ -0,0 +1,10 @@
# The deprecated rgb_order / is_rgbw / is_wrgb keys, kept working until 2027.3.0.
# Config-only, and only one strip because P16 is the sole supported pin.
light:
- platform: beken_spi_led_strip
name: Legacy RGBW
pin: P16
num_leds: 30
chipset: sk6812
rgb_order: GRB
is_rgbw: true # -> GRBW
@@ -136,3 +136,19 @@ binary_sensor:
invalid_cooldown: 2s
then:
- logger.log: "Click with custom cooldown"
# Test on_click and on_double_click (compiles match_interval via
# USE_BINARY_SENSOR_CLICK_TRIGGER)
- platform: template
id: click_triggers
name: "Click Triggers"
on_click:
min_length: 50ms
max_length: 350ms
then:
- logger.log: "Clicked"
on_double_click:
min_length: 50ms
max_length: 350ms
then:
- logger.log: "Double clicked"
+11
View File
@@ -0,0 +1,11 @@
import esphome.codegen as cg
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# No host camera platform exists to emit USE_CAMERA; define it here so
# the iterator CAMERA state compiles into the test binary.
async def to_code_testing(config):
cg.add_define("USE_CAMERA")
manifest.to_code = to_code_testing
@@ -0,0 +1,79 @@
#include <gtest/gtest.h>
#include "esphome/core/component_iterator.h"
#ifdef USE_CAMERA
#include "esphome/components/camera/camera.h"
namespace esphome::testing {
class StubCamera : public camera::Camera {
public:
void add_listener(camera::CameraListener *listener) override {}
camera::CameraImageReader *create_image_reader() override { return nullptr; }
void request_image(camera::CameraRequester requester) override {}
void start_stream(camera::CameraRequester requester) override {}
void stop_stream(camera::CameraRequester requester) override {}
};
// Iterator that accepts everything except the camera, which can refuse a
// configurable number of times. The CAMERA state is a singleton path
// distinct from process_platform_item_; this pins the same contract:
// a refused camera is re-offered, never skipped.
class CameraRefusingIterator : public ComponentIterator {
public:
// NOLINTBEGIN(bugprone-macro-parentheses)
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
bool on_##singular(type *obj) override { return true; }
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
ENTITY_TYPE_(type, singular, plural, count, upper)
#include "esphome/core/entity_types.h"
#undef ENTITY_TYPE_
#undef ENTITY_CONTROLLER_TYPE_
// NOLINTEND(bugprone-macro-parentheses)
bool on_camera(camera::Camera *obj) override {
this->camera_calls++;
if (this->camera_refusals > 0) {
this->camera_refusals--;
return false;
}
return true;
}
int camera_calls{0};
int camera_refusals{0};
};
// Far above the fixed number of iterator states
static constexpr size_t BIG_BUDGET = 1000;
class ComponentIteratorCameraTest : public ::testing::Test {
protected:
void SetUp() override {
// Constructing a Camera installs the process-wide singleton
static StubCamera stub_camera;
ASSERT_EQ(camera::Camera::instance(), &stub_camera);
}
};
TEST_F(ComponentIteratorCameraTest, RefusedCameraIsReofferedNotSkipped) {
CameraRefusingIterator it;
it.camera_refusals = 2;
it.begin();
// Runs until the camera refuses, which stops the pass
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.camera_calls, 1);
EXPECT_FALSE(it.completed());
// The camera is re-offered once per call, not skipped
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.camera_calls, 2);
EXPECT_FALSE(it.completed());
// Once accepted, the iteration completes
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_EQ(it.camera_calls, 3);
}
} // namespace esphome::testing
#endif // USE_CAMERA
+73
View File
@@ -0,0 +1,73 @@
#include <gtest/gtest.h>
#include "esphome/components/climate/climate.h"
namespace esphome::climate::testing {
// Minimal concrete Climate that offers a fixed set of modes, so the restore path can be exercised
// without any hardware or platform component.
class TestClimate : public Climate {
public:
ClimateTraits traits() override {
auto traits = ClimateTraits();
traits.set_supported_modes({CLIMATE_MODE_OFF, CLIMATE_MODE_COOL});
traits.set_supported_fan_modes({CLIMATE_FAN_LOW, CLIMATE_FAN_HIGH});
return traits;
}
protected:
void control(const ClimateCall &call) override {}
};
TEST(ClimateRestoreStateTest, RestoresASupportedMode) {
TestClimate climate;
// Value-initialized: several members (mode, swing_mode, the temperature union) have no default
// member initializer, so leaving the {} off would read indeterminate values.
ClimateDeviceRestoreState state{};
state.mode = CLIMATE_MODE_COOL;
state.apply(&climate);
EXPECT_EQ(climate.mode, CLIMATE_MODE_COOL);
}
TEST(ClimateRestoreStateTest, DoesNotRestoreAnUnsupportedMode) {
TestClimate climate;
ClimateDeviceRestoreState state{};
state.mode = CLIMATE_MODE_HEAT;
state.apply(&climate);
// The device never advertised HEAT, so the mode stays where it was.
EXPECT_EQ(climate.mode, CLIMATE_MODE_OFF);
}
TEST(ClimateRestoreStateTest, LeavesTheCurrentModeAloneRatherThanForcingOff) {
TestClimate climate;
// apply() is public and nothing restricts it to setup(), so the entity is not necessarily off
// when an unsupported mode is dropped. It keeps what it had rather than being forced to OFF.
climate.mode = CLIMATE_MODE_COOL;
ClimateDeviceRestoreState state{};
state.mode = CLIMATE_MODE_HEAT;
state.apply(&climate);
EXPECT_EQ(climate.mode, CLIMATE_MODE_COOL);
}
TEST(ClimateRestoreStateTest, KeepsRestoringTheOtherFieldsWhenTheModeIsDropped) {
TestClimate climate;
ClimateDeviceRestoreState state{};
state.mode = CLIMATE_MODE_HEAT;
state.target_temperature = 21.0f;
state.uses_custom_fan_mode = false;
state.fan_mode = CLIMATE_FAN_HIGH;
state.apply(&climate);
EXPECT_EQ(climate.mode, CLIMATE_MODE_OFF);
EXPECT_FLOAT_EQ(climate.target_temperature, 21.0f);
// Compared as an optional: this asserts both that the fan mode was restored and what it holds.
EXPECT_EQ(climate.fan_mode, CLIMATE_FAN_HIGH);
}
} // namespace esphome::climate::testing
@@ -12,5 +12,8 @@ climate:
- platform: climate_ir_lg
name: LG Climate
transmitter_id: xmitr
header_high: 3300us
header_low: 9840us
advanced_commands_support: true
sensor: climate_ir_lg_temp_sensor
humidity_sensor: humidity_sensor
+11
View File
@@ -0,0 +1,11 @@
# Pulls in sensor so entity iteration paths compile (USE_SENSOR);
# tests register their own instances. Plain yaml.safe_load, no ESPHome tags.
# An alphabetically-earlier component's sensor: block shadows this one in
# combined builds; the tests' sensor-count ASSERT catches a capacity drop.
sensor:
- platform: template
id: bench_sensor_a
name: "Bench A"
- platform: template
id: bench_sensor_b
name: "Bench B"
+46
View File
@@ -83,4 +83,50 @@ TEST(StaticVectorTest, ConvertingConstructorSameSize) {
EXPECT_EQ(dst[2], 3);
}
TEST(StringContainsIgnoreCaseTest, NullPointerAlwaysFalse) {
const char *haystack = nullptr;
const char *needle = nullptr;
EXPECT_FALSE(str_contains_ignore_case(haystack, needle));
EXPECT_FALSE(str_contains_ignore_case("Hello World", needle));
EXPECT_FALSE(str_contains_ignore_case(haystack, "anything"));
}
TEST(StringContainsIgnoreCaseTest, EmptySearchMatches) {
const char *haystack = "Hello World";
EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, ""));
}
TEST(StringContainsIgnoreCaseTest, MiscCaseMatches) {
const char *haystack = "Hello World";
EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "Hello"));
EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "hello"));
EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "HELLO"));
EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "hELLO"));
}
TEST(StringContainsIgnoreCaseTest, MiscNotMatching) {
const char *haystack = "Hello World";
// Expected to match
EXPECT_TRUE(str_contains_ignore_case_fallback(haystack, "Hell"));
// Expected not to match
EXPECT_FALSE(str_contains_ignore_case_fallback(haystack, "Heaven"));
EXPECT_FALSE(str_contains_ignore_case_fallback(haystack, "Hello!"));
EXPECT_FALSE(str_contains_ignore_case_fallback(haystack, "world!"));
}
TEST(StringContainsIgnoreCaseTest, FallbackMatchesLibc) {
const char *haystack = "Hello World";
for (const char *needle : {"", "Hello", "hELLO", "HELLO", "Hell", "world", "World", "Heaven", "Hello!", "d"}) {
EXPECT_EQ(str_contains_ignore_case_fallback(haystack, needle), str_contains_ignore_case(haystack, needle))
<< "needle: " << needle;
}
EXPECT_EQ(str_contains_ignore_case_fallback("", ""), str_contains_ignore_case("", ""));
EXPECT_EQ(str_contains_ignore_case_fallback("ab", "abc"), str_contains_ignore_case("ab", "abc"));
}
} // namespace esphome
@@ -0,0 +1,195 @@
#include <gtest/gtest.h>
#include "esphome/core/component_iterator.h"
#ifdef USE_SENSOR
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/application.h"
#endif
namespace esphome::testing {
// Iterator whose begin/end callbacks can refuse a configurable number of
// times; all entity callbacks accept (any registered entities are accepted).
class RefusingIterator : public ComponentIterator {
public:
// NOLINTBEGIN(bugprone-macro-parentheses)
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
bool on_##singular(type *obj) override { return true; }
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
ENTITY_TYPE_(type, singular, plural, count, upper)
#include "esphome/core/entity_types.h"
#undef ENTITY_TYPE_
#undef ENTITY_CONTROLLER_TYPE_
// NOLINTEND(bugprone-macro-parentheses)
bool on_begin() override { return step(this->begin_calls, this->begin_refusals); }
bool on_end() override { return step(this->end_calls, this->end_refusals); }
int begin_calls{0};
int end_calls{0};
int begin_refusals{0};
int end_refusals{0};
protected:
static bool step(int &calls, int &refusals) {
calls++;
if (refusals > 0) {
refusals--;
return false;
}
return true;
}
};
// Far above the fixed number of iterator states
static constexpr size_t BIG_BUDGET = 1000;
TEST(ComponentIterator, NotRunningMakesNoProgress) {
RefusingIterator it;
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_EQ(it.begin_calls, 0);
EXPECT_EQ(it.end_calls, 0);
}
TEST(ComponentIterator, CompletesInOneCallWithoutRefusals) {
RefusingIterator it;
it.begin();
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_EQ(it.begin_calls, 1);
EXPECT_EQ(it.end_calls, 1);
}
TEST(ComponentIterator, StepBudgetIsHonored) {
RefusingIterator it;
it.begin();
it.try_advance(1);
EXPECT_EQ(it.begin_calls, 1);
EXPECT_EQ(it.end_calls, 0);
EXPECT_FALSE(it.completed());
}
TEST(ComponentIterator, RefusedStepStopsBatchAndRetriesSameStep) {
RefusingIterator it;
it.end_refusals = 3;
it.begin();
// First call runs until the refused end step, which stops the pass
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.end_calls, 1);
EXPECT_FALSE(it.completed());
// The refused step is retried once per call, not skipped
it.try_advance(BIG_BUDGET);
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.end_calls, 3);
EXPECT_FALSE(it.completed());
// Once accepted, the iteration completes
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_EQ(it.end_calls, 4);
}
TEST(ComponentIterator, RefusedBeginStopsBatchAndRetries) {
RefusingIterator it;
it.begin_refusals = 2;
it.begin();
it.try_advance(BIG_BUDGET);
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.begin_calls, 2);
EXPECT_FALSE(it.completed());
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_EQ(it.begin_calls, 3);
}
// The deprecated advance() wrapper must keep the legacy once-per-loop
// pattern working during the deprecation window.
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
TEST(ComponentIterator, DeprecatedAdvanceKeepsLegacyPatternWorking) {
RefusingIterator it;
it.end_refusals = 2;
it.begin();
size_t guard = 0;
while (!it.completed() && guard++ < BIG_BUDGET) {
it.advance();
}
EXPECT_TRUE(it.completed());
// Two refused end steps were retried, then accepted
EXPECT_EQ(it.end_calls, 3);
}
#pragma GCC diagnostic pop
#ifdef USE_SENSOR
// Iterator whose sensor callback can refuse or yield; pins the per-item
// contract: a refused item is re-offered with at_ unchanged, never skipped.
class ItemRefusingIterator : public RefusingIterator {
public:
bool on_sensor(sensor::Sensor *obj) override {
this->last_sensor = obj;
if (!step(this->sensor_calls, this->sensor_refusals))
return false;
if (this->yield_on_sensor)
this->yield_after_step_();
return true;
}
sensor::Sensor *last_sensor{nullptr};
int sensor_calls{0};
int sensor_refusals{0};
bool yield_on_sensor{false};
};
class ComponentIteratorSensorTest : public ::testing::Test {
protected:
void SetUp() override {
static sensor::Sensor sensor_a;
static sensor::Sensor sensor_b;
static bool registered = false;
if (!registered) {
App.register_sensor(&sensor_a);
App.register_sensor(&sensor_b);
registered = true;
}
// StaticVector drops silently when full; fail the fixture, not the contract
ASSERT_EQ(App.get_sensors().size(), 2u) << "benchmark.yaml sensor count too small";
}
};
TEST_F(ComponentIteratorSensorTest, RefusedItemIsReofferedNotSkipped) {
ItemRefusingIterator it;
it.sensor_refusals = 2;
it.begin();
// Runs until the first sensor refuses
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.sensor_calls, 1);
EXPECT_FALSE(it.completed());
// The refused item is re-offered, not skipped
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.sensor_calls, 2);
sensor::Sensor *refused = it.last_sensor;
// Once accepted, iteration continues through the second sensor to the end
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
EXPECT_NE(it.last_sensor, refused);
EXPECT_EQ(it.sensor_calls, 4);
}
TEST_F(ComponentIteratorSensorTest, YieldAfterStepEndsPassAndResumes) {
ItemRefusingIterator it;
it.yield_on_sensor = true;
it.begin();
// The pass ends right after the first sensor despite a big budget
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.sensor_calls, 1);
EXPECT_FALSE(it.completed());
// The next pass ends after the second sensor
it.try_advance(BIG_BUDGET);
EXPECT_EQ(it.sensor_calls, 2);
// Remaining states then run to completion in one pass
it.try_advance(BIG_BUDGET);
EXPECT_TRUE(it.completed());
}
#endif // USE_SENSOR
} // namespace esphome::testing
+135
View File
@@ -1,6 +1,8 @@
#include <gtest/gtest.h>
#include <cmath>
#include <cstring>
#include "esphome/core/alloc_helpers.h"
#include "esphome/core/helpers.h"
namespace esphome::core::testing {
@@ -213,4 +215,137 @@ TEST(BufAppendSepStr, Truncation) {
EXPECT_EQ(end - buf, 7);
}
// --- base64 encode/decode ---
static const char BASE64_ALPHABET[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
// Pack 6-bit indices 0..63 into 48 bytes so encoding yields the full alphabet in order
TEST(Base64, EncodeProducesCanonicalAlphabet) {
uint8_t bytes[48];
size_t n = 0;
for (uint8_t i = 0; i < 64; i += 4) {
bytes[n++] = (i << 2) | ((i + 1) >> 4);
bytes[n++] = ((i + 1) & 0x0F) << 4 | ((i + 2) >> 2);
bytes[n++] = ((i + 2) & 0x03) << 6 | (i + 3);
}
std::string encoded = base64_encode(bytes, sizeof(bytes)); // NOLINT(esphome-heap-allocation) - host test
EXPECT_EQ(encoded, BASE64_ALPHABET);
}
// Decode the alphabet then re-encode: locks the encode and decode mappings together
TEST(Base64, DecodeCanonicalAlphabetRoundTrip) {
uint8_t buf[48];
size_t len = base64_decode(std::string(BASE64_ALPHABET), buf, sizeof(buf));
EXPECT_EQ(len, 48u);
std::string reencoded = base64_encode(buf, len); // NOLINT(esphome-heap-allocation) - host test
EXPECT_EQ(reencoded, BASE64_ALPHABET);
}
TEST(Base64, DecodeBase64UrlMatchesStandard) {
std::string url = BASE64_ALPHABET;
for (char &c : url) {
if (c == '+')
c = '-';
if (c == '/')
c = '_';
}
uint8_t standard[48], urlsafe[48];
size_t len_standard = base64_decode(std::string(BASE64_ALPHABET), standard, sizeof(standard));
size_t len_url = base64_decode(url, urlsafe, sizeof(urlsafe));
EXPECT_EQ(len_standard, len_url);
EXPECT_EQ(memcmp(standard, urlsafe, len_standard), 0);
}
// RFC 4648 vectors cover both padding cases (len % 3 == 1 and len % 3 == 2)
TEST(Base64, Rfc4648Vectors) {
const struct {
const char *plain;
const char *encoded;
} vectors[] = {
{"", ""},
{"f", "Zg=="},
{"fo", "Zm8="},
{"foo", "Zm9v"},
{"foob", "Zm9vYg=="},
{"fooba", "Zm9vYmE="},
{"foobar", "Zm9vYmFy"},
};
for (const auto &v : vectors) {
const auto *plain = reinterpret_cast<const uint8_t *>(v.plain);
std::string encoded = base64_encode(plain, strlen(v.plain)); // NOLINT(esphome-heap-allocation) - host test
EXPECT_EQ(encoded, v.encoded);
uint8_t buf[8];
size_t len = base64_decode(reinterpret_cast<const uint8_t *>(v.encoded), strlen(v.encoded), buf, sizeof(buf));
EXPECT_EQ(len, strlen(v.plain));
EXPECT_EQ(memcmp(buf, v.plain, len), 0);
}
}
// --- 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
+3
View File
@@ -0,0 +1,3 @@
sensor:
- platform: d01
name: D01 PM2.5 Concentration
+7
View File
@@ -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
+3
View File
@@ -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
+1 -1
View File
@@ -5,7 +5,7 @@ light:
id: led_matrix_32x8
default_transition_length: 500ms
chipset: ws2812
rgb_order: GRB
channel_colors: GRB
num_leds: 256
pin: ${pin}
effects:
+1 -1
View File
@@ -5,7 +5,7 @@ light:
id: led_matrix_32x8
default_transition_length: 500ms
chipset: ws2812
rgb_order: GRB
channel_colors: GRB
num_leds: 256
pin: ${pin}
effects:
+1 -1
View File
@@ -6,7 +6,7 @@ light:
pin: 2
pio: 0
num_leds: 256
rgb_order: GRB
channel_colors: GRB
chipset: WS2812
effects:
- e131:
+1 -2
View File
@@ -1,4 +1,3 @@
packages:
uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
<<: !include common.yaml
emontx: !include common.yaml
@@ -1,4 +1,3 @@
packages:
uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml
<<: !include common.yaml
emontx: !include common.yaml
+1 -2
View File
@@ -1,4 +1,3 @@
packages:
uart_115200: !include ../../test_build_components/common/uart_115200/rp2040-ard.yaml
<<: !include common.yaml
emontx: !include common.yaml
@@ -0,0 +1,88 @@
packages:
uart_115200: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
emontx: !include common.yaml
# Validate that each sensor type gets the correct default state_class,
# unit_of_measurement, device_class, and accuracy_decimals when NO overrides
# are provided. The values are intentionally omitted so apply_tag_defaults is
# exercised, not the user-override path.
sensor:
# Energy sensor (E prefix): expects state_class=total_increasing, unit=Wh,
# device_class=energy, accuracy_decimals=0
- platform: emontx
tag_name: E1
name: Energy 1
emontx_id: test_emontx
# Power sensor (P prefix): expects state_class=measurement, unit=W,
# device_class=power, accuracy_decimals=0
- platform: emontx
tag_name: P1
name: Power 1
emontx_id: test_emontx
# Voltage sensor (V prefix): expects state_class=measurement, unit=V,
# device_class=voltage, accuracy_decimals=2
- platform: emontx
tag_name: V1
name: Voltage 1
emontx_id: test_emontx
# Current sensor (I prefix): expects state_class=measurement, unit=A,
# device_class=current, accuracy_decimals=2
- platform: emontx
tag_name: I1
name: Current 1
emontx_id: test_emontx
# Temperature sensor (T prefix): expects state_class=measurement, unit=°C,
# device_class=temperature, accuracy_decimals=2
- platform: emontx
tag_name: T1
name: Temperature 1
emontx_id: test_emontx
# Pulse sensor (PULSE pattern): expects state_class=total_increasing,
# unit=pulses, device_class=energy, accuracy_decimals=0
- platform: emontx
tag_name: PULSE1
name: Pulse 1
emontx_id: test_emontx
# Power factor sensor (PF pattern): expects state_class=measurement,
# device_class=power_factor, accuracy_decimals=2
- platform: emontx
tag_name: PF1
name: Power Factor 1
emontx_id: test_emontx
# Apparent power sensor (AP pattern): expects state_class=measurement,
# unit=VA, device_class=apparent_power, accuracy_decimals=2
- platform: emontx
tag_name: AP1
name: Apparent Power 1
emontx_id: test_emontx
# Frequency sensor (F, matched exactly, not as a prefix): expects
# state_class=measurement, unit=Hz, device_class=frequency,
# accuracy_decimals=2
- platform: emontx
tag_name: F
name: Frequency
emontx_id: test_emontx
# Unknown tag: no prefix match, falls back to state_class=measurement,
# accuracy_decimals=0
- platform: emontx
tag_name: CUSTOM1
name: Custom sensor
emontx_id: test_emontx
# User override: verify that explicit values are respected and not clobbered
- platform: emontx
tag_name: E2
name: Energy 2 (user override)
emontx_id: test_emontx
state_class: measurement
accuracy_decimals: 3
@@ -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
+1 -1
View File
@@ -7,7 +7,7 @@ esp32:
enable_lwip_mdns_queries: true
enable_lwip_bridge_interface: true
disable_libc_locks_in_iram: false # Test explicit opt-out of RAM optimization
use_full_certificate_bundle: false # Test CMN bundle (default)
use_full_certificate_bundle: false # Bundle stays off without a component that needs it
include_builtin_idf_components:
- freertos # Test escape hatch (freertos is always included anyway)
enable_full_printf: false
@@ -6,3 +6,6 @@ update:
type: embedded
path: $component_dir/test_firmware.bin
sha256: de2f256064a0af797747c2b97505dc0b9f3df0de4f489eac731c23ae9ca9cc31
on_update_available:
then:
- logger.log: "Coprocessor update available"
@@ -8,3 +8,6 @@ update:
type: http
source: https://esphome.github.io/esp-hosted-firmware/manifest/esp32c6.json
update_interval: 6h
on_update_available:
then:
- logger.log: "Coprocessor update available"
@@ -3,13 +3,13 @@ light:
id: led_strip1
pin: ${pin1}
num_leds: 60
rgb_order: GRB
channel_colors: GRB
chipset: ws2812
- platform: esp32_rmt_led_strip
id: led_strip2
pin: ${pin2}
num_leds: 60
rgbw_order: RWGB
channel_colors: RWGB
bit0_high: 100us
bit0_low: 100us
bit1_high: 100us
@@ -8,14 +8,14 @@ light:
id: led_strip1
pin: ${pin1}
num_leds: 60
rgb_order: GRB
channel_colors: GRB
chipset: ws2812
use_dma: "true"
- platform: esp32_rmt_led_strip
id: led_strip2
pin: ${pin2}
num_leds: 60
rgb_order: RGB
channel_colors: RGB
bit0_high: 100us
bit0_low: 100us
bit1_high: 100us
@@ -0,0 +1,23 @@
# The deprecated rgb_order / is_rgbw / is_wrgb keys, kept working until 2027.3.0.
# Config-only: each strip below must migrate to the channel_colors shown in the comment.
light:
- platform: esp32_rmt_led_strip
id: legacy_rgb
pin: GPIO13
num_leds: 60
chipset: ws2812
rgb_order: GRB # -> GRB
- platform: esp32_rmt_led_strip
id: legacy_rgbw
pin: GPIO14
num_leds: 60
chipset: sk6812
rgb_order: GRB
is_rgbw: true # -> GRBW
- platform: esp32_rmt_led_strip
id: legacy_wrgb
pin: GPIO15
num_leds: 60
chipset: sk6812
rgb_order: GRB
is_wrgb: true # -> WGRB
@@ -0,0 +1,17 @@
ethernet:
type: W5500
spi_id: spi_bus
cs_pin: 5
interrupt_pin: 36
reset_pin: 22
clock_speed: 10Mhz
manual_ip:
static_ip: 192.168.178.56
gateway: 192.168.178.1
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
on_connect:
- logger.log: "Ethernet connected!"
on_disconnect:
- logger.log: "Ethernet disconnected!"
@@ -0,0 +1,3 @@
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
ethernet: !include common-w5500-spi-id.yaml
@@ -2,29 +2,9 @@
#include "esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h"
namespace esphome::hoermann_hcp {
#include "../common.h"
using modbus::RegisterValues;
namespace {
constexpr uint16_t COMMAND_REG = 0x9C41;
constexpr uint16_t BROADCAST_REG = 0x9D31;
RegisterValues make_registers(std::initializer_list<uint16_t> values) {
RegisterValues registers;
for (uint16_t value : values)
registers.push_back(value);
return registers;
}
// Exposes the connection bookkeeping so a drop can be driven without waiting one out.
class TestableHoermannHcp : public HoermannHcp {
public:
using HoermannHcp::set_valid_;
};
} // namespace
namespace esphome::hoermann_hcp::testing {
// Nothing has been heard from the bus controller yet, so the sensor starts out seeded as disconnected.
TEST(HoermannHcpBinarySensorTest, StartsDisconnected) {
@@ -42,7 +22,7 @@ TEST(HoermannHcpBinarySensorTest, FollowsTheConnectionState) {
sensor.setup();
ASSERT_FALSE(sensor.state);
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
connect_controller(door);
door.update();
EXPECT_TRUE(sensor.state);
@@ -59,7 +39,7 @@ TEST(HoermannHcpBinarySensorTest, UnchangedConnectionIsPublishedOnce) {
int publishes = 0;
sensor.add_on_state_callback([&publishes](bool /*state*/) { publishes++; });
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
connect_controller(door);
door.update();
ASSERT_EQ(publishes, 1);
@@ -69,4 +49,4 @@ TEST(HoermannHcpBinarySensorTest, UnchangedConnectionIsPublishedOnce) {
EXPECT_EQ(publishes, 1);
}
} // namespace esphome::hoermann_hcp
} // namespace esphome::hoermann_hcp::testing
@@ -0,0 +1,72 @@
#include <gtest/gtest.h>
#include "esphome/components/hoermann_hcp/button/hoermann_hcp_button.h"
#include "../common.h"
namespace esphome::hoermann_hcp::testing {
// The intermediate positions are named in the second register, which repeats that name on release.
TEST(HoermannHcpButtonTest, VentButtonSendsTheVentCommand) {
TestableHoermannHcp door;
HoermannHcpVentButton vent(&door);
connect_controller(door);
vent.press();
auto [pressed, pressed_2] = poll_command(door);
EXPECT_EQ(pressed, 0x0200);
EXPECT_EQ(pressed_2, 0x4000);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
auto [released, released_2] = poll_command(door);
EXPECT_EQ(released, 0x0100);
EXPECT_EQ(released_2, 0x4000);
}
TEST(HoermannHcpButtonTest, HalfOpenButtonSendsTheHalfOpenCommand) {
TestableHoermannHcp door;
HoermannHcpHalfOpenButton half_open(&door);
connect_controller(door);
half_open.press();
auto [pressed, pressed_2] = poll_command(door);
EXPECT_EQ(pressed, 0x0200);
EXPECT_EQ(pressed_2, 0x0400);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
auto [released, released_2] = poll_command(door);
EXPECT_EQ(released, 0x0100);
EXPECT_EQ(released_2, 0x0400);
}
// The door drives to the vent position on its own, so a position the cover was still travelling to must not
// stop it on the way there.
TEST(HoermannHcpButtonTest, VentAbandonsAnArmedTarget) {
TestableHoermannHcp door; // starts out fully closed
HoermannHcpVentButton vent(&door);
connect_controller(door);
door.set_position(0.5f);
consume_command(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100}));
ASSERT_EQ(door.get_door_state(), DoorState::OPENING);
vent.press();
consume_command(door);
// Position 120/200 = 0.6 is past the abandoned target, which must no longer stop the door.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// A button carries no state, so a refused press is simply dropped rather than fired once the controller
// turns up, which could be much later.
TEST(HoermannHcpButtonTest, PressWithoutABusControllerSendsNothing) {
HoermannHcp door; // never contacted by a bus controller
HoermannHcpVentButton vent(&door);
vent.press();
EXPECT_EQ(poll_command(door).first, 0x0000);
}
} // namespace esphome::hoermann_hcp::testing
+68
View File
@@ -0,0 +1,68 @@
#pragma once
#include <chrono>
#include <initializer_list>
#include <thread>
#include <utility>
#include <gtest/gtest.h>
#include "esphome/components/hoermann_hcp/hoermann_hcp.h"
namespace esphome::hoermann_hcp::testing {
using modbus::RegisterValues;
// Register block addresses the Hoermann bus controller polls (see hoermann_hcp.cpp).
constexpr uint16_t COMMAND_REG = 0x9C41;
constexpr uint16_t STATE_REG = 0x9CB9;
constexpr uint16_t BROADCAST_REG = 0x9D31;
// The tests shorten the key-press delay to zero, so the release only needs the millis() clock to tick on.
constexpr auto KEY_PRESS_ELAPSED = std::chrono::milliseconds(2);
inline RegisterValues make_registers(std::initializer_list<uint16_t> values) {
RegisterValues registers;
for (uint16_t value : values)
registers.push_back(value);
return registers;
}
// A status broadcast carrying the lamp register, which the door reports at index 6.
inline RegisterValues lamp_broadcast(uint16_t lamp_reg) {
return make_registers({0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, lamp_reg});
}
// The door only accepts commands once the bus controller has actually talked to it.
inline void connect_controller(HoermannHcp &door) {
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
}
// Runs one command poll (write 2 / read 8) and returns both key-press registers.
inline std::pair<uint16_t, uint16_t> poll_command(HoermannHcp &door) {
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
RegisterValues response;
door.on_read_holding_registers(STATE_REG, 8, response);
EXPECT_EQ(response.size(), 8u);
if (response.size() != 8u)
return {0xFFFF, 0xFFFF};
return {response[2], response[3]};
}
// Presents and then releases the queued command, leaving the slot free.
inline void consume_command(HoermannHcp &door) {
poll_command(door);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
poll_command(door);
}
// Exposes the internal timings and the connection bookkeeping, so no test has to wait out a real delay.
class TestableHoermannHcp : public HoermannHcp {
public:
TestableHoermannHcp() { this->key_press_delay_ms_ = 0; }
using HoermannHcp::connection_timeout_ms_;
using HoermannHcp::is_light_toggle_pending_;
using HoermannHcp::light_toggle_released_at_;
using HoermannHcp::light_toggles_in_flight_;
using HoermannHcp::set_valid_;
};
} // namespace esphome::hoermann_hcp::testing
+11
View File
@@ -11,3 +11,14 @@ binary_sensor:
- platform: hoermann_hcp
is_connected:
name: Garage Connected
button:
- platform: hoermann_hcp
vent:
name: Garage Vent
half_open:
name: Garage Half Open
light:
- platform: hoermann_hcp
name: Garage Light
@@ -2,36 +2,9 @@
#include "esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h"
namespace esphome::hoermann_hcp {
#include "../common.h"
using modbus::RegisterValues;
namespace {
constexpr uint16_t COMMAND_REG = 0x9C41;
constexpr uint16_t STATE_REG = 0x9CB9;
constexpr uint16_t BROADCAST_REG = 0x9D31;
RegisterValues make_registers(std::initializer_list<uint16_t> values) {
RegisterValues registers;
for (uint16_t value : values)
registers.push_back(value);
return registers;
}
// The door only accepts commands once the bus controller has actually talked to it.
void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); }
// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value.
uint16_t poll_command(HoermannHcp &door) {
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
RegisterValues response;
door.on_read_holding_registers(STATE_REG, 8, response);
EXPECT_EQ(response.size(), 8u);
return response.size() == 8u ? response[2] : 0xFFFF;
}
} // namespace
namespace esphome::hoermann_hcp::testing {
// Cover::position starts at COVER_OPEN, so a door that is already closed still has a state to publish.
TEST(HoermannHcpCoverTest, ClosedDoorPublishesItsInitialPosition) {
@@ -92,10 +65,10 @@ TEST(HoermannHcpCoverTest, OpenCommandOpensTheDoor) {
HoermannHcp door;
HoermannHcpCover cover(&door);
cover.setup();
connect(door);
connect_controller(door);
cover.make_call().set_command_open().perform();
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed
}
// The same for cover.close, which arrives as a position of 0.0.
@@ -103,32 +76,32 @@ TEST(HoermannHcpCoverTest, CloseCommandClosesTheDoor) {
HoermannHcp door;
HoermannHcpCover cover(&door);
cover.setup();
connect(door);
connect_controller(door);
cover.make_call().set_command_close().perform();
EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed
EXPECT_EQ(poll_command(door).first, 0x0220); // COMMAND_CLOSE pressed
}
TEST(HoermannHcpCoverTest, ToggleCommandSendsAnImpulse) {
HoermannHcp door;
HoermannHcpCover cover(&door);
cover.setup();
connect(door);
connect_controller(door);
cover.make_call().set_command_toggle().perform();
EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed
EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed
}
TEST(HoermannHcpCoverTest, StopCommandStopsAMovingDoor) {
HoermannHcp door;
HoermannHcpCover cover(&door);
cover.setup();
connect(door);
connect_controller(door);
// The door is opening, so it takes an impulse to stop it.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100}));
cover.make_call().set_command_stop().perform();
EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed
EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed
}
// A position between the end stops starts the door in the right direction; it is stopped there later.
@@ -136,10 +109,10 @@ TEST(HoermannHcpCoverTest, PositionCommandStartsTheDoorTowardsTheTarget) {
HoermannHcp door; // starts out fully closed
HoermannHcpCover cover(&door);
cover.setup();
connect(door);
connect_controller(door);
cover.make_call().set_position(0.5f).perform();
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed
}
// A command the door cannot take is assumed to have worked by whoever sent it, so the unchanged state has
@@ -153,7 +126,7 @@ TEST(HoermannHcpCoverTest, RefusedCommandPublishesTheUnchangedState) {
cover.make_call().set_command_close().perform();
EXPECT_EQ(poll_command(door), 0x0000);
EXPECT_EQ(poll_command(door).first, 0x0000);
EXPECT_EQ(publishes, 1);
EXPECT_FLOAT_EQ(cover.position, cover::COVER_OPEN);
}
@@ -166,9 +139,9 @@ TEST(HoermannHcpCoverTest, MissingBusControllerIsFlaggedUntilFirstContact) {
cover.setup();
EXPECT_TRUE(cover.status_has_warning());
connect(door);
connect_controller(door);
door.update();
EXPECT_FALSE(cover.status_has_warning());
}
} // namespace esphome::hoermann_hcp
} // namespace esphome::hoermann_hcp::testing
@@ -3,51 +3,9 @@
#include <chrono>
#include <thread>
#include "esphome/components/hoermann_hcp/hoermann_hcp.h"
#include "common.h"
namespace esphome::hoermann_hcp {
using modbus::RegisterValues;
namespace {
// Register block addresses the Hoermann bus controller polls (see hoermann_hcp.cpp).
constexpr uint16_t COMMAND_REG = 0x9C41;
constexpr uint16_t STATE_REG = 0x9CB9;
constexpr uint16_t BROADCAST_REG = 0x9D31;
// The tests shorten the key-press delay to zero, so the release only needs the millis() clock to tick on.
constexpr auto KEY_PRESS_ELAPSED = std::chrono::milliseconds(2);
RegisterValues make_registers(std::initializer_list<uint16_t> values) {
RegisterValues registers;
for (uint16_t value : values)
registers.push_back(value);
return registers;
}
// The device only accepts commands once the bus controller has actually talked to it.
void connect(HoermannHcp &door) { door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); }
// Runs one command poll (write 2 / read 8) and returns the register carrying the key-press value.
uint16_t poll_command(HoermannHcp &door) {
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
RegisterValues response;
door.on_read_holding_registers(STATE_REG, 8, response);
EXPECT_EQ(response.size(), 8u);
return response.size() == 8u ? response[2] : 0xFFFF;
}
// Exposes the internal timings and the connection bookkeeping, so no test has to wait out a real delay.
class TestableHoermannHcp : public HoermannHcp {
public:
TestableHoermannHcp() { this->key_press_delay_ms_ = 0; }
using HoermannHcp::connection_timeout_ms_;
using HoermannHcp::set_valid_;
};
} // namespace
namespace esphome::hoermann_hcp::testing {
// An empty poll (write 2 / read 2) answers with the fixed status word 0x0004.
TEST(HoermannHcpReadWrite, EmptyPollReturnsStatusWord) {
@@ -91,7 +49,7 @@ TEST(HoermannHcpReadWrite, IdleCommandPollHasNoCommand) {
// A queued control command is injected into the next command poll as a simulated key press.
TEST(HoermannHcpReadWrite, QueuedCommandIsInjectedIntoPoll) {
HoermannHcp door;
connect(door);
connect_controller(door);
door.open_door();
EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value());
RegisterValues response;
@@ -113,31 +71,31 @@ TEST(HoermannHcpReadWrite, UnknownAddressIsRejected) {
// A command is held for the key-press duration, then released, and only then can the next one be queued.
TEST(HoermannHcpReadWrite, CommandIsReleasedAfterTheKeyPressDelay) {
TestableHoermannHcp door;
connect(door);
connect_controller(door);
door.open_door();
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed
// Refused while one is pending: were it accepted, the release below would carry COMMAND_CLOSE's 0x0120.
door.close_door();
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released
// With the command gone, the next one is accepted again.
door.close_door();
EXPECT_EQ(poll_command(door), 0x0220); // COMMAND_CLOSE pressed
EXPECT_EQ(poll_command(door).first, 0x0220); // COMMAND_CLOSE pressed
}
// Commands issued while the bus controller is absent are dropped instead of firing when it returns.
TEST(HoermannHcpReadWrite, CommandIsDroppedWhileDisconnected) {
HoermannHcp door;
door.open_door();
EXPECT_EQ(poll_command(door), 0x0000);
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// Losing the controller must drop a command it never fetched, otherwise it blocks every later command
// and fires unasked once the bus comes back.
TEST(HoermannHcpReadWrite, ConnectionLossDropsThePendingCommand) {
TestableHoermannHcp door;
connect(door);
connect_controller(door);
door.open_door();
ASSERT_TRUE(door.is_valid());
@@ -145,10 +103,10 @@ TEST(HoermannHcpReadWrite, ConnectionLossDropsThePendingCommand) {
EXPECT_FALSE(door.is_valid());
// The reconnecting poll must not replay the dropped command.
EXPECT_EQ(poll_command(door), 0x0000);
EXPECT_EQ(poll_command(door).first, 0x0000);
// And the slot is free, so a new command is accepted.
door.close_door();
EXPECT_EQ(poll_command(door), 0x0220);
EXPECT_EQ(poll_command(door).first, 0x0220);
}
// The connection is dropped by update() once the controller stops polling, which is what releases a
@@ -157,7 +115,7 @@ TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) {
TestableHoermannHcp door;
// Wide enough that a stall cannot expire the connection before the check below runs.
door.connection_timeout_ms_ = 10000;
connect(door);
connect_controller(door);
door.open_door();
// Still inside the window: the controller counts as present.
@@ -170,7 +128,7 @@ TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) {
door.update();
EXPECT_FALSE(door.is_valid());
// The pending command went with the connection instead of firing on the reconnecting poll.
EXPECT_EQ(poll_command(door), 0x0000);
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// Status broadcasts alone keep the connection alive, so a command the controller never fetches has to
@@ -178,7 +136,7 @@ TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) {
TEST(HoermannHcpReadWrite, UnfetchedCommandExpiresWhileConnected) {
TestableHoermannHcp door;
door.connection_timeout_ms_ = 200;
connect(door);
connect_controller(door);
door.open_door();
std::this_thread::sleep_for(std::chrono::milliseconds(220));
@@ -189,7 +147,7 @@ TEST(HoermannHcpReadWrite, UnfetchedCommandExpiresWhileConnected) {
// With the stale command gone, the door accepts commands again.
door.close_door();
EXPECT_EQ(poll_command(door), 0x0220);
EXPECT_EQ(poll_command(door).first, 0x0220);
}
// The 0x17 read half echoes the message counter and command byte written to COMMAND_REG, packed
@@ -272,7 +230,7 @@ TEST(HoermannHcpWrite, EndStopsReportExactPositions) {
// A position request below the lower snap threshold becomes a plain close command.
TEST(HoermannHcpPosition, NearlyClosedTargetClosesTheDoor) {
HoermannHcp door;
connect(door);
connect_controller(door);
door.set_position(0.02f);
RegisterValues response;
door.on_read_holding_registers(STATE_REG, 8, response);
@@ -283,7 +241,7 @@ TEST(HoermannHcpPosition, NearlyClosedTargetClosesTheDoor) {
// A half-open target starts the door moving towards the requested position.
TEST(HoermannHcpPosition, HalfOpenTargetOpensTheDoor) {
HoermannHcp door; // starts out fully closed
connect(door);
connect_controller(door);
door.set_position(0.5f);
RegisterValues response;
door.on_read_holding_registers(STATE_REG, 8, response);
@@ -294,31 +252,31 @@ TEST(HoermannHcpPosition, HalfOpenTargetOpensTheDoor) {
// The door has no notion of a target, so it is stopped with an impulse once it travels past the request.
TEST(HoermannHcpPosition, TargetPositionStopsTheDoor) {
TestableHoermannHcp door;
connect(door);
connect_controller(door);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released
// Position 20/200 = 0.1 while opening: short of the target, so the door keeps going.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100}));
ASSERT_EQ(door.get_door_state(), DoorState::OPENING);
EXPECT_EQ(poll_command(door), 0x0000);
EXPECT_EQ(poll_command(door).first, 0x0000);
// Position 120/200 = 0.6 is past the target, so the door is stopped.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed
EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed
}
// An impulse restarts a stopped door, so a frame reporting the stop and the target crossing at once
// must be read as "already stopped" rather than "still opening".
TEST(HoermannHcpPosition, StopReportedWithTheCrossingSendsNoImpulse) {
TestableHoermannHcp door;
connect(door);
connect_controller(door);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door), 0x0210);
EXPECT_EQ(poll_command(door).first, 0x0210);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door), 0x0110);
EXPECT_EQ(poll_command(door).first, 0x0110);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100}));
ASSERT_EQ(door.get_door_state(), DoorState::OPENING);
@@ -326,17 +284,17 @@ TEST(HoermannHcpPosition, StopReportedWithTheCrossingSendsNoImpulse) {
// Same frame: position 0.6 (past the target) and state 0x20 -> the door has reached its open end stop.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x2000}));
ASSERT_EQ(door.get_door_state(), DoorState::OPEN);
EXPECT_EQ(poll_command(door), 0x0000);
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// A target the door never reaches is dropped once it comes to rest, so a later move is not cut short.
TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorStopsShort) {
TestableHoermannHcp door;
connect(door);
connect_controller(door);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door), 0x0210);
EXPECT_EQ(poll_command(door).first, 0x0210);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door), 0x0110);
EXPECT_EQ(poll_command(door).first, 0x0110);
// The door is stopped at 0.3 by a wall button, short of the requested 0.5.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100}));
@@ -346,48 +304,48 @@ TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorStopsShort) {
// A later manual open must run freely instead of being stopped at the abandoned target.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100}));
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
EXPECT_EQ(poll_command(door), 0x0000);
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// A target armed while the door is still travelling the other way must not be judged by that old direction,
// otherwise the very next position it reports counts as reached and stops the door where it stands.
TEST(HoermannHcpPosition, TargetArmedWhileMovingTheOtherWayWaitsForTheTurnaround) {
TestableHoermannHcp door;
connect(door);
connect_controller(door);
// The door is closing, passing 60/200 = 0.3.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200}));
ASSERT_EQ(door.get_door_state(), DoorState::CLOSING);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door), 0x0210); // COMMAND_OPEN pressed
EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door), 0x0110); // COMMAND_OPEN released
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released
// Still closing at 58/200 = 0.29: below the target, but not on the way to it.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003A, 0x0200}));
EXPECT_EQ(poll_command(door), 0x0000);
EXPECT_EQ(poll_command(door).first, 0x0000);
// Now opening at 62/200 = 0.31, still short of the target.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100}));
EXPECT_EQ(poll_command(door), 0x0000);
EXPECT_EQ(poll_command(door).first, 0x0000);
// Past the target at 110/200 = 0.55, so the door is stopped.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100}));
EXPECT_EQ(poll_command(door), 0x0240); // COMMAND_IMPULSE pressed
EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed
}
// A motor turning around can report a momentary stop; dropping the target there would let the door run on
// to the end stop that the reversing command asked for.
TEST(HoermannHcpPosition, MomentaryStopWhileTurningAroundKeepsTheTarget) {
TestableHoermannHcp door;
connect(door);
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200}));
ASSERT_EQ(door.get_door_state(), DoorState::CLOSING);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door), 0x0210);
EXPECT_EQ(poll_command(door).first, 0x0210);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door), 0x0110);
EXPECT_EQ(poll_command(door).first, 0x0110);
// The stop reported on the way from closing to opening.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000}));
@@ -395,23 +353,23 @@ TEST(HoermannHcpPosition, MomentaryStopWhileTurningAroundKeepsTheTarget) {
// The door then opens and still has to be stopped at the requested position.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100}));
EXPECT_EQ(poll_command(door), 0x0000);
EXPECT_EQ(poll_command(door).first, 0x0000);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100}));
EXPECT_EQ(poll_command(door), 0x0240);
EXPECT_EQ(poll_command(door).first, 0x0240);
}
// A door that never turns around has to lose the target as well, otherwise it would cut a later move short.
TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorNeverTurnsAround) {
TestableHoermannHcp door;
door.connection_timeout_ms_ = 200;
connect(door);
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200}));
ASSERT_EQ(door.get_door_state(), DoorState::CLOSING);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door), 0x0210);
EXPECT_EQ(poll_command(door).first, 0x0210);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door), 0x0110);
EXPECT_EQ(poll_command(door).first, 0x0110);
std::this_thread::sleep_for(std::chrono::milliseconds(220));
// The door ignored the command and closed all the way. Its broadcast keeps the connection alive, so the
@@ -424,7 +382,7 @@ TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorNeverTurnsAround) {
// A later manual open must run freely instead of being stopped at the abandoned target.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100}));
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100}));
EXPECT_EQ(poll_command(door), 0x0000);
EXPECT_EQ(poll_command(door).first, 0x0000);
}
} // namespace esphome::hoermann_hcp
} // namespace esphome::hoermann_hcp::testing
@@ -0,0 +1,761 @@
#include <gtest/gtest.h>
#include <chrono>
#include <thread>
#include "esphome/components/hoermann_hcp/light/hoermann_hcp_light.h"
#include "../common.h"
namespace esphome::hoermann_hcp::testing {
namespace {
// Counts how often the platform is asked to write, so a publish that re-triggers itself becomes visible.
class CountingHoermannHcpLight : public HoermannHcpLight {
public:
using HoermannHcpLight::HoermannHcpLight;
void write_state(light::LightState *state) override {
this->writes++;
HoermannHcpLight::write_state(state);
}
int writes{0};
};
// Drives the platform against a real LightState. ALWAYS_OFF keeps setup() clear of preferences.
struct LightFixture {
TestableHoermannHcp door;
CountingHoermannHcpLight output{&door};
light::LightState state{&output};
explicit LightFixture(light::LightRestoreMode restore_mode = light::LIGHT_ALWAYS_OFF) {
this->state.set_restore_mode(restore_mode);
this->output.setup();
// setup() queues the restored state for write_state(); the first settle() below delivers it, which is the
// boot ordering tests need to be able to place around the bus controller coming up.
this->state.setup();
}
// Brings the bus controller up and lets the platform read the lamp once, which is what a device does before
// any user command can arrive.
void bring_up() {
connect_controller(this->door);
this->report_lamp(false);
}
// Issues a command the way Home Assistant would, then lets the state machine settle.
void command(bool on) {
auto call = this->state.make_call();
call.set_state(on);
call.perform();
this->settle();
}
// Delivers a status broadcast and runs the hub's notification pass.
void report_broadcast(const RegisterValues &registers) {
this->door.on_write_registers(BROADCAST_REG, registers);
this->pump();
}
void report_lamp(bool on) { this->report_broadcast(lamp_broadcast(on ? 0x0010 : 0x0000)); }
// Runs the hub's notification pass and lets the resulting publishes settle.
void pump() {
this->door.update();
this->settle();
}
void settle() {
for (int i = 0; i < 4; i++)
this->state.loop();
}
bool entity_on() { return this->state.remote_values.is_on(); }
};
} // namespace
// The lamp state lives in the low byte of register 6; only 0x14 and 0x10 mean lit.
TEST(HoermannHcpLightTest, LampStateIsDecodedFromTheBroadcast) {
HoermannHcp door;
EXPECT_FALSE(door.is_light_on());
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0014));
EXPECT_TRUE(door.is_light_on());
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
EXPECT_FALSE(door.is_light_on());
}
// The lamp command is the only one that drives the second command register, on both halves of the press.
TEST(HoermannHcpLightTest, LampCommandUsesTheSecondRegister) {
TestableHoermannHcp door;
connect_controller(door);
ASSERT_FALSE(door.is_light_on());
ASSERT_TRUE(door.toggle_light());
auto [pressed, pressed_2] = poll_command(door);
EXPECT_EQ(pressed, 0x0100);
EXPECT_EQ(pressed_2, 0x0200);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
auto [released, released_2] = poll_command(door);
EXPECT_EQ(released, 0x0800);
EXPECT_EQ(released_2, 0x0200);
// The command is spent, so the next poll carries nothing.
auto [idle, idle_2] = poll_command(door);
EXPECT_EQ(idle, 0x0000);
EXPECT_EQ(idle_2, 0x0000);
}
// Toggling the lamp must not disturb a cover position the door is still travelling to.
TEST(HoermannHcpLightTest, LampToggleKeepsTheCoverTarget) {
TestableHoermannHcp door;
connect_controller(door);
// Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
ASSERT_TRUE(door.set_position(0.5f));
consume_command(door);
ASSERT_TRUE(door.toggle_light());
consume_command(door);
// Past the target: the door still has to be stopped despite the lamp command in between.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
auto [pressed, pressed_2] = poll_command(door);
EXPECT_EQ(pressed, 0x0240); // COMMAND_IMPULSE
EXPECT_EQ(pressed_2, 0x0000);
}
// A lamp toggle occupies the single command slot, so a target stop falling due while it waits to be fetched
// has to wait too. The target stays armed and the stop goes out on the next position report, which costs the
// door a little overshoot but never loses the stop.
TEST(HoermannHcpLightTest, LampToggleDelaysButDoesNotLoseTheTargetStop) {
TestableHoermannHcp door;
connect_controller(door);
// Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
ASSERT_TRUE(door.set_position(0.5f));
consume_command(door);
ASSERT_TRUE(door.toggle_light());
// The door passes the target while the lamp toggle still holds the slot, so the lamp goes out first.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
auto [pressed, pressed_2] = poll_command(door);
EXPECT_EQ(pressed, 0x0100);
EXPECT_EQ(pressed_2, 0x0200);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
poll_command(door);
// The target survived the refusal, so the next position report still stops the door.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0079, 0x0100}));
auto [stop, stop_2] = poll_command(door);
EXPECT_EQ(stop, 0x0240); // COMMAND_IMPULSE
EXPECT_EQ(stop_2, 0x0000);
}
// The target's start deadline is its own, so toggling the lamp cannot keep a stale target alive.
TEST(HoermannHcpLightTest, LampToggleDoesNotExtendTheTargetWatchdog) {
TestableHoermannHcp door;
door.connection_timeout_ms_ = 20;
connect_controller(door);
// The door is closing, so an opening target is armed but not yet under way.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200}));
ASSERT_TRUE(door.set_position(0.5f));
consume_command(door);
std::this_thread::sleep_for(std::chrono::milliseconds(30));
ASSERT_TRUE(door.toggle_light());
consume_command(door);
door.update();
// The target expired on its own schedule, so a later opening move runs freely.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100}));
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
auto [pressed, pressed_2] = poll_command(door);
EXPECT_EQ(pressed, 0x0000);
EXPECT_EQ(pressed_2, 0x0000);
}
// Without a bus controller the command cannot be delivered, and the caller is told.
TEST(HoermannHcpLightTest, LampCommandIsRefusedWhileDisconnected) {
HoermannHcp door;
EXPECT_FALSE(door.toggle_light());
}
// Switching the entity on sends one toggle, and the door's own report does not send a second.
TEST(HoermannHcpLightPlatformTest, CommandTogglesOnceAndSettles) {
LightFixture fixture;
fixture.bring_up();
fixture.command(true);
auto [pressed, pressed_2] = poll_command(fixture.door);
EXPECT_EQ(pressed, 0x0100);
EXPECT_EQ(pressed_2, 0x0200);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
poll_command(fixture.door); // release, clearing the slot
// The lamp is now on, and the resulting broadcast must not queue another toggle.
fixture.report_lamp(true);
EXPECT_TRUE(fixture.entity_on());
auto [idle, idle_2] = poll_command(fixture.door);
EXPECT_EQ(idle, 0x0000);
EXPECT_EQ(idle_2, 0x0000);
}
// A broadcast arriving while a toggle is queued must not reconcile against the not-yet-inverted lamp, which
// would cancel the user's own command.
TEST(HoermannHcpLightPlatformTest, BroadcastDuringPendingToggleKeepsTheCommand) {
LightFixture fixture;
fixture.bring_up();
fixture.command(true);
ASSERT_TRUE(fixture.door.is_light_toggle_pending_());
// A door movement sets changed_, firing the state callback while the toggle is still queued.
fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100}));
EXPECT_TRUE(fixture.door.is_light_toggle_pending_());
EXPECT_TRUE(fixture.entity_on());
}
// A lamp switched on at the door itself has to reach the entity.
TEST(HoermannHcpLightPlatformTest, DoorDrivenChangeReachesTheEntity) {
LightFixture fixture;
fixture.bring_up();
ASSERT_FALSE(fixture.entity_on());
fixture.report_lamp(true);
EXPECT_TRUE(fixture.entity_on());
fixture.report_lamp(false);
EXPECT_FALSE(fixture.entity_on());
}
// A refused command must leave the entity showing the lamp, not the request.
TEST(HoermannHcpLightPlatformTest, RefusedCommandRepublishesTheLamp) {
LightFixture fixture; // never connected, so the hub refuses every command
fixture.command(true);
EXPECT_FALSE(fixture.entity_on());
}
// A reversing press once the toggle is already on the wire cannot stop it, so the entity has to end up
// showing the lamp rather than the request that was refused.
TEST(HoermannHcpLightPlatformTest, RefusedPressAfterFetchShowsWhereTheLampIsHeading) {
LightFixture fixture;
fixture.bring_up();
fixture.command(true);
poll_command(fixture.door); // the controller fetches the press, so it can no longer be cancelled
ASSERT_TRUE(fixture.door.is_light_toggle_pending_());
fixture.command(false);
EXPECT_TRUE(fixture.entity_on());
// A door movement while the refused toggle is still on the wire must not pull the entity back either.
fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100}));
EXPECT_TRUE(fixture.entity_on());
// The toggle lands and the door confirms it; the entity must already agree.
fixture.report_lamp(true);
EXPECT_TRUE(fixture.entity_on());
}
// The lamp is only reported some time after the key press is released, so an unrelated door broadcast in
// that gap must not publish the state the lamp is about to leave.
TEST(HoermannHcpLightPlatformTest, DoorMovementDoesNotFlipTheEntityBeforeTheLampReports) {
LightFixture fixture;
fixture.bring_up();
fixture.command(true);
poll_command(fixture.door);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
poll_command(fixture.door); // release, so nothing is pending any more
ASSERT_FALSE(fixture.door.is_light_toggle_pending_());
ASSERT_FALSE(fixture.door.is_light_on()); // the lamp has still not been reported
fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100}));
EXPECT_TRUE(fixture.entity_on());
}
// A toggle the controller never fetches is eventually dropped, and nothing else will ever report the lamp
// moving, so the entity has to be brought back to what the lamp actually is.
TEST(HoermannHcpLightPlatformTest, DroppedToggleReturnsTheEntityToTheLamp) {
LightFixture fixture;
fixture.door.connection_timeout_ms_ = 20;
fixture.bring_up();
fixture.command(true);
ASSERT_TRUE(fixture.door.is_light_toggle_pending_());
EXPECT_TRUE(fixture.entity_on());
// The controller keeps broadcasting but never fetches the command, so the connection stays up.
std::this_thread::sleep_for(std::chrono::milliseconds(30));
fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
fixture.pump();
EXPECT_FALSE(fixture.door.is_light_toggle_pending_());
EXPECT_FALSE(fixture.entity_on());
}
// Losing the bus controller discards the queued toggle too, so the entity must not keep showing it once the
// controller is back and still reporting the lamp unchanged.
TEST(HoermannHcpLightPlatformTest, ToggleLostWithTheConnectionReturnsTheEntityToTheLamp) {
LightFixture fixture;
fixture.door.connection_timeout_ms_ = 20;
fixture.bring_up();
fixture.command(true);
ASSERT_TRUE(fixture.door.is_light_toggle_pending_());
std::this_thread::sleep_for(std::chrono::milliseconds(30));
fixture.pump(); // the connection times out and the command goes with it
ASSERT_FALSE(fixture.door.is_valid());
connect_controller(fixture.door);
fixture.report_lamp(false);
EXPECT_FALSE(fixture.entity_on());
}
// The lamp can be switched at the door while the bus is quiet, so what was read before an outage must not
// decide whether a toggle is needed after it.
TEST(HoermannHcpLightPlatformTest, LampIsNotTrustedAcrossAConnectionLoss) {
LightFixture fixture;
fixture.door.connection_timeout_ms_ = 20;
fixture.bring_up();
fixture.report_lamp(true);
ASSERT_TRUE(fixture.entity_on());
std::this_thread::sleep_for(std::chrono::milliseconds(30));
fixture.pump();
ASSERT_FALSE(fixture.door.is_valid());
// Back on the bus, but nothing has said what the lamp is doing yet.
connect_controller(fixture.door);
fixture.pump();
ASSERT_TRUE(fixture.door.is_valid());
ASSERT_FALSE(fixture.door.is_light_known());
fixture.command(false);
auto [idle, idle_2] = poll_command(fixture.door);
EXPECT_EQ(idle, 0x0000);
EXPECT_EQ(idle_2, 0x0000);
}
// A door that never reports the lamp leaves the entity unable to do anything, so it must not look healthy.
TEST(HoermannHcpLightPlatformTest, UnreportedLampIsFlaggedOnTheEntity) {
LightFixture fixture;
connect_controller(fixture.door);
fixture.pump();
ASSERT_TRUE(fixture.door.is_valid());
EXPECT_TRUE(fixture.output.status_has_warning());
fixture.report_lamp(false);
EXPECT_FALSE(fixture.output.status_has_warning());
}
// Two outstanding toggles leave the lamp where it started, so a third tap has to be judged against that and
// withdraw the one still waiting rather than deciding nothing is needed.
TEST(HoermannHcpLightPlatformTest, ThirdTapWithTwoTogglesOutstandingIsHonoured) {
LightFixture fixture;
fixture.bring_up();
fixture.command(true);
poll_command(fixture.door);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
poll_command(fixture.door); // the first toggle is released but not reported back
fixture.command(false);
ASSERT_TRUE(fixture.door.is_light_toggle_pending_());
ASSERT_EQ(fixture.door.light_toggles_in_flight_, 2);
// Two toggles cancel out, so asking for on again means withdrawing the second one.
fixture.command(true);
EXPECT_FALSE(fixture.door.is_light_toggle_pending_());
EXPECT_EQ(fixture.door.light_toggles_in_flight_, 1);
EXPECT_TRUE(fixture.entity_on());
}
// The boot replay is the first write and nothing else, so a real command arriving before the hub's next poll
// must not be mistaken for it and swallowed.
TEST(HoermannHcpLightPlatformTest, CommandBeforeTheFirstPollIsNotMistakenForTheBootReplay) {
LightFixture fixture;
connect_controller(fixture.door);
fixture.settle(); // the boot replay lands here, while the lamp is still unknown
// The first status broadcast arrives, but the hub has not polled yet, so no callback has fired.
fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
ASSERT_TRUE(fixture.door.is_light_known());
fixture.command(true);
auto [pressed, pressed_2] = poll_command(fixture.door);
EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP
EXPECT_EQ(pressed_2, 0x0200);
}
// On boot the restored state is replayed through write_state() before the lamp has ever been read. A lamp
// that is already on must not be switched off by that replay.
TEST(HoermannHcpLightPlatformTest, RestoredStateOnBootDoesNotCommandTheLamp) {
LightFixture fixture;
// The controller is already up and reporting the lamp lit before the entity's first loop.
connect_controller(fixture.door);
fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010));
ASSERT_TRUE(fixture.door.is_light_on());
fixture.settle();
auto [idle, idle_2] = poll_command(fixture.door);
EXPECT_EQ(idle, 0x0000);
EXPECT_EQ(idle_2, 0x0000);
// Once the platform has read the lamp the entity follows it, still without commanding anything.
fixture.pump();
EXPECT_TRUE(fixture.entity_on());
}
// Bus traffic makes the connection valid without saying anything about the lamp, so a request arriving before
// the first status broadcast must not be judged against a lamp state that was never read.
TEST(HoermannHcpLightPlatformTest, RequestBeforeTheLampIsReportedDoesNotCommandTheLamp) {
LightFixture fixture;
// The controller polls for commands, which is enough to connect but carries no lamp register.
connect_controller(fixture.door);
fixture.pump();
ASSERT_TRUE(fixture.door.is_valid());
ASSERT_FALSE(fixture.door.is_light_known());
fixture.command(true);
auto [idle, idle_2] = poll_command(fixture.door);
EXPECT_EQ(idle, 0x0000);
EXPECT_EQ(idle_2, 0x0000);
EXPECT_FALSE(fixture.entity_on());
}
// A toggle that has been released onto the wire is no longer pending, but the lamp has not reported it yet.
// A reversing request in that window is a real request and has to be sent, not swallowed.
TEST(HoermannHcpLightPlatformTest, ReversingRequestAfterReleaseQueuesASecondToggle) {
LightFixture fixture;
fixture.bring_up();
fixture.command(true);
poll_command(fixture.door);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
poll_command(fixture.door); // released, so nothing is pending and the lamp is still unreported
ASSERT_FALSE(fixture.door.is_light_toggle_pending_());
ASSERT_FALSE(fixture.door.is_light_on());
fixture.command(false);
auto [pressed, pressed_2] = poll_command(fixture.door);
EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP
EXPECT_EQ(pressed_2, 0x0200);
EXPECT_FALSE(fixture.entity_on());
// The first toggle lands and is reported, but the entity is already heading for off.
fixture.report_lamp(true);
EXPECT_FALSE(fixture.entity_on());
// The second toggle lands too, and the lamp finally agrees with the request.
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
poll_command(fixture.door);
fixture.report_lamp(false);
EXPECT_FALSE(fixture.entity_on());
}
// A refusal that has no toggle on the wire leaves nothing outstanding, so it must not latch the entity
// against the next lamp change the door reports.
TEST(HoermannHcpLightPlatformTest, RefusalWithoutAToggleStillFollowsTheLamp) {
LightFixture fixture;
fixture.door.connection_timeout_ms_ = 20;
fixture.bring_up();
std::this_thread::sleep_for(std::chrono::milliseconds(30));
fixture.pump();
ASSERT_FALSE(fixture.door.is_valid());
// Refused because the bus is down, so no toggle is heading for the lamp.
fixture.command(true);
EXPECT_FALSE(fixture.entity_on());
// The controller returns and reports the lamp switched on at the door itself.
connect_controller(fixture.door);
fixture.report_lamp(true);
EXPECT_TRUE(fixture.entity_on());
}
// A lamp toggle carries no target, so dropping it unfetched must leave the cover's target alone.
TEST(HoermannHcpLightTest, DroppedLampToggleKeepsTheCoverTarget) {
TestableHoermannHcp door;
door.connection_timeout_ms_ = 20;
connect_controller(door);
// Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
ASSERT_TRUE(door.set_position(0.5f));
consume_command(door);
// The controller keeps broadcasting but stops fetching, so the lamp toggle expires on its own.
ASSERT_TRUE(door.toggle_light());
std::this_thread::sleep_for(std::chrono::milliseconds(30));
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100}));
door.update();
// The target survived the lamp toggle being dropped, so the door is still stopped on the way.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
auto [pressed, pressed_2] = poll_command(door);
EXPECT_EQ(pressed, 0x0240); // COMMAND_IMPULSE
EXPECT_EQ(pressed_2, 0x0000);
}
// A door that takes the key press but never actually switches the lamp must not leave the entity showing the
// request for ever; the wait has to end so the entity can settle back on what the door reports.
TEST(HoermannHcpLightPlatformTest, ToggleTheDoorIgnoresStopsBeingWaitedFor) {
LightFixture fixture;
fixture.door.connection_timeout_ms_ = 20;
fixture.bring_up();
fixture.command(true);
consume_command(fixture.door); // the door takes press and release, then does nothing
ASSERT_FALSE(fixture.door.is_light_toggle_pending_());
EXPECT_TRUE(fixture.entity_on());
std::this_thread::sleep_for(std::chrono::milliseconds(30));
fixture.report_lamp(false); // the lamp is still off, and keeps saying so
EXPECT_FALSE(fixture.entity_on());
}
// A resting door's first broadcast changes nothing except the lamp finally being reported, so unless that
// counts as a change the light never hears about it and swallows the first command.
TEST(HoermannHcpLightPlatformTest, FirstLampReportReachesTheEntity) {
LightFixture fixture;
// A command poll connects the controller without saying anything about the lamp.
connect_controller(fixture.door);
fixture.pump();
ASSERT_FALSE(fixture.door.is_light_known());
// Closed, at rest, lamp off: every field matches the defaults the hub started with.
fixture.report_broadcast(make_registers({0x0000, 0x0000, 0x4000, 0x0000, 0x0000, 0x0000, 0x0000}));
ASSERT_TRUE(fixture.door.is_light_known());
fixture.command(true);
auto [pressed, pressed_2] = poll_command(fixture.door);
EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP
EXPECT_EQ(pressed_2, 0x0200);
}
// A lost connection means the door can travel unwatched, so a target left armed would stop it long afterwards.
// Which command happened to be in the slot must not change that.
TEST(HoermannHcpLightTest, ConnectionLossWithALampTogglePendingClearsTheTarget) {
TestableHoermannHcp door;
door.connection_timeout_ms_ = 20;
connect_controller(door);
// Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
ASSERT_TRUE(door.set_position(0.5f));
consume_command(door);
ASSERT_TRUE(door.toggle_light());
std::this_thread::sleep_for(std::chrono::milliseconds(30));
door.update();
ASSERT_FALSE(door.is_valid());
// Back on the bus and travelling past where the target was: nothing should stop the door now.
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100}));
auto [pressed, pressed_2] = poll_command(door);
EXPECT_EQ(pressed, 0x0000);
EXPECT_EQ(pressed_2, 0x0000);
}
// Withdrawing a later toggle must not take the deadline of the one already on the wire with it, or a door
// that never reports the lamp would leave the entity waiting for ever.
TEST(HoermannHcpLightPlatformTest, WithdrawingALaterToggleKeepsTheWatchdogArmed) {
LightFixture fixture;
fixture.door.connection_timeout_ms_ = 20;
fixture.bring_up();
fixture.command(true);
consume_command(fixture.door); // the first toggle is released but never reported back
fixture.command(false);
ASSERT_EQ(fixture.door.light_toggles_in_flight_, 2);
fixture.command(true); // withdraws the second, leaving the first outstanding
ASSERT_EQ(fixture.door.light_toggles_in_flight_, 1);
// The door still says nothing about the lamp, so the wait has to time out on its own.
std::this_thread::sleep_for(std::chrono::milliseconds(30));
fixture.report_lamp(false);
EXPECT_EQ(fixture.door.light_toggles_in_flight_, 0);
EXPECT_FALSE(fixture.entity_on());
}
// A request refused while the lamp is unknown must leave the entity idle. Republishing unconditionally would
// re-enter write_state() on every loop, so the platform would never stop asking to be written.
TEST(HoermannHcpLightPlatformTest, RefusedRequestLeavesTheEntityIdle) {
LightFixture fixture;
connect_controller(fixture.door);
fixture.settle();
ASSERT_FALSE(fixture.door.is_light_known());
// The lamp is unknown and the entity already shows off, so asking for off cannot be serviced or displayed.
fixture.command(false);
const int settled_writes = fixture.output.writes;
fixture.settle();
EXPECT_EQ(fixture.output.writes, settled_writes);
}
// A door that acts on the key press and reports the lamp before the release is even fetched leaves nothing
// outstanding. Arming the watchdog on that release anyway would leave it firing on every poll and abandoning
// the next toggle the moment it is queued.
TEST(HoermannHcpLightTest, ReleaseWithNothingOutstandingLeavesTheWatchdogDisarmed) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
ASSERT_TRUE(door.toggle_light());
poll_command(door); // the door is shown the key press
// The door acts on it and reports the lamp straight away, which settles the count.
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010));
ASSERT_EQ(door.light_toggles_in_flight_, 0);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
poll_command(door); // the release, with nothing left to wait for
EXPECT_EQ(door.light_toggle_released_at_, 0u);
}
// A restore mode that boots the entity on replays a lit state the door has never confirmed, so it has to be
// adopted back to what is known rather than turned into a command.
TEST(HoermannHcpLightPlatformTest, RestoredOnStateIsAdoptedNotCommanded) {
LightFixture fixture{light::LIGHT_ALWAYS_ON};
connect_controller(fixture.door);
fixture.settle();
auto [idle, idle_2] = poll_command(fixture.door);
EXPECT_EQ(idle, 0x0000);
EXPECT_EQ(idle_2, 0x0000);
EXPECT_FALSE(fixture.entity_on());
}
// A reversing press before the toggle is fetched cancels it, so the lamp never moves.
TEST(HoermannHcpLightPlatformTest, ReversingPressCancelsTheQueuedToggle) {
LightFixture fixture;
fixture.bring_up();
fixture.command(true);
ASSERT_TRUE(fixture.door.is_light_toggle_pending_());
fixture.command(false);
EXPECT_FALSE(fixture.door.is_light_toggle_pending_());
EXPECT_FALSE(fixture.entity_on());
// Nothing is left for the controller to fetch, so the lamp stays off as asked.
auto [pressed, pressed_2] = poll_command(fixture.door);
EXPECT_EQ(pressed, 0x0000);
EXPECT_EQ(pressed_2, 0x0000);
}
// A lamp switched at the door itself is not one of our toggles landing, so a toggle the door has not even
// been shown has to keep counting.
TEST(HoermannHcpLightTest, DoorSideLampChangeLeavesAnUnsentToggleCounted) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
ASSERT_TRUE(door.toggle_light());
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010));
EXPECT_EQ(door.light_toggles_in_flight_, 1);
// The toggle still in the slot will invert what the door just reported.
EXPECT_FALSE(door.is_light_heading_on());
}
// Once the toggles left over are all still waiting in the slot, nothing the door has seen is outstanding,
// so the wait has to end rather than time out against toggles the door was never shown.
TEST(HoermannHcpLightTest, SettlingTheLastSentToggleEndsTheWait) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
ASSERT_TRUE(door.toggle_light());
consume_command(door); // shown to the door, so the wait for a lamp report starts
ASSERT_TRUE(door.toggle_light()); // queued behind it, never shown
ASSERT_NE(door.light_toggle_released_at_, 0u);
// The door reports the lamp change the first toggle caused, leaving only the unsent one.
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010));
ASSERT_EQ(door.light_toggles_in_flight_, 1);
EXPECT_EQ(door.light_toggle_released_at_, 0u);
}
// The watchdog gives up on the toggles the door was shown, but one still waiting in the command slot is
// going to fire, so it keeps counting.
TEST(HoermannHcpLightTest, WatchdogKeepsAToggleTheDoorHasNotSeen) {
TestableHoermannHcp door;
// Wide enough that the toggle queued after the sleep cannot expire before update() runs.
door.connection_timeout_ms_ = 200;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
ASSERT_TRUE(door.toggle_light());
consume_command(door); // shown to the door, which then says nothing about the lamp
std::this_thread::sleep_for(std::chrono::milliseconds(220));
// Queued just now, so only the wait for the first toggle is overdue.
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
ASSERT_TRUE(door.toggle_light());
door.update();
EXPECT_EQ(door.light_toggles_in_flight_, 1);
EXPECT_TRUE(door.is_light_toggle_pending_());
EXPECT_TRUE(door.is_light_heading_on());
}
// Only the parity of the outstanding count says where the lamp is heading, so the count must not run away.
TEST(HoermannHcpLightTest, TogglesAreRefusedOnceTooManyAreOutstanding) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
// The door takes every key press but never reports the lamp, so nothing is ever confirmed.
for (int i = 0; i < 4; i++) {
ASSERT_TRUE(door.toggle_light());
consume_command(door);
}
EXPECT_FALSE(door.toggle_light());
EXPECT_EQ(door.light_toggles_in_flight_, 4);
}
// A controller that stops carrying the lamp register leaves nothing refreshing it, so the entity has to flag
// itself rather than command against what was read before.
TEST(HoermannHcpLightPlatformTest, BroadcastWithoutTheLampRegisterMarksItUnknown) {
LightFixture fixture;
fixture.bring_up();
ASSERT_TRUE(fixture.door.is_light_known());
fixture.report_broadcast(make_registers({0x0000, 0x0000, 0x4000}));
EXPECT_FALSE(fixture.door.is_light_known());
EXPECT_TRUE(fixture.output.status_has_warning());
}
// A publish of ours only reaches write_state() a loop pass later. If the lamp changed at the door in that
// gap, the write still carries the old value and must not be taken for a request to invert the lamp.
TEST(HoermannHcpLightPlatformTest, PublishOvertakenByTheLampIsNotARequest) {
LightFixture fixture;
fixture.bring_up();
// A door command holds the only command slot, so the request below is refused and the lamp published back.
ASSERT_TRUE(fixture.door.open_door());
auto call = fixture.state.make_call();
call.set_state(true);
call.perform();
fixture.state.loop(); // the refusal happens here and schedules the publish for a later pass
// The slot frees up and the lamp is switched on at the door before that publish arrives.
consume_command(fixture.door);
fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010));
fixture.settle();
EXPECT_EQ(fixture.door.light_toggles_in_flight_, 0);
EXPECT_TRUE(fixture.entity_on());
}
} // namespace esphome::hoermann_hcp::testing
+2 -2
View File
@@ -12,7 +12,7 @@ esphome:
data_template:
message: The humidity is {{ my_variable }}%.
variables:
my_variable: "return id(ha_hello_world_temperature).state;"
my_variable: !lambda "return id(ha_hello_world_temperature).state;"
- homeassistant.action:
action: notify.html5
data:
@@ -24,7 +24,7 @@ esphome:
data_template:
message: The humidity is {{ my_variable }}%.
variables:
my_variable: "return id(ha_hello_world_temperature).state;"
my_variable: !lambda "return id(ha_hello_world_temperature).state;"
wifi:
ssid: MySSID
@@ -0,0 +1,24 @@
# `platform: file` entry using the `defaults:`/`files:` shape, including the
# per-type byte_order drop when an entry overrides to a non-endian type.
display:
- platform: sdl
id: image_display
auto_clear_enabled: false
dimensions:
width: 480
height: 480
image:
- platform: file
defaults:
type: rgb565
transparency: opaque
byte_order: little_endian
resize: 50x50
dither: FloydSteinberg
files:
- id: platform_defaults_image
file: ../../pnglogo.png
- id: platform_defaults_binary
file: ../../pnglogo.png
type: binary
@@ -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
+181
View File
@@ -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
@@ -1181,6 +1272,38 @@ lvgl:
- logger.log:
format: "bar value %f"
args: [x]
- table:
id: table_id
align: top_mid
y: 60
columns:
- width: 40%
- width: 80
rows:
- ["Name", "Value"]
- cells:
- text: "Temp"
merge_right: true
- text: "22.5"
text_crop: true
selected_row: 0
on_value:
then:
- logger.log:
format: "table selected row %u col %u"
args: [row, column]
on_click:
then:
- lvgl.table.cell.update:
id: table_id
row: 1
column: 1
text: !lambda return str_sprintf("%.1f", (float) rand() / RAND_MAX * 100);
merge_right: false
- lvgl.table.update:
id: table_id
selected_row: !lambda return (int) ((float) rand() / RAND_MAX * 2);
selected_column: 0
- line:
id: lv_line_id
align: center
@@ -1214,6 +1337,64 @@ lvgl:
id: checkbox_id
text: Checkbox
align: bottom_right
- list:
id: test_list_id
align: top_right
width: 150px
height: 120px
pad_row: 4
on_add:
- logger.log:
format: "list entry added at %d"
args: [list_index]
on_remove:
- logger.log:
format: "list entry removed at %d"
args: [list_index]
on_click:
- lvgl.list.add_text:
id: test_list_id
text: !lambda return "Section";
- lvgl.list.add_text:
id: test_list_id
text: "Pinned section"
index: 0
- lvgl.list.add:
id: test_list_id
button:
text: "Entry"
checkable: true
- lvgl.list.add:
id: test_list_id
index: 1
obj:
widgets:
- label:
text: !lambda return "Dynamic row " + std::to_string(millis());
- button:
widgets:
- label:
text: "Tap"
on_click:
- lambda: |-
ESP_LOGD("lvgl", "dynamic row button clicked, row %d",
lvgl::lv_list_get_row_index(id(test_list_id), static_cast<lv_obj_t *>(lv_event_get_target(event))));
- dropdown:
options:
- "One"
- "Two"
on_value:
- lambda: |-
ESP_LOGD("lvgl", "dynamic row dropdown changed, row %d",
lvgl::lv_list_get_row_index(id(test_list_id), static_cast<lv_obj_t *>(lv_event_get_target(event))));
- lvgl.list.remove:
id: test_list_id
index: 0
- lvgl.list.clear:
id: test_list_id
- lvgl.list.update:
id: test_list_id
pad_row: 8
- slider:
id: slider_id
align: top_mid
+73
View File
@@ -0,0 +1,73 @@
esphome:
name: lvgl-list-validate
host:
logger:
display:
- platform: sdl
id: sdl0
dimensions:
width: 320
height: 240
lvgl:
displays: sdl0
widgets:
# Two independent lists, each with their own on_add/on_remove and, for list_a,
# more than one automation under the same trigger key -- checks that the
# per-list trigger bookkeeping is keyed correctly and doesn't require exactly
# one automation.
- list:
id: validate_list_a
align: center
pad_row: 6
on_add:
- logger.log:
format: "a: added %d"
args: [list_index]
- logger.log:
format: "a: also added %d"
args: [list_index]
on_remove:
- logger.log:
format: "a: removed %d"
args: [list_index]
on_boot:
# lvgl.list.add_text and lvgl.list.add both take an optional, templatable index.
- lvgl.list.add_text:
id: validate_list_a
text: "Header"
index: !lambda return 0;
# any registered widget type is valid as the single lvgl.list.add key.
- lvgl.list.add:
id: validate_list_a
checkbox:
align: center
text: "Option"
- lvgl.list.add:
id: validate_list_a
index: !lambda return 0;
switch:
align: center
- lvgl.list.add:
id: validate_list_a
spinner:
align: center
- lvgl.list.add:
id: validate_list_a
obj:
align: center
- lvgl.list.remove:
id: validate_list_a
index: !lambda return 0;
- lvgl.list.clear:
id: validate_list_a
- list:
id: validate_list_b
align: center
on_remove:
- logger.log:
format: "b: removed %d"
args: [list_index]
@@ -1,165 +1,112 @@
#include <array>
#include <utility>
#include "../common.h"
#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h"
namespace esphome::mitsubishi_cn105::testing {
struct MitsubishiCN105ClimateTestContext {
MitsubishiCN105Component component;
MitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext() { this->sut.set_parent(&this->component); }
};
TEST(MitsubishiCN105ClimateTests, CelsiusTemperatureMappingAndTraitsMatchExpectedValues) {
MitsubishiCN105ClimateTestContext context;
for (int temperature = 16; temperature <= 31; ++temperature) {
EXPECT_EQ(context.component.get_temperature_mapping().to_mitsubishi(temperature), temperature);
EXPECT_EQ(context.component.get_temperature_mapping().from_mitsubishi(temperature), temperature);
}
const auto traits = context.sut.traits();
EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::CELSIUS);
EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 16.0f);
EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 31.0f);
EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 1.0f);
EXPECT_FLOAT_EQ(traits.get_visual_current_temperature_step(), 0.5f);
}
TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingAndTraitsMatchExpectedValues) {
MitsubishiCN105ClimateTestContext context;
context.component.set_use_fahrenheit(true);
const std::array cases{
std::pair{61, 16.0f}, std::pair{62, 16.5f}, std::pair{63, 17.0f}, std::pair{64, 17.5f}, std::pair{65, 18.0f},
std::pair{66, 18.5f}, std::pair{67, 19.0f}, std::pair{68, 20.0f}, std::pair{69, 21.0f}, std::pair{70, 21.5f},
std::pair{71, 22.0f}, std::pair{72, 22.5f}, std::pair{73, 23.0f}, std::pair{74, 23.5f}, std::pair{75, 24.0f},
std::pair{76, 24.5f}, std::pair{77, 25.0f}, std::pair{78, 25.5f}, std::pair{79, 26.0f}, std::pair{80, 26.5f},
std::pair{81, 27.0f}, std::pair{82, 27.5f}, std::pair{83, 28.0f}, std::pair{84, 28.5f}, std::pair{85, 29.0f},
std::pair{86, 29.5f}, std::pair{87, 30.0f}, std::pair{88, 30.5f},
};
for (const auto &[fahrenheit, mitsubishi_celsius] : cases) {
EXPECT_FLOAT_EQ(context.component.get_temperature_mapping().to_mitsubishi(fahrenheit), mitsubishi_celsius);
EXPECT_FLOAT_EQ(context.component.get_temperature_mapping().from_mitsubishi(mitsubishi_celsius), fahrenheit);
}
const auto traits = context.sut.traits();
EXPECT_EQ(traits.get_temperature_unit(), TemperatureUnit::FAHRENHEIT);
EXPECT_FLOAT_EQ(traits.get_visual_min_temperature(), 61.0f);
EXPECT_FLOAT_EQ(traits.get_visual_max_temperature(), 88.0f);
EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 1.0f);
EXPECT_FLOAT_EQ(traits.get_visual_current_temperature_step(), 1.0f);
}
TEST(MitsubishiCN105ClimateTests, FahrenheitTemperatureMappingUsesLinearConversionOutsideSetpointRange) {
auto mapping = TemperatureMapping();
mapping.set_use_fahrenheit(true);
const std::array cases{
std::pair{0.0f, 32.0f}, std::pair{10.0f, 50.0f}, std::pair{15.5f, 59.9f},
std::pair{31.0f, 87.8f}, std::pair{35.0f, 95.0f}, std::pair{40.0f, 104.0f},
};
for (const auto &[celsius, fahrenheit] : cases) {
EXPECT_FLOAT_EQ(mapping.from_mitsubishi(celsius), fahrenheit);
}
}
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeOffLeavesTraitsEmpty) {
TestableMitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext context;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_OFF);
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_OFF);
EXPECT_FALSE(sut.traits().get_supports_swing_modes());
EXPECT_FALSE(context.sut.traits().get_supports_swing_modes());
}
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeVerticalExposesOffAndVertical) {
TestableMitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext context;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
}
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeHorizontalExposesOffAndHorizontal) {
TestableMitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext context;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_FALSE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_FALSE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
}
TEST(MitsubishiCN105ClimateTests, SupportedSwingModeBothExposesAllExpectedModes) {
TestableMitsubishiCN105Climate sut;
MitsubishiCN105ClimateTestContext context;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
context.sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_TRUE(sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsVerticalSwingWhenSupported) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_VERTICAL);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsHorizontalSwingWhenSupported) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
sut.status().vane_mode = MitsubishiCN105::VaneMode::AUTO;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_HORIZONTAL);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsBothSwingWhenSupported) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_BOTH);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesMapsSwingOffWhenNoSwingActive) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
sut.status().vane_mode = MitsubishiCN105::VaneMode::POSITION_3;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesRemembersLastNonSwingPositions) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
sut.status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::RIGHT;
sut.apply_values_();
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_4);
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::RIGHT);
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
sut.apply_values_();
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_4);
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::RIGHT);
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_BOTH);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesDoesNotOverwriteRememberedPositionWithUnknownValues) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_BOTH);
sut.last_non_swing_vane_mode_ = MitsubishiCN105::VaneMode::POSITION_2;
sut.last_non_swing_wide_vane_mode_ = MitsubishiCN105::WideVaneMode::LEFT;
sut.status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::UNKNOWN;
sut.apply_values_();
EXPECT_EQ(sut.last_non_swing_vane_mode_, MitsubishiCN105::VaneMode::POSITION_2);
EXPECT_EQ(sut.last_non_swing_wide_vane_mode_, MitsubishiCN105::WideVaneMode::LEFT);
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesIgnoresUnsupportedVerticalSwingState) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_HORIZONTAL);
sut.status().vane_mode = MitsubishiCN105::VaneMode::SWING;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::CENTER;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
}
TEST(MitsubishiCN105ClimateTests, ApplyValuesIgnoresUnsupportedHorizontalSwingState) {
TestableMitsubishiCN105Climate sut;
sut.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
sut.status().vane_mode = MitsubishiCN105::VaneMode::AUTO;
sut.status().wide_vane_mode = MitsubishiCN105::WideVaneMode::SWING;
sut.apply_values_();
EXPECT_EQ(sut.swing_mode, climate::CLIMATE_SWING_OFF);
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_OFF));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_VERTICAL));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_HORIZONTAL));
EXPECT_TRUE(context.sut.traits().supports_swing_mode(climate::CLIMATE_SWING_BOTH));
}
} // namespace esphome::mitsubishi_cn105::testing
@@ -0,0 +1,99 @@
#include "../common.h"
#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_swing_mode_manager.h"
namespace esphome::mitsubishi_cn105::testing {
static SwingModeManager make_swing_mode_manager(std::initializer_list<climate::ClimateSwingMode> supported_modes) {
SwingModeManager manager;
climate::ClimateSwingModeMask supported_swing_modes;
for (const auto mode : supported_modes)
supported_swing_modes.insert(mode);
manager.set_supported_swing_modes(supported_swing_modes);
return manager;
}
TEST(SwingModeManagerTests, StatusMapsVerticalSwingWhenSupported) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL});
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::CENTER),
std::optional{climate::CLIMATE_SWING_VERTICAL});
}
TEST(SwingModeManagerTests, StatusMapsHorizontalSwingWhenSupported) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL});
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::WideVaneMode::SWING),
std::optional{climate::CLIMATE_SWING_HORIZONTAL});
}
TEST(SwingModeManagerTests, StatusMapsBothSwingWhenSupported) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::SWING),
std::optional{climate::CLIMATE_SWING_BOTH});
}
TEST(SwingModeManagerTests, StatusMapsSwingOffWhenNoSwingActive) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
EXPECT_EQ(
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::POSITION_3, MitsubishiCN105::WideVaneMode::CENTER),
std::optional{climate::CLIMATE_SWING_OFF});
}
TEST(SwingModeManagerTests, RemembersLastNonSwingPositions) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::WideVaneMode::RIGHT);
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::SWING);
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::VaneMode::POSITION_4});
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::WideVaneMode::RIGHT});
}
TEST(SwingModeManagerTests, UnknownValuesDoNotOverwriteRememberedPositions) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::POSITION_2, MitsubishiCN105::WideVaneMode::LEFT);
manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::UNKNOWN, MitsubishiCN105::WideVaneMode::UNKNOWN);
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::VaneMode::POSITION_2});
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_OFF), std::optional{MitsubishiCN105::WideVaneMode::LEFT});
}
TEST(SwingModeManagerTests, UnsupportedVerticalSwingStateIsIgnored) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL});
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::CENTER),
std::optional{climate::CLIMATE_SWING_OFF});
}
TEST(SwingModeManagerTests, UnsupportedHorizontalSwingStateIsIgnored) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL});
EXPECT_EQ(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::WideVaneMode::SWING),
std::optional{climate::CLIMATE_SWING_OFF});
}
TEST(SwingModeManagerTests, SwingModeFromReturnsNulloptWhenNoSwingModesSupported) {
auto manager = make_swing_mode_manager({});
EXPECT_FALSE(manager.update_and_get_swing_mode(MitsubishiCN105::VaneMode::SWING, MitsubishiCN105::WideVaneMode::SWING)
.has_value());
}
TEST(SwingModeManagerTests, VaneFromSwingModeReturnsNulloptWhenVerticalUnsupported) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL});
EXPECT_FALSE(manager.vane_from(climate::CLIMATE_SWING_VERTICAL).has_value());
}
TEST(SwingModeManagerTests, WideVaneFromSwingModeReturnsNulloptWhenHorizontalUnsupported) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL});
EXPECT_FALSE(manager.wide_vane_from(climate::CLIMATE_SWING_HORIZONTAL).has_value());
}
TEST(SwingModeManagerTests, VaneAndWideVaneFromSwingModeMapSwingModes) {
auto manager = make_swing_mode_manager({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL,
climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH});
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_VERTICAL), std::optional{MitsubishiCN105::VaneMode::SWING});
EXPECT_EQ(manager.vane_from(climate::CLIMATE_SWING_BOTH), std::optional{MitsubishiCN105::VaneMode::SWING});
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_HORIZONTAL),
std::optional{MitsubishiCN105::WideVaneMode::SWING});
EXPECT_EQ(manager.wide_vane_from(climate::CLIMATE_SWING_BOTH), std::optional{MitsubishiCN105::WideVaneMode::SWING});
}
} // namespace esphome::mitsubishi_cn105::testing
@@ -42,11 +42,17 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
// All bytes from UART should be consumed
EXPECT_TRUE(ctx.uart.rx.empty());
// After successful connect we request status, first settings (0x02)
// Defer the first settings request (0x02) until the next update.
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::DEFERRED_STATUS_REQUEST);
EXPECT_TRUE(ctx.uart.tx.empty());
ctx.sut.set_current_time(201);
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7B));
EXPECT_EQ(ctx.sut.operation_start_ms_, 200);
EXPECT_EQ(ctx.sut.operation_start_ms_, 201);
// Clear TX bytes.
ctx.uart.tx.clear();
@@ -75,15 +81,24 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
EXPECT_EQ(ctx.sut.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_4);
EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::SWING);
// Now fetch telemetry (0x03)
// Defer the telemetry request (0x03) until the next update.
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::DEFERRED_STATUS_REQUEST);
EXPECT_TRUE(ctx.uart.tx.empty());
ctx.sut.set_current_time(301);
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x42, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7A));
EXPECT_EQ(ctx.sut.operation_start_ms_, 300);
EXPECT_EQ(ctx.sut.operation_start_ms_, 301);
// Clear TX bytes.
ctx.uart.tx.clear();
// Queue a setting while waiting for telemetry.
ctx.sut.set_power(true);
// Telemetry response
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x0B, 0x00, 0x00,
0xAA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xA5});
@@ -103,6 +118,13 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) {
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
EXPECT_EQ(ctx.sut.operation_start_ms_, 400);
// Apply the pending setting on the next update, outside RX processing.
ctx.sut.set_current_time(401);
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
EXPECT_FALSE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.operation_start_ms_, 401);
}
TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) {
@@ -469,6 +491,36 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) {
EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0);
}
TEST(MitsubishiCN105Tests, PendingSettingsTakePriorityOverDueTelemetry) {
MitsubishiCN105TestsContext ctx;
ctx.sut.status_.target_temperature = 24.0f;
ctx.sut.status_.room_temperature = 21.0f;
ASSERT_TRUE(ctx.sut.is_status_initialized());
ctx.sut.state_ = TestableMitsubishiCN105::State::STATUS_UPDATED;
ctx.sut.set_state(TestableMitsubishiCN105::State::SCHEDULE_NEXT_STATUS_UPDATE);
ctx.sut.set_current_time(1000);
ASSERT_FALSE(ctx.sut.update());
ASSERT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::UPDATING_STATUS);
ctx.uart.tx.clear();
ctx.sut.set_power(true);
ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x08, 0x07,
0x00, 0x04, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3C});
ctx.sut.set_current_time(1001);
ASSERT_TRUE(ctx.sut.update());
EXPECT_TRUE(ctx.uart.tx.empty());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
ctx.sut.set_current_time(1002);
ASSERT_FALSE(ctx.sut.update());
EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS);
EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7B));
}
TEST(MitsubishiCN105Tests, SetAndClearRemoteRoomTemp) {
MitsubishiCN105TestsContext ctx;
@@ -64,25 +64,4 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 {
void set_current_time(uint32_t ms) { test_loop_time_ms = ms; }
};
class TestableMitsubishiCN105Climate : public MitsubishiCN105Climate {
public:
TestableMitsubishiCN105Climate() { this->set_parent(&this->component_); }
using MitsubishiCN105Climate::apply_values_;
using MitsubishiCN105Climate::last_non_swing_vane_mode_;
using MitsubishiCN105Climate::last_non_swing_wide_vane_mode_;
MitsubishiCN105::Status &status() { return const_cast<MitsubishiCN105::Status &>(this->component_.status()); }
protected:
MitsubishiCN105Component component_;
};
class TestableMitsubishiCN105Component : public MitsubishiCN105Component {
public:
MitsubishiCN105::Status &mutable_status() { return const_cast<MitsubishiCN105::Status &>(this->status()); }
void notify_status() { this->status_callback_.call(); }
};
} // namespace esphome::mitsubishi_cn105::testing
@@ -3,6 +3,7 @@ mitsubishi_cn105:
uart_id: uart_bus
update_interval: 30s
telemetry_request_min_interval: 120s
use_fahrenheit: true
vane:
on_state:
- logger.log:
@@ -3,7 +3,7 @@
namespace esphome::mitsubishi_cn105::testing {
TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) {
TestableMitsubishiCN105Component hub;
MitsubishiCN105Component hub;
size_t callback_count = 0;
std::optional<VerticalVaneMode> callback_direction;
hub.add_on_vane_state_callback([&](const VaneState &state) {
@@ -11,8 +11,9 @@ TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) {
callback_direction = state.vertical.direction;
});
hub.mutable_status().room_temperature = 20.0f;
hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4;
hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
hub.set_target_temperature(20.0f);
hub.set_vane_mode(MitsubishiCN105::VaneMode::POSITION_4);
hub.publish_status();
EXPECT_EQ(callback_count, 1);
@@ -25,7 +26,7 @@ TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) {
}
TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) {
TestableMitsubishiCN105Component hub;
MitsubishiCN105Component hub;
size_t status_callback_count = 0;
size_t vane_callback_count = 0;
std::optional<VerticalVaneMode> callback_direction;
@@ -35,15 +36,16 @@ TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) {
callback_direction = state.vertical.direction;
});
hub.mutable_status().room_temperature = 20.0f;
hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN;
hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
hub.set_target_temperature(20.0f);
ASSERT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::UNKNOWN);
hub.publish_status();
EXPECT_EQ(status_callback_count, 1);
EXPECT_EQ(vane_callback_count, 1);
EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_UNKNOWN});
hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4;
hub.set_vane_mode(MitsubishiCN105::VaneMode::POSITION_4);
hub.publish_status();
EXPECT_EQ(status_callback_count, 2);
@@ -52,7 +54,7 @@ TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) {
}
TEST(MitsubishiCN105ComponentTests, VaneCallAppliesVerticalDirection) {
TestableMitsubishiCN105Component hub;
MitsubishiCN105Component hub;
auto call = hub.make_vane_call();
call.vertical.set_direction(VERTICAL_VANE_MODE_POSITION_5);
@@ -62,12 +64,11 @@ TEST(MitsubishiCN105ComponentTests, VaneCallAppliesVerticalDirection) {
}
TEST(MitsubishiCN105ComponentTests, VaneControlActionAppliesConfiguredFields) {
TestableMitsubishiCN105Component hub;
MitsubishiCN105Component hub;
VaneControlAction<> action(&hub, [](VaneCall &call) { call.vertical.set_direction(VERTICAL_VANE_MODE_SWING); });
action.play();
EXPECT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::SWING);
}
} // namespace esphome::mitsubishi_cn105::testing
@@ -3,14 +3,9 @@
namespace esphome::mitsubishi_cn105::testing {
class TestableMitsubishiCN105VerticalVaneDirectionSelect : public MitsubishiCN105VerticalVaneDirectionSelect {
public:
using MitsubishiCN105VerticalVaneDirectionSelect::control;
};
struct VerticalVaneDirectionSelectTestContext {
TestableMitsubishiCN105Component hub;
TestableMitsubishiCN105VerticalVaneDirectionSelect select;
MitsubishiCN105Component hub;
MitsubishiCN105VerticalVaneDirectionSelect select;
VerticalVaneDirectionSelectTestContext() {
this->select.traits.set_options({"Auto", "1", "2", "3", "4", "5", "Swing"});
@@ -31,13 +26,15 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, MapsIndexesToVaneModes) {
for (size_t i = 0; i < expected_modes.size(); ++i) {
SCOPED_TRACE(i);
ctx.select.control(i);
ctx.select.make_call().set_index(i).perform();
EXPECT_EQ(ctx.hub.status().vane_mode, expected_modes[i]);
}
}
TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, PublishesIncomingVaneModes) {
VerticalVaneDirectionSelectTestContext ctx;
ctx.hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
ctx.hub.set_target_temperature(20.0f);
constexpr std::array modes{
MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1,
@@ -48,13 +45,12 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, PublishesIncomingVaneModes
for (size_t i = 0; i < modes.size(); ++i) {
SCOPED_TRACE(i);
ctx.hub.mutable_status().vane_mode = modes[i];
ctx.hub.notify_status();
ctx.hub.set_vane_mode(modes[i]);
ctx.hub.publish_status();
EXPECT_EQ(ctx.select.active_index(), std::optional{i});
}
ctx.hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN;
ctx.hub.notify_status();
ctx.select.publish_vane_state(MitsubishiCN105::VaneMode::UNKNOWN);
EXPECT_EQ(ctx.select.active_index(), std::optional{modes.size() - 1});
}
@@ -64,14 +60,15 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ControlPublishesSelectAndC
climate_entity.set_parent(&ctx.hub);
climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
ctx.hub.mutable_status().room_temperature = 20.0f;
ctx.hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
ctx.hub.set_target_temperature(20.0f);
climate_entity.setup();
ctx.select.control(6);
ctx.select.make_call().set_index(6).perform();
EXPECT_EQ(ctx.select.active_index(), std::optional<size_t>{6});
EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_VERTICAL);
ctx.select.control(3);
ctx.select.make_call().set_index(3).perform();
EXPECT_EQ(ctx.select.active_index(), std::optional<size_t>{3});
EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_OFF);
}
@@ -82,7 +79,8 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ClimateControlPublishesSel
climate_entity.set_parent(&ctx.hub);
climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL);
ctx.hub.mutable_status().room_temperature = 20.0f;
ctx.hub.set_telemetry_request_min_interval(SCHEDULER_DONT_RUN);
ctx.hub.set_target_temperature(20.0f);
climate_entity.setup();
climate_entity.make_call().set_swing_mode(climate::CLIMATE_SWING_VERTICAL).perform();
@@ -95,10 +93,9 @@ TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ClimateControlPublishesSel
TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, BeforeInitializationDoesNotPublishSelectState) {
VerticalVaneDirectionSelectTestContext ctx;
ctx.select.control(3);
ctx.select.make_call().set_index(3).perform();
EXPECT_EQ(ctx.hub.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_3);
EXPECT_FALSE(ctx.select.has_state());
}
} // namespace esphome::mitsubishi_cn105::testing
+46
View File
@@ -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
+44 -1
View File
@@ -1,14 +1,24 @@
#pragma once
#include <cstdint>
#include <cstring>
#include <span>
#include <vector>
#include "esphome/components/uart/uart_component.h"
#include "esphome/core/helpers.h"
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; }
@@ -30,4 +40,37 @@ class RecordingUART : public NullUART {
std::vector<uint8_t> written;
};
// A UART the test can inject received bytes into, so frames travel the full receive path
// (receive_modbus_frames -> parse -> dispatch) through hub.loop(). Writes are recorded.
class InjectableUART : public RecordingUART {
public:
bool peek_byte(uint8_t *data) override {
if (this->rx_.empty())
return false;
*data = this->rx_.front();
return true;
}
bool read_array(uint8_t *data, size_t len) override {
if (len > this->rx_.size())
return false;
memcpy(data, this->rx_.data(), len);
this->rx_.erase(this->rx_.begin(), this->rx_.begin() + len);
return true;
}
size_t available() override { return this->rx_.size(); }
// Queues a complete wire frame: address + PDU + CRC16 (low byte first).
void inject_frame(uint8_t address, std::span<const uint8_t> pdu) {
size_t start = this->rx_.size();
this->rx_.push_back(address);
this->rx_.insert(this->rx_.end(), pdu.begin(), pdu.end());
uint16_t crc = crc16(this->rx_.data() + start, this->rx_.size() - start);
this->rx_.push_back(crc & 0xFF);
this->rx_.push_back(crc >> 8);
}
private:
std::vector<uint8_t> rx_;
};
} // namespace esphome::modbus::testing
@@ -322,14 +322,14 @@ TEST(ModbusClientHubPriority, ContinuousReadRequeuesOnSuccessOnly) {
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
EXPECT_TRUE(hub.queued(0).options.continuous);
hub.force_send_next();
// A matching successful response cycles the continuous entry back to READY.
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
EXPECT_TRUE(hub.queued(0).options.continuous);
// An exception response ends the poll.
hub.force_send_next();
@@ -346,13 +346,13 @@ TEST(ModbusClientHubPriority, RetriedContinuousReadStaysContinuous) {
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_TRUE(hub.queued(0).continuous);
ASSERT_TRUE(hub.queued(0).options.continuous);
hub.force_send_next();
hub.timeout_waiting(); // no response -> device requests retry
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous); // the retried poll stays continuous
EXPECT_TRUE(hub.queued(0).options.continuous); // the retried poll stays continuous
}
// A one-shot duplicate downgrades a continuous poll to a one-shot (the mirror of a continuous
@@ -363,16 +363,16 @@ TEST(ModbusClientHubPriority, DuplicateSendDowngradesContinuous) {
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_TRUE(hub.queued(0).continuous);
ASSERT_TRUE(hub.queued(0).options.continuous);
device.read_holding_registers(0x100, 2); // one-shot duplicate downgrades the poll
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_FALSE(hub.queued(0).continuous);
EXPECT_FALSE(hub.queued(0).options.continuous);
EXPECT_EQ(hub.queued(0).pending, 1u);
// It runs one more cycle to serve the request, then stops - not re-queued as a poll.
hub.force_send_next();
EXPECT_FALSE(hub.waiting_command().continuous);
EXPECT_FALSE(hub.waiting_command().options.continuous);
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response);
EXPECT_EQ(hub.queued_frames(), 0u);
@@ -407,16 +407,16 @@ TEST(ModbusClientHubPriority, DowngradeAfterTerminalKeepsRequestAlive) {
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_TRUE(hub.queued(0).continuous);
ASSERT_TRUE(hub.queued(0).options.continuous);
hub.force_send_next();
const uint8_t exception_response[] = {0x83, 0x02};
hub.receive_frame_for_test(0x02, exception_response); // exception ends the poll; on_error re-sends
EXPECT_EQ(device.error_count_, 1); // one terminal delivered so far
ASSERT_EQ(hub.queued_frames(), 1u); // the re-send survived the sweep instead of being erased
EXPECT_FALSE(hub.queued(0).continuous); // downgraded to a one-shot
EXPECT_EQ(hub.queued(0).pending, 1u); // debt restored so the request runs
EXPECT_EQ(device.error_count_, 1); // one terminal delivered so far
ASSERT_EQ(hub.queued_frames(), 1u); // the re-send survived the sweep instead of being erased
EXPECT_FALSE(hub.queued(0).options.continuous); // downgraded to a one-shot
EXPECT_EQ(hub.queued(0).pending, 1u); // debt restored so the request runs
// And it runs to its own terminal - a good response this time - then the entry is gone.
hub.force_send_next();
@@ -434,18 +434,18 @@ TEST(ModbusClientHubPriority, ContinuousRequestUpgradesQueuedDuplicate) {
device.read_holding_registers(0x100, 2);
ASSERT_EQ(hub.queued_frames(), 1u);
ASSERT_FALSE(hub.queued(0).continuous);
ASSERT_FALSE(hub.queued(0).options.continuous);
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
EXPECT_TRUE(hub.queued(0).options.continuous);
// And it behaves as a poll from here: success cycles it back to READY.
hub.force_send_next();
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response);
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_TRUE(hub.queued(0).continuous);
EXPECT_TRUE(hub.queued(0).options.continuous);
}
// The transmit order is one key with three levels: writes, then one-shot reads, then continuous
@@ -473,7 +473,7 @@ TEST(ModbusClientHubPriority, WritesThenOneShotReadsThenContinuousPolls) {
EXPECT_EQ(hub.waiting_command().frame.pdu()[1], 0x02); // then the one-shot read
hub.timeout_waiting();
hub.force_send_next();
EXPECT_TRUE(hub.waiting_command().continuous); // and the poll takes what is left
EXPECT_TRUE(hub.waiting_command().options.continuous); // and the poll takes what is left
}
// continuous is ignored for writes: the frame still sends at WRITE priority, once.
@@ -485,7 +485,7 @@ TEST(ModbusClientHubPriority, ContinuousIgnoredForWrites) {
device.queue_pdu(write_pdu, {.continuous = true});
ASSERT_EQ(hub.queued_frames(), 1u);
EXPECT_EQ(hub.queued(0).priority(), CommandPriority::WRITE);
EXPECT_FALSE(hub.queued(0).continuous);
EXPECT_FALSE(hub.queued(0).options.continuous);
}
// A queued continuous poll does not count against immediate-send readiness: it ranks below every
@@ -496,7 +496,7 @@ TEST(ModbusClientHubPriority, ContinuousPollDoesNotBlockImmediateSend) {
EXPECT_TRUE(hub.tx_buffer_empty()); // nothing queued
device.read_holding_registers(0x100, 2, {.continuous = true});
ASSERT_TRUE(hub.queued(0).continuous);
ASSERT_TRUE(hub.queued(0).options.continuous);
EXPECT_TRUE(hub.tx_buffer_empty()); // a READY continuous poll still leaves room to send now
device.read_holding_registers(0x200, 2); // a one-shot does count
@@ -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 {
@@ -1878,34 +1921,24 @@ TEST(ModbusClientHubPriority, ResendFromOnResponseAbsorbsIntoCompletingCommand)
const uint8_t ok_response[] = {0x03, 0x04, 0x00, 0x2A, 0x01, 0x00};
hub.receive_frame_for_test(0x02, ok_response); // handler re-sends the identical frame mid-completion
ASSERT_EQ(hub.queued_frames(), 1u); // absorbed into the same entry, not a fresh twin
EXPECT_FALSE(hub.queued(0).continuous); // the one-shot re-send downgraded the poll
ASSERT_EQ(hub.queued_frames(), 1u); // absorbed into the same entry, not a fresh twin
EXPECT_FALSE(hub.queued(0).options.continuous); // the one-shot re-send downgraded the poll
}
// An exception-flagged function code is never silently re-sendable, even though the read check
// 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.
@@ -0,0 +1,155 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <span>
#include <vector>
#include "common.h"
#include "esphome/components/modbus/modbus.h"
namespace esphome::modbus::testing {
namespace {
// Records custom-response dispatches so tests can assert an unknown-length frame reached the device.
class CustomRecordingDevice : public ModbusClientDevice {
public:
using ModbusClientDevice::ModbusClientDevice;
void on_custom_response(std::span<const uint8_t> request_pdu, std::span<const uint8_t> response_pdu,
ResponseStatus status) override {
this->requests.emplace_back(request_pdu.begin(), request_pdu.end());
this->responses.emplace_back(response_pdu.begin(), response_pdu.end());
this->statuses.push_back(status);
}
std::vector<std::vector<uint8_t>> requests;
std::vector<std::vector<uint8_t>> responses;
std::vector<ResponseStatus> statuses;
};
// Every handler keeps its ILLEGAL_FUNCTION default; the hub's dispatch is what is under test.
class SilentServerDevice : public ModbusServerDevice {};
// Drives full client frames through the server hub's receive path (same shape as the broadcast tests).
class TestServerHub : public ModbusServerHub {
public:
bool tx_blocked() override { return false; }
// Builds a complete client frame (address + FC + data + CRC) and runs the full receive-side parser.
// Returns true once the buffer has fully drained.
bool run_receive_parser_for_test(uint8_t address, uint8_t function_code, std::span<const uint8_t> data) {
this->rx_buffer_.clear();
this->rx_buffer_.reserve(data.size() + 4);
this->rx_buffer_.push_back(address);
this->rx_buffer_.push_back(function_code);
this->rx_buffer_.insert(this->rx_buffer_.end(), data.begin(), data.end());
uint16_t crc = crc16(this->rx_buffer_.data(), this->rx_buffer_.size());
this->rx_buffer_.push_back(crc & 0xFF);
this->rx_buffer_.push_back(crc >> 8);
this->parse_modbus_frames();
return this->rx_buffer_.empty();
}
};
} // namespace
// 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. 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);
}
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);
}
// 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_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;
}
EXPECT_FALSE(helpers::is_function_code_custom(0x49));
// 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. 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)
<< "client_pdu_length disagrees for fc 0x" << std::hex << fc;
EXPECT_EQ(helpers::is_function_code_unknown_length(fc),
helpers::server_pdu_length(pdu, sizeof(pdu)) == MIN_PDU_SIZE)
<< "server_pdu_length disagrees for fc 0x" << std::hex << fc;
}
}
// 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
// scan the parser assumes a 4-byte frame, fails the CRC, and the response never reaches the device.
TEST(ModbusUnknownFunction, ClientParsesUnknownLengthResponse) {
InjectableUART uart;
ModbusClientHub hub;
hub.set_uart_parent(&uart);
hub.setup(); // computes frame timing from the baud rate
CustomRecordingDevice device(&hub, 0x02);
const uint8_t request[] = {0x49, 0x01};
ASSERT_TRUE(device.queue_pdu(request));
hub.loop(); // transmit
ASSERT_FALSE(uart.written.empty());
const uint8_t response_pdu[] = {0x49, 0x02, 0xAA, 0xBB};
uart.inject_frame(0x02, response_pdu);
hub.loop(); // receive + parse + match + dispatch
ASSERT_EQ(device.responses.size(), 1u);
EXPECT_EQ(device.requests[0], std::vector<uint8_t>(request, request + sizeof(request)));
EXPECT_EQ(device.responses[0], std::vector<uint8_t>(response_pdu, response_pdu + sizeof(response_pdu)));
EXPECT_FALSE(device.statuses[0].has_value());
}
// The server side of the same gap: a request with FC 0x49 for a registered device must parse (CRC
// scan again) so the hub can answer ILLEGAL_FUNCTION per the spec. Without the scan the frame fails
// to parse and the client gets silence instead of the exception.
TEST(ModbusUnknownFunction, ServerRepliesIllegalFunctionToUnknownLengthRequest) {
TestServerHub hub;
RecordingUART uart;
hub.set_uart_parent(&uart);
SilentServerDevice device;
device.set_address(0x02);
hub.register_device(&device);
const uint8_t data[] = {0x02, 0xAA, 0xBB};
ASSERT_TRUE(hub.run_receive_parser_for_test(0x02, 0x49, data));
// Expected reply: address + FC with exception flag + ILLEGAL_FUNCTION + CRC.
std::vector<uint8_t> expected = {0x02, 0xC9, 0x01};
uint16_t crc = crc16(expected.data(), expected.size());
expected.push_back(crc & 0xFF);
expected.push_back(crc >> 8);
EXPECT_EQ(uart.written, expected);
}
} // namespace esphome::modbus::testing
@@ -51,6 +51,7 @@ button:
# A pdu lambda can hand-assemble bytes or return a modbus::helpers::create_*_pdu() builder result.
- modbus_client.send:
address: 0x01
continuous: true
pdu: !lambda "return modbus::helpers::create_read_pdu(modbus::FunctionCode::READ_HOLDING_REGISTERS, 0x0010, 1);"
- modbus_client.send:
address: !lambda "return 1;"
@@ -91,6 +92,7 @@ button:
address: !lambda "return 1;"
start_address: 0x10
count: 2
continuous: true
on_response:
then:
- lambda: 'ESP_LOGI("modbus_client.test", "first=%u n=%u", values[0], (unsigned) values.size());'
@@ -98,6 +100,7 @@ button:
then:
- logger.log: "typed read timeout"
- modbus_client.read_input_registers:
continuous: !lambda "return false;"
address: 0x01
start_address: 0x20
on_custom_response:
@@ -113,12 +116,14 @@ button:
address: 0x01
start_address: 0x03
count: 16
continuous: true
on_response:
then:
- lambda: 'ESP_LOGI("modbus_client.test", "coil0=%d n=%u", bits[0], (unsigned) bits.size());'
- modbus_client.read_discrete_inputs:
address: 0x01
start_address: 0x00
continuous: true
on_error:
then:
- lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);'
@@ -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
@@ -13,7 +18,7 @@ namespace esphome::modbus_controller::testing {
// malformed. Built at its true byte count, the oversize frame is refused by the hub's size check with
// a log instead.
TEST(ModbusCommandPayload, CoilWritePayloadIsExactSizedNotTruncated) {
ModbusController controller;
ModbusController controller(nullptr, 1);
std::vector<bool> coils(modbus::MAX_NUM_OF_COILS_TO_WRITE + 1, true);
auto cmd = ModbusCommandItem::create_write_multiple_coils(&controller, 0x10, coils);
EXPECT_EQ(cmd.payload.size(), modbus::packed_bit_bytes(coils.size()));
@@ -21,7 +26,7 @@ TEST(ModbusCommandPayload, CoilWritePayloadIsExactSizedNotTruncated) {
// LSB-first packing with zeroed pad bits, matching the wire layout the PDU builders produce.
TEST(ModbusCommandPayload, CoilWritePacksLsbFirstWithZeroPad) {
ModbusController controller;
ModbusController controller(nullptr, 1);
const std::vector<bool> coils{true, false, true, true};
auto cmd = ModbusCommandItem::create_write_multiple_coils(&controller, 0x10, coils);
ASSERT_EQ(cmd.payload.size(), 1u);
@@ -29,3 +34,5 @@ TEST(ModbusCommandPayload, CoilWritePacksLsbFirstWithZeroPad) {
}
} // namespace esphome::modbus_controller::testing
#pragma GCC diagnostic pop
+28 -7
View File
@@ -2,6 +2,7 @@ modbus_controller:
- id: modbus_controller1
address: 0x2
modbus_id: modbus_bus
continuous: true
on_online:
then:
logger.log: "Module Online"
@@ -20,6 +21,7 @@ binary_sensor:
name: Test Binary Sensor with Lambda
register_type: input
address: 0x3201
reuse_previous_range: false
lambda: |-
return x;
@@ -84,6 +86,7 @@ select:
name: Test Select with Lambda
address: 1001
value_type: U_WORD
reuse_previous_range: auto
optionsmap:
"Off": 0
"On": 1
@@ -108,6 +111,22 @@ select:
return value;
sensor:
# custom_pdu polls a ready-made PDU (function code + data - no device address byte, no CRC); covers
# the set_custom_pdu codegen path and the custom-range polling constructor.
- platform: modbus_controller
modbus_controller_id: modbus_controller1
id: modbus_sensor_custom_pdu
name: Test Custom PDU Sensor
custom_pdu: [0x03, 0x00, 0x2A, 0x00, 0x01]
value_type: U_WORD
# Deprecated custom_command (leading byte 0x02 == modbus_controller1's address) drives the
# migrate_custom_command final-validate auto-migration path in CI.
- platform: modbus_controller
modbus_controller_id: modbus_controller1
id: modbus_sensor_custom_command
name: Test Custom Command Sensor
custom_command: [0x02, 0x03, 0x00, 0x2B, 0x00, 0x01]
value_type: U_WORD
- platform: modbus_controller
modbus_controller_id: modbus_controller1
id: modbus_sensor1
@@ -123,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
@@ -170,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
@@ -207,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
@@ -216,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
@@ -238,4 +258,5 @@ text_sensor:
register_type: holding
address: 0x9032
register_count: 1
reuse_previous_range: true
raw_encode: HEXBYTES
@@ -2,3 +2,4 @@
network:
enable_high_performance: true
tcp_send_buffer: 32kB
+7
View File
@@ -0,0 +1,7 @@
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# to_code must run: it defines USE_NOISE and adds the noise-c library
# the component sources under test need.
manifest.enable_codegen()
+1
View File
@@ -0,0 +1 @@
noise:
@@ -0,0 +1,2 @@
packages:
noise: !include common.yaml
@@ -0,0 +1,2 @@
packages:
noise: !include common.yaml
+2
View File
@@ -0,0 +1,2 @@
packages:
noise: !include common.yaml
@@ -0,0 +1,2 @@
packages:
noise: !include common.yaml
@@ -0,0 +1,199 @@
#include <gtest/gtest.h>
#include <cstring>
#include <noise/protocol.h>
#include "esphome/components/noise/noise.h"
#include "esphome/components/noise/noise_handshake.h"
namespace esphome::noise::testing {
using Action = NoiseResponderHandshake::Action;
// A raw noise-c initiator driving the same Noise_NNpsk0_25519_ChaChaPoly_SHA256
// pattern the responder class implements, so the tests exercise a real
// two-message handshake rather than mirrored calls into the class under test.
class Initiator {
public:
Initiator(const psk_t &psk, const uint8_t *prologue, size_t prologue_len) {
const NoiseProtocolId nid = {
.prefix_id = NOISE_PREFIX_STANDARD,
.pattern_id = NOISE_PATTERN_NN,
.modifier_ids = {NOISE_MODIFIER_PSK0},
.dh_id = NOISE_DH_CURVE25519,
.cipher_id = NOISE_CIPHER_CHACHAPOLY,
.hash_id = NOISE_HASH_SHA256,
.hybrid_id = NOISE_DH_NONE,
};
EXPECT_EQ(noise_handshakestate_new_by_id(&this->state_, &nid, NOISE_ROLE_INITIATOR), 0);
EXPECT_EQ(noise_handshakestate_set_pre_shared_key(this->state_, psk.data(), psk.size()), 0);
EXPECT_EQ(noise_handshakestate_set_prologue(this->state_, prologue, prologue_len), 0);
EXPECT_EQ(noise_handshakestate_start(this->state_), 0);
}
~Initiator() {
if (this->state_ != nullptr)
noise_handshakestate_free(this->state_);
if (this->send_ != nullptr)
noise_cipherstate_free(this->send_);
if (this->recv_ != nullptr)
noise_cipherstate_free(this->recv_);
}
Initiator(const Initiator &) = delete;
Initiator &operator=(const Initiator &) = delete;
size_t write_message(uint8_t *out, size_t capacity) {
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_output(mbuf, out, capacity);
EXPECT_EQ(noise_handshakestate_write_message(this->state_, &mbuf, nullptr), 0);
return mbuf.size;
}
int read_message(uint8_t *data, size_t len) {
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_input(mbuf, data, len);
return noise_handshakestate_read_message(this->state_, &mbuf, nullptr);
}
void split() { EXPECT_EQ(noise_handshakestate_split(this->state_, &this->send_, &this->recv_), 0); }
NoiseCipherState *send_{nullptr};
NoiseCipherState *recv_{nullptr};
private:
NoiseHandshakeState *state_{nullptr};
};
static const uint8_t PROLOGUE[] = {'t', 'e', 's', 't', 'p', 'r', 'o', 'l', 'o', 'g', 'u', 'e'};
static psk_t make_psk(uint8_t seed) {
psk_t psk;
for (size_t i = 0; i < psk.size(); i++) {
psk[i] = static_cast<uint8_t>(seed + i);
}
return psk;
}
TEST(NoiseResponderHandshakeTest, ActionFailedBeforeInit) {
NoiseResponderHandshake handshake;
EXPECT_EQ(handshake.action(), Action::ACTION_FAILED);
}
TEST(NoiseResponderHandshakeTest, MessageMethodsErrorBeforeInit) {
// The class doc promises a noise-c error, not a crash, when the message
// methods run outside their action() step; pin the library's null check
NoiseResponderHandshake handshake;
uint8_t buf[MAX_HANDSHAKE_SIZE] = {};
size_t out_len = 0;
EXPECT_NE(handshake.read_message(buf, sizeof(buf)), 0);
EXPECT_NE(handshake.write_message(buf, sizeof(buf), out_len), 0);
// Deliberately non-null: split() documents a nullptr postcondition on
// error, so a caller's uninitialized locals never hold garbage to free
auto *sentinel = reinterpret_cast<NoiseCipherState *>(0x1);
NoiseCipherState *send_cipher = sentinel;
NoiseCipherState *recv_cipher = sentinel;
EXPECT_NE(handshake.split(send_cipher, recv_cipher), 0);
EXPECT_EQ(send_cipher, nullptr);
EXPECT_EQ(recv_cipher, nullptr);
}
TEST(NoiseResponderHandshakeTest, FullHandshakeAndTransportRoundTrip) {
const psk_t psk = make_psk(7);
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0);
EXPECT_EQ(responder.action(), Action::ACTION_READ);
Initiator initiator(psk, PROLOGUE, sizeof(PROLOGUE));
uint8_t msg[MAX_HANDSHAKE_SIZE];
size_t msg_len = initiator.write_message(msg, sizeof(msg));
ASSERT_GT(msg_len, 0u);
ASSERT_EQ(responder.read_message(msg, msg_len), 0);
ASSERT_EQ(responder.action(), Action::ACTION_WRITE);
size_t reply_len = 0;
ASSERT_EQ(responder.write_message(msg, sizeof(msg), reply_len), 0);
ASSERT_GT(reply_len, 0u);
ASSERT_EQ(responder.action(), Action::ACTION_SPLIT);
ASSERT_EQ(initiator.read_message(msg, reply_len), 0);
initiator.split();
NoiseCipherState *send_cipher = nullptr;
NoiseCipherState *recv_cipher = nullptr;
ASSERT_EQ(responder.split(send_cipher, recv_cipher), 0);
ASSERT_NE(send_cipher, nullptr);
ASSERT_NE(recv_cipher, nullptr);
// The handshake state is released by split(); the class reports FAILED after
EXPECT_EQ(responder.action(), Action::ACTION_FAILED);
EXPECT_EQ(static_cast<size_t>(noise_cipherstate_get_mac_length(send_cipher)), MAC_SIZE);
// Responder encrypts, initiator decrypts
uint8_t frame[64];
static constexpr char PLAINTEXT[] = "encrypted ota";
std::memcpy(frame, PLAINTEXT, sizeof(PLAINTEXT));
NoiseBuffer mbuf;
noise_buffer_init(mbuf);
noise_buffer_set_inout(mbuf, frame, sizeof(PLAINTEXT), sizeof(frame));
ASSERT_EQ(noise_cipherstate_encrypt(send_cipher, &mbuf), 0);
EXPECT_EQ(mbuf.size, sizeof(PLAINTEXT) + MAC_SIZE);
noise_buffer_set_inout(mbuf, frame, mbuf.size, sizeof(frame));
ASSERT_EQ(noise_cipherstate_decrypt(initiator.recv_, &mbuf), 0);
ASSERT_EQ(mbuf.size, sizeof(PLAINTEXT));
EXPECT_EQ(std::memcmp(frame, PLAINTEXT, sizeof(PLAINTEXT)), 0);
noise_cipherstate_free(send_cipher);
noise_cipherstate_free(recv_cipher);
}
TEST(NoiseResponderHandshakeTest, ReInitRestartsHandshake) {
// The documented retry shape: a repeated init() frees the previous state
// and starts over. The first message under the new key authenticating
// proves the restart took effect; the old state surviving would fail the
// MAC here.
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0);
ASSERT_EQ(responder.init(make_psk(9), PROLOGUE, sizeof(PROLOGUE)), 0);
EXPECT_EQ(responder.action(), Action::ACTION_READ);
Initiator initiator(make_psk(9), PROLOGUE, sizeof(PROLOGUE));
uint8_t msg[MAX_HANDSHAKE_SIZE];
size_t msg_len = initiator.write_message(msg, sizeof(msg));
ASSERT_GT(msg_len, 0u);
EXPECT_EQ(responder.read_message(msg, msg_len), 0);
}
TEST(NoiseResponderHandshakeTest, WrongPskFailsWithMacFailure) {
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(make_psk(7), PROLOGUE, sizeof(PROLOGUE)), 0);
Initiator initiator(make_psk(200), PROLOGUE, sizeof(PROLOGUE));
uint8_t msg[MAX_HANDSHAKE_SIZE];
size_t msg_len = initiator.write_message(msg, sizeof(msg));
ASSERT_GT(msg_len, 0u);
int err = responder.read_message(msg, msg_len);
EXPECT_EQ(err, NOISE_ERROR_MAC_FAILURE);
EXPECT_EQ(responder.action(), Action::ACTION_FAILED);
}
TEST(NoiseResponderHandshakeTest, MismatchedPrologueFailsWithMacFailure) {
// The prologue binds the plaintext preamble for downgrade resistance; a
// tampered preamble must fail even with the right key.
const psk_t psk = make_psk(7);
NoiseResponderHandshake responder;
ASSERT_EQ(responder.init(psk, PROLOGUE, sizeof(PROLOGUE)), 0);
static const uint8_t TAMPERED[] = {'x'};
Initiator initiator(psk, TAMPERED, sizeof(TAMPERED));
uint8_t msg[MAX_HANDSHAKE_SIZE];
size_t msg_len = initiator.write_message(msg, sizeof(msg));
ASSERT_GT(msg_len, 0u);
EXPECT_EQ(responder.read_message(msg, msg_len), NOISE_ERROR_MAC_FAILURE);
}
} // namespace esphome::noise::testing
@@ -0,0 +1,74 @@
#include <gtest/gtest.h>
#include <cstring>
#include <noise/protocol.h>
#include "esphome/components/noise/noise.h"
namespace esphome::noise::testing {
TEST(NoiseContextTest, AllZerosPskIsReserved) {
psk_t zeros{};
EXPECT_TRUE(NoiseContext::is_all_zeros(zeros));
psk_t psk{};
psk[31] = 1;
EXPECT_FALSE(NoiseContext::is_all_zeros(psk));
NoiseContext ctx;
EXPECT_FALSE(ctx.has_psk());
ctx.set_psk(zeros);
EXPECT_FALSE(ctx.has_psk());
ctx.set_psk(psk);
EXPECT_TRUE(ctx.has_psk());
EXPECT_EQ(ctx.get_psk(), psk);
}
TEST(WireFormatTest, FrameHeaderIsIndicatorPlusBigEndianLength) {
uint8_t header[FRAME_HEADER_SIZE];
write_frame_header(header, 0x1234);
EXPECT_EQ(header[0], FRAME_INDICATOR);
EXPECT_EQ(header[1], 0x12);
EXPECT_EQ(header[2], 0x34);
}
TEST(WireFormatTest, RejectPayloadCarriesStatusByteAndMacFailureContract) {
// The MAC failure string is a wire contract: clients match it to report a
// wrong key. Format the payload exactly the way the handshake read path does.
uint8_t buf[64];
size_t len = format_reject_payload(buf, sizeof(buf), reject_reason_for(NOISE_ERROR_MAC_FAILURE));
static constexpr char EXPECTED[] = "Handshake MAC failure";
ASSERT_EQ(len, 1 + strlen(EXPECTED));
EXPECT_EQ(buf[0], HANDSHAKE_STATUS_REJECT);
EXPECT_EQ(memcmp(buf + 1, EXPECTED, strlen(EXPECTED)), 0);
// The exported floor covers the full MAC failure payload exactly
EXPECT_EQ(MAC_FAILURE_PAYLOAD_SIZE, 1 + strlen(EXPECTED));
// Any other error maps to the generic reason
len = format_reject_payload(buf, sizeof(buf), reject_reason_for(NOISE_ERROR_INVALID_STATE));
static constexpr char GENERIC[] = "Handshake error";
ASSERT_EQ(len, 1 + strlen(GENERIC));
EXPECT_EQ(memcmp(buf + 1, GENERIC, strlen(GENERIC)), 0);
}
TEST(WireFormatTest, RejectPayloadTruncatesToCapacity) {
uint8_t buf[8];
size_t len = format_reject_payload(buf, sizeof(buf), reject_reason_for(NOISE_ERROR_MAC_FAILURE));
ASSERT_EQ(len, sizeof(buf));
EXPECT_EQ(buf[0], HANDSHAKE_STATUS_REJECT);
EXPECT_EQ(memcmp(buf + 1, "Handsha", 7), 0);
// A one-byte buffer still carries the status byte
uint8_t tiny[1];
len = format_reject_payload(tiny, sizeof(tiny), reject_reason_for(NOISE_ERROR_MAC_FAILURE));
ASSERT_EQ(len, 1u);
EXPECT_EQ(tiny[0], HANDSHAKE_STATUS_REJECT);
// A zero-capacity buffer yields no payload and stays untouched
uint8_t none[1] = {0xAA};
EXPECT_EQ(format_reject_payload(none, 0, reject_reason_for(NOISE_ERROR_MAC_FAILURE)), 0u);
EXPECT_EQ(none[0], 0xAA);
}
} // namespace esphome::noise::testing
+11
View File
@@ -57,6 +57,17 @@ image:
url: http://www.faqs.org/images/library.jpg
format: JPG
type: RGB565
- platform: online_image
id: online_auto_image
url: http://www.faqs.org/images/library.jpg
format: AUTO
type: RGB565
- 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:
+9
View File
@@ -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
+41
View File
@@ -0,0 +1,41 @@
// Pins the lazy erase-ahead arithmetic used by the ESP-IDF OTA backend: the
// erased watermark must always cover the write end, stay 64 KiB block-aligned
// until the clamp, and never exceed the partition.
#include <gtest/gtest.h>
#include "esphome/components/ota/ota_backend.h"
namespace esphome::ota::testing {
static constexpr size_t BLOCK = 64 * 1024;
static constexpr size_t PART = 1835008; // 0x1C0000, a real app slot size
TEST(NextEraseEnd, FirstWriteRoundsUpToOneBlock) { EXPECT_EQ(next_erase_end(1024, PART), BLOCK); }
TEST(NextEraseEnd, ExactBlockBoundaryDoesNotOverErase) { EXPECT_EQ(next_erase_end(BLOCK, PART), BLOCK); }
TEST(NextEraseEnd, StraddlingWriteCoversNextBlock) { EXPECT_EQ(next_erase_end(BLOCK + 1, PART), 2 * BLOCK); }
TEST(NextEraseEnd, ClampsToPartitionEnd) {
// Partition sizes are sector multiples but not always block multiples
constexpr size_t part = 27 * BLOCK + 4096;
EXPECT_EQ(next_erase_end(27 * BLOCK + 1, part), part);
EXPECT_EQ(next_erase_end(part, part), part);
}
// Bootloader staging seeds erased_end_ mid-block (e.g. 0x8000); the target for
// a write past that seed must still cover the write end.
TEST(NextEraseEnd, MidBlockSeedStillCovered) { EXPECT_EQ(next_erase_end(0x8000 + 1024, PART), BLOCK); }
TEST(NextEraseEnd, SweepAlwaysCoversWriteEndWithinPartition) {
for (size_t end = 1; end <= PART; end += 4093) {
const size_t erased = next_erase_end(end, PART);
ASSERT_GE(erased, end);
ASSERT_LE(erased, PART);
// Block-aligned unless clamped at the partition end
ASSERT_TRUE(erased == PART || erased % BLOCK == 0);
}
}
} // namespace esphome::ota::testing
+1 -1
View File
@@ -4,7 +4,7 @@ light:
default_transition_length: 500ms
chipset: ws2812
num_leds: 256
rgb_order: GRB
channel_colors: GRB
pin: ${pin}
- platform: partition
name: Partition Light
+1 -1
View File
@@ -4,7 +4,7 @@ light:
default_transition_length: 500ms
chipset: ws2812
num_leds: 256
rgb_order: GRB
channel_colors: GRB
pin: ${pin}
- platform: partition
name: Partition Light
@@ -1,2 +1,9 @@
preferences:
id: prefs_syncer
flash_write_interval: 20s
esphome:
on_boot:
then:
- component.suspend: prefs_syncer
- component.resume: prefs_syncer

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