mirror of
https://github.com/esphome/esphome.git
synced 2026-09-13 00:07:32 +00:00
Merge branch 'dev' into jesserockz-2026-503
This commit is contained in:
@@ -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
|
||||
@@ -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!
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
sensor:
|
||||
- platform: d01
|
||||
name: D01 PM2.5 Concentration
|
||||
@@ -0,0 +1,7 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO4
|
||||
rx_pin: GPIO5
|
||||
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
|
||||
d01: !include common.yaml
|
||||
@@ -0,0 +1,7 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO0
|
||||
rx_pin: GPIO2
|
||||
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml
|
||||
d01: !include common.yaml
|
||||
@@ -0,0 +1,7 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO4
|
||||
rx_pin: GPIO5
|
||||
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml
|
||||
d01: !include common.yaml
|
||||
@@ -0,0 +1,3 @@
|
||||
sensor:
|
||||
- platform: ds1603l
|
||||
name: ds1603l Distance
|
||||
@@ -0,0 +1,7 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO1
|
||||
rx_pin: GPIO3
|
||||
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
|
||||
ds1603l: !include common.yaml
|
||||
@@ -0,0 +1,7 @@
|
||||
substitutions:
|
||||
tx_pin: GPIO0
|
||||
rx_pin: GPIO2
|
||||
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml
|
||||
ds1603l: !include common.yaml
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -6,7 +6,7 @@ light:
|
||||
pin: 2
|
||||
pio: 0
|
||||
num_leds: 256
|
||||
rgb_order: GRB
|
||||
channel_colors: GRB
|
||||
chipset: WS2812
|
||||
effects:
|
||||
- e131:
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
@@ -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,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 ®isters) {
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+99
@@ -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
|
||||
|
||||
+15
-18
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
noise:
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
noise: !include common.yaml
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
noise: !include common.yaml
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user