Merge branch 'neutral-ble-client' into radon-eye-single-node

This commit is contained in:
J. Nick Koston
2026-10-06 21:35:46 -10:00
535 changed files with 16156 additions and 2566 deletions
+32
View File
@@ -149,6 +149,38 @@ api:
args:
- error.c_str()
- name.c_str()
# Constant and lambda fields mixed in one action, with lambdas of several
# return types reading the trigger args, and a templated action name
- action: test_homeassistant_fields
variables:
room: string
level: int
then:
- homeassistant.action:
action: !lambda 'return std::string("light.") + (level > 0 ? "turn_on" : "turn_off");'
data:
entity_id: light.living_room
brightness: !lambda 'return level;'
data_template:
message: "{{ room }} is at {{ level }}"
variables:
room: !lambda 'return room.c_str();'
level: !lambda 'static char buf[12]; snprintf(buf, sizeof(buf), "%d", level); return buf;'
- homeassistant.action:
action: notify.html5
data:
message: Button was pressed
# Same constant fields as above, so codegen can share them
- homeassistant.action:
action: notify.html5
data:
message: Button was pressed
- homeassistant.event:
event: esphome.room_level
data:
room: !lambda 'return room;'
ratio: !lambda 'return level / 100.0f;'
- homeassistant.tag_scanned: !lambda 'return room;'
# Test ContinuationAction (IfAction with then/else branches)
- action: test_if_action
variables:
@@ -0,0 +1,11 @@
packages:
common: !include common.yaml
wifi:
ssid: MySSID
password: password1
# Outgoing connection on the lwip_sockets implementation used by LibreTiny
api:
outgoing_connection:
host: 192.168.1.2
@@ -0,0 +1,14 @@
packages:
common: !include common.yaml
wifi:
ssid: MySSID
password: password1
# Outgoing connection: the device dials out when no dial-back client is
# connected. Requires encryption so the peer is verified by key.
api:
outgoing_connection:
host: 192.168.1.2
port: 6054
delay: 60s
@@ -0,0 +1,11 @@
packages:
common: !include common.yaml
wifi:
ssid: MySSID
password: password1
# Outgoing connection on the raw lwip_tcp implementation used by ESP8266 and RP2040
api:
outgoing_connection:
host: 192.168.1.2
@@ -0,0 +1,9 @@
packages:
common: !include common.yaml
network:
# No host set: the device dials the last remembered Home Assistant address
api:
outgoing_connection:
delay: 30s
@@ -0,0 +1,11 @@
packages:
common: !include common.yaml
wifi:
ssid: MySSID
password: password1
# Outgoing connection on the raw lwip_tcp implementation used by ESP8266 and RP2040
api:
outgoing_connection:
host: 192.168.1.2
+6
View File
@@ -0,0 +1,6 @@
packages:
api: !include common.yaml
wifi:
ssid: MySSID
password: password1
@@ -0,0 +1,84 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <random>
#include <vector>
#include "esphome/components/api/proto.h"
namespace esphome::api::testing {
// The original byte at a time implementation.
static uint16_t reference_count(const uint8_t *data, size_t len) {
uint16_t count = 0;
while (len > 0) {
while (len > 0 && (*data & 0x80)) {
data++;
len--;
}
if (len > 0) {
data++;
len--;
count++;
}
}
return count;
}
TEST(CountPackedVarints, EmptyBuffer) {
const uint8_t data[1] = {0x00};
EXPECT_EQ(count_packed_varints(data, 0), 0);
}
TEST(CountPackedVarints, SingleByteVarints) {
const uint8_t data[] = {0x00, 0x01, 0x7F};
EXPECT_EQ(count_packed_varints(data, sizeof(data)), 3);
}
TEST(CountPackedVarints, MultiByteVarints) {
// 3 varints: 2 bytes, 3 bytes, 1 byte
const uint8_t data[] = {0x80, 0x01, 0x80, 0x80, 0x01, 0x05};
EXPECT_EQ(count_packed_varints(data, sizeof(data)), 3);
}
TEST(CountPackedVarints, TruncatedTrailingVarintIsNotCounted) {
const uint8_t data[] = {0x05, 0x80, 0x80, 0x80};
EXPECT_EQ(count_packed_varints(data, sizeof(data)), 1);
}
TEST(CountPackedVarints, AllContinuationBytes) {
std::vector<uint8_t> data(5000, 0x80);
EXPECT_EQ(count_packed_varints(data.data(), data.size()), 0);
}
TEST(CountPackedVarints, EveryStartOffsetAndLength) {
// Cover every alignment and length around word boundaries.
std::mt19937 rng(42); // NOLINT(cert-msc32-c,cert-msc51-cpp,bugprone-random-generator-seed) reproducible
std::vector<uint8_t> buf(300);
for (auto &byte : buf)
byte = static_cast<uint8_t>(rng() & 0xFF);
for (size_t offset = 0; offset < 16; offset++) {
for (size_t len = 0; len + offset <= buf.size(); len++) {
const uint16_t expected = reference_count(buf.data() + offset, len);
EXPECT_EQ(count_packed_varints<uint32_t>(buf.data() + offset, len), expected)
<< "offset=" << offset << " len=" << len;
EXPECT_EQ(count_packed_varints<uint64_t>(buf.data() + offset, len), expected)
<< "offset=" << offset << " len=" << len;
}
}
}
TEST(CountPackedVarints, LongBuffer) {
std::mt19937 rng(7); // NOLINT(cert-msc32-c,cert-msc51-cpp,bugprone-random-generator-seed) reproducible
std::vector<uint8_t> buf(5000);
for (auto &byte : buf)
byte = static_cast<uint8_t>((rng() % 4 == 0) ? (0x80 | (rng() & 0x7F)) : (rng() & 0x7F));
for (size_t offset = 0; offset < 8; offset++) {
const size_t len = buf.size() - offset;
const uint16_t expected = reference_count(buf.data() + offset, len);
EXPECT_EQ(count_packed_varints<uint32_t>(buf.data() + offset, len), expected) << "offset=" << offset;
EXPECT_EQ(count_packed_varints<uint64_t>(buf.data() + offset, len), expected) << "offset=" << offset;
}
}
} // namespace esphome::api::testing
@@ -0,0 +1 @@
<<: !include common.yaml
@@ -0,0 +1,96 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <type_traits>
#include "esphome/core/helpers.h"
namespace esphome::testing {
static constexpr const char *const TABLE[] = {"a", "b", "c"};
// Exposes the owned flag, which is protected.
class ProbeVector : public ConstVector<const char *, true> {
public:
using ConstVector::ConstVector;
bool owned() const { return (this->size_ & OWNED_BIT) != 0; }
};
TEST(ConstVector, StaticTableIsViewedNotOwned) {
ProbeVector list;
EXPECT_TRUE(list.empty());
list.assign_static(TABLE, 3);
EXPECT_EQ(list.data(), TABLE);
EXPECT_EQ(list.size(), 3U);
EXPECT_FALSE(list.owned());
EXPECT_STREQ(list[1], "b");
EXPECT_STREQ(list.at(2), "c");
}
TEST(ConstVector, CopyIsOwnedAndSizeMasksTheFlag) {
ProbeVector list;
list.assign_copy(TABLE, 3);
EXPECT_NE(list.data(), TABLE);
EXPECT_TRUE(list.owned());
EXPECT_EQ(list.size(), 3U);
EXPECT_STREQ(list[0], "a");
const char *const next[] = {"x", "y"};
list.assign_copy(next, 2); // frees the previous copy
EXPECT_TRUE(list.owned());
EXPECT_EQ(list.size(), 2U);
EXPECT_STREQ(list[1], "y");
}
TEST(ConstVector, StaticThenRuntimeCopiesNeverFreeTheTable) {
ProbeVector list;
list.assign_static(TABLE, 3);
EXPECT_FALSE(list.owned());
list.assign_copy(TABLE, 2); // the static table is not owned, so nothing is freed
EXPECT_TRUE(list.owned());
EXPECT_NE(list.data(), TABLE);
const char *const next[] = {"x"};
list.assign_copy(next, 1); // frees the previous copy
EXPECT_EQ(list.size(), 1U);
EXPECT_STREQ(list[0], "x");
EXPECT_STREQ(TABLE[0], "a");
}
TEST(ConstVector, EmptyCopyIsEmptyAndFreedOnNextSet) {
ProbeVector list;
list.assign_copy(TABLE, 3);
list.assign_copy(TABLE, 0);
EXPECT_TRUE(list.empty());
list.assign_copy(TABLE, 2); // frees the empty copy
EXPECT_EQ(list.size(), 2U);
}
TEST(ConstVector, OwningVariantIsNotCopyable) {
static_assert(!std::is_copy_constructible_v<ConstVector<const char *, true>>);
static_assert(!std::is_copy_assignable_v<ConstVector<const char *, true>>);
}
TEST(ConstVector, CopyFromItsOwnStorage) {
ProbeVector list;
list.assign_copy(TABLE, 3);
list.assign_copy(list.data(), list.size());
EXPECT_EQ(list.size(), 3U);
EXPECT_STREQ(list[2], "c");
}
TEST(ConstVector, PlainViewHasNoOwnershipCost) {
static_assert(std::is_trivially_copyable_v<ConstVector<const char *>>);
static_assert(std::is_trivially_destructible_v<ConstVector<const char *>>);
ConstVector<const char *> list(TABLE, 3);
EXPECT_EQ(list.size(), 3U);
EXPECT_STREQ(list[2], "c");
}
TEST(ConstVector, IteratorsAreRawPointers) {
ConstVector<const char *, true> list(TABLE, 3);
static_assert(std::is_same_v<decltype(list.begin()), const char *const *>);
const auto *it = std::find(list.begin(), list.end(), TABLE[1]);
EXPECT_EQ(it - list.begin(), 1);
}
} // namespace esphome::testing
+21
View File
@@ -281,6 +281,27 @@ TEST(Base64, Rfc4648Vectors) {
}
}
TEST(Base64, DecodeTruncatesToBuffer) {
uint8_t buf[4];
size_t len = base64_decode(std::string("Zm9vYmFy"), buf, sizeof(buf));
EXPECT_EQ(len, 4u);
EXPECT_EQ(memcmp(buf, "foob", 4), 0);
}
TEST(Base64, DecodeStopsAtNonAlphabetChar) {
uint8_t buf[8];
EXPECT_EQ(base64_decode(std::string("Zm9v!Zm9v"), buf, sizeof(buf)), 3u);
EXPECT_EQ(memcmp(buf, "foo", 3), 0);
EXPECT_EQ(base64_decode(std::string("Zm9v Zm9v"), buf, sizeof(buf)), 3u);
EXPECT_EQ(base64_decode(std::string("Zm9v\xC3Zm9v"), buf, sizeof(buf)), 3u);
}
TEST(Base64, DecodeDropsPartialGroup) {
uint8_t buf[8];
EXPECT_EQ(base64_decode(std::string("Z"), buf, sizeof(buf)), 0u);
EXPECT_EQ(base64_decode(std::string("Zm9vY"), buf, sizeof(buf)), 3u);
}
// --- step_to_accuracy_decimals() ---
TEST(StepToAccuracyDecimals, TypicalSteps) {
@@ -8,7 +8,7 @@ packages:
# exercised, not the user-override path.
sensor:
# Energy sensor (E prefix): expects state_class=total_increasing, unit=Wh,
# Energy sensor (E prefix): expects state_class=total, unit=Wh,
# device_class=energy, accuracy_decimals=0
- platform: emontx
tag_name: E1
@@ -190,6 +190,24 @@ display:
allow_other_uses: true
number: GPIO4
# Waveshare 1.54" V2 mono e-paper (200x200, SSD1681)
- platform: epaper_spi
spi_id: spi_bus
model: waveshare-1.54in-v2
full_update_every: 30
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
# Waveshare 2.13" V4 B series 3-color e-paper (122x250, BWR, SSD1680)
- platform: epaper_spi
spi_id: spi_bus
+24
View File
@@ -0,0 +1,24 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.core import CORE
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
# The real schema needs an ESP32 target and Wi-Fi; the host test build has neither.
manifest.dependencies = ["binary_sensor", "sensor", "button", "select"]
manifest.config_schema = cv.Schema({})
manifest.final_validate_schema = None
async def to_code_testing(config: ConfigType) -> None:
# Resolve <espectre_sdk.h> to the test double copied next to the tests.
cg.add_build_flag(f"-I{CORE.relative_src_path('espectre')}")
cg.add_define("USE_ESPECTRE")
# No entity platforms are configured, so count them to emit USE_BINARY_SENSOR,
# USE_SENSOR and USE_SELECT for the entity code under test.
CORE.platform_counts["binary_sensor"] += 2
CORE.platform_counts["sensor"] += 1
CORE.platform_counts["select"] += 1
manifest.to_code = to_code_testing
+47
View File
@@ -0,0 +1,47 @@
wifi:
ssid: MySSID
password: password1
espectre:
id: espectre_hub
binary_sensor:
- platform: espectre
espectre_id: espectre_hub
motion:
name: Motion
calibrating:
name: Calibrating
sensor:
- platform: espectre
espectre_id: espectre_hub
movement:
name: Movement score
diagnostics:
id: espectre_diagnostics
update_interval: 10s
generator_rate:
name: Generator Rate
traffic_tx_rate:
name: Traffic TX Rate
traffic_rx_rate:
name: Traffic RX Rate
csi_accepted_rate:
name: CSI Accepted Rate
csi_occupancy:
name: CSI Temporal Occupancy
select:
- platform: espectre
espectre_id: espectre_hub
name: CSI Traffic Source
button:
- platform: espectre
espectre_id: espectre_hub
name: Recalibrate
- platform: template
name: Refresh Diagnostics
on_press:
- component.update: espectre_diagnostics
+105
View File
@@ -0,0 +1,105 @@
#pragma once
// Lets the tests skip themselves when clang-tidy builds them against the real SDK.
#define ESPECTRE_SDK_TEST_DOUBLE
// SDK test double: hardware behavior belongs to the SDK; these tests exercise the ESPHome adapter.
#include <cstdarg>
#include <cstdint>
#include <functional>
#include <string>
namespace espectre {
enum class DetectionAlgorithm { LIGHTWEIGHT, HIGH_ACCURACY };
enum class CsiCapturePolicy { AUTO, LLTF, HT_VHT };
enum class TrafficGeneratorMode { PING, DNS, DNS_TCP, WIFI_RAW, EXTERNAL_HOST };
enum class WifiBandPolicy { BAND_2G, BAND_5G, AUTO };
enum class MotionState { IDLE, MOTION };
enum class LogLevel { ERROR, WARNING, INFO, DEBUG, VERBOSE };
constexpr float runtime_default_threshold(DetectionAlgorithm algorithm) {
return algorithm == DetectionAlgorithm::HIGH_ACCURACY ? 0.5f : 0.66218545f;
}
struct RuntimeConfig {
DetectionAlgorithm detection_algorithm{DetectionAlgorithm::LIGHTWEIGHT};
float threshold{runtime_default_threshold(DetectionAlgorithm::LIGHTWEIGHT)};
CsiCapturePolicy csi_capture_policy{CsiCapturePolicy::AUTO};
TrafficGeneratorMode traffic_generator_mode{TrafficGeneratorMode::PING};
std::string traffic_generator_target_ip;
std::string csi_traffic_multicast_group{"239.255.0.1"};
uint8_t motion_on_hits{4};
uint8_t motion_off_hits{3};
WifiBandPolicy wifi_band_policy{WifiBandPolicy::AUTO};
bool wifi_scan_results_managed_externally{false};
bool persist_runtime_overrides{false};
};
struct RuntimeDiagnosticsSample {
float generator_pps{0.0f};
float traffic_tx_pps{0.0f};
float traffic_rx_pps{0.0f};
float csi_accepted_pps{0.0f};
float csi_occupancy_ratio{0.0f};
};
struct RuntimeSnapshot {
bool ready_to_publish{false};
bool calibrating{false};
MotionState motion_state{MotionState::IDLE};
float movement_metric{0.0f};
};
class IRuntimeListener {
public:
virtual ~IRuntimeListener() = default;
virtual void on_live_telemetry(const RuntimeSnapshot &snapshot) {}
virtual void on_calibration_finished(const RuntimeSnapshot &snapshot, bool success) {}
virtual void on_runtime_fault(const char *message) {}
};
struct LogSink {
void *context;
bool (*enabled)(void *, LogLevel, const char *);
void (*write)(void *, LogLevel, const char *, int, const char *, va_list);
};
inline bool set_log_sink(const LogSink &sink) { return true; }
inline void clear_log_sink() {}
class RuntimeFrontendController {
public:
RuntimeFrontendController() { instance = this; }
RuntimeConfig &config() { return config_; }
const RuntimeSnapshot &snapshot() const { return snapshot_; }
const RuntimeDiagnosticsSample *diagnostics_sample() const { return &diagnostics_sample_; }
bool setup(IRuntimeListener *listener) {
setup_calls++;
this->listener = listener;
this->config_at_setup_ = this->config_;
return setup_result;
}
void loop() {
if (loop_hook)
loop_hook();
}
bool trigger_recalibration() {
recalibration_calls++;
return true;
}
void shutdown() { shutdown_called = true; }
bool set_traffic_generator_mode(TrafficGeneratorMode mode) {
config_.traffic_generator_mode = mode;
return true;
}
inline static RuntimeFrontendController *instance{nullptr};
RuntimeConfig config_;
RuntimeConfig config_at_setup_;
RuntimeSnapshot snapshot_;
RuntimeDiagnosticsSample diagnostics_sample_;
IRuntimeListener *listener{nullptr};
std::function<void()> loop_hook;
bool setup_result{true};
bool shutdown_called{false};
unsigned recalibration_calls{0};
unsigned setup_calls{0};
};
} // namespace espectre
@@ -0,0 +1,28 @@
#include "gtest/gtest.h"
#include "esphome/components/espectre/sensor/espectre_diagnostics.h"
#ifdef ESPECTRE_SDK_TEST_DOUBLE
namespace esphome::espectre::testing {
TEST(ESPectreDiagnosticsTest, PublishOnUpdate) {
ESPectreComponent component;
auto *sdk = ::espectre::RuntimeFrontendController::instance;
sensor::Sensor accepted;
sensor::Sensor occupancy;
DiagnosticsUpdater updater(&component);
updater.set_csi_accepted_rate_sensor(&accepted);
updater.set_csi_occupancy_sensor(&occupancy);
component.setup();
sdk->diagnostics_sample_.csi_accepted_pps = 98.5f;
sdk->diagnostics_sample_.csi_occupancy_ratio = 0.9f;
component.loop();
EXPECT_FALSE(accepted.has_state());
updater.update();
EXPECT_FLOAT_EQ(accepted.state, 98.5f);
EXPECT_FLOAT_EQ(occupancy.state, 90.0f);
}
} // namespace esphome::espectre::testing
#endif // ESPECTRE_SDK_TEST_DOUBLE
@@ -0,0 +1,2 @@
packages:
espectre: !include common.yaml
@@ -0,0 +1,8 @@
packages:
espectre: !include common.yaml
espectre:
detection_algorithm: high_accuracy
csi_capture_profile: lltf
traffic_generator_mode: dns
traffic_generator_target_ip: 192.168.1.1
@@ -0,0 +1,5 @@
packages:
espectre: !include common.yaml
espectre:
csi_capture_profile: lltf
@@ -0,0 +1,8 @@
packages:
espectre: !include common.yaml
espectre:
traffic_generator_mode: external
csi_traffic_multicast_group: 239.255.0.1
motion_on_hits: 2
motion_off_hits: 5
@@ -0,0 +1,5 @@
wifi:
ssid: MySSID
password: password1
espectre:
@@ -0,0 +1,6 @@
packages:
espectre: !include common.yaml
espectre:
csi_capture_profile: lltf
traffic_generator_mode: wifi_raw
@@ -0,0 +1,5 @@
packages:
espectre: !include common.yaml
espectre:
traffic_generator_mode: external
@@ -0,0 +1,7 @@
packages:
espectre: !include common.yaml
espectre:
detection_algorithm: high_accuracy
csi_capture_profile: ht_vht
traffic_generator_mode: dns_tcp
+164
View File
@@ -0,0 +1,164 @@
#include <cmath>
#include <vector>
#include "gtest/gtest.h"
#include "esphome/components/espectre/espectre.h"
#ifdef ESPECTRE_SDK_TEST_DOUBLE
namespace esphome::espectre::testing {
class ESPectreTest : public ::testing::Test {
protected:
void SetUp() override {
component_.set_motion_binary_sensor(&motion_);
component_.set_calibrating_binary_sensor(&calibrating_);
component_.set_movement_sensor(&movement_);
}
void make_ready_(bool motion = true) {
sdk_->snapshot_.ready_to_publish = true;
sdk_->snapshot_.motion_state = motion ? ::espectre::MotionState::MOTION : ::espectre::MotionState::IDLE;
sdk_->snapshot_.movement_metric = 0.8f;
component_.loop();
}
ESPectreComponent component_;
::espectre::RuntimeFrontendController *sdk_{::espectre::RuntimeFrontendController::instance};
binary_sensor::BinarySensor motion_;
binary_sensor::BinarySensor calibrating_;
sensor::Sensor movement_;
};
TEST_F(ESPectreTest, StartupDoesNotPublishFalseMotion) {
component_.setup();
component_.loop();
EXPECT_FALSE(motion_.has_state());
EXPECT_FALSE(movement_.has_state());
EXPECT_TRUE(calibrating_.has_state());
}
TEST_F(ESPectreTest, SetupHandsScanResultsAndPersistenceToEspHome) {
component_.setup();
EXPECT_TRUE(sdk_->config_at_setup_.wifi_scan_results_managed_externally);
EXPECT_FALSE(sdk_->config_at_setup_.persist_runtime_overrides);
}
TEST_F(ESPectreTest, ReadinessLossAndRecovery) {
component_.setup();
std::vector<optional<bool>> published_states;
motion_.add_full_state_callback(
[&published_states](optional<bool>, optional<bool> current) { published_states.push_back(current); });
for (bool motion : {false, true}) {
published_states.clear();
make_ready_(motion);
ASSERT_TRUE(motion_.has_state());
EXPECT_EQ(motion_.state, motion);
EXPECT_FLOAT_EQ(movement_.state, 0.8f);
sdk_->snapshot_.ready_to_publish = false;
sdk_->snapshot_.calibrating = true;
component_.loop();
EXPECT_FALSE(motion_.has_state());
EXPECT_TRUE(std::isnan(movement_.state));
EXPECT_TRUE(calibrating_.state);
sdk_->snapshot_.calibrating = false;
make_ready_(motion);
EXPECT_TRUE(motion_.has_state());
EXPECT_EQ(motion_.state, motion);
EXPECT_FLOAT_EQ(movement_.state, 0.8f);
EXPECT_EQ(published_states, (std::vector<optional<bool>>{motion, nullopt, motion}));
}
}
TEST_F(ESPectreTest, TelemetryUsesFinalReadiness) {
component_.setup();
make_ready_();
std::vector<float> published_states;
movement_.add_on_state_callback([&published_states](float state) { published_states.push_back(state); });
sdk_->loop_hook = [this]() {
sdk_->listener->on_live_telemetry(sdk_->snapshot_);
sdk_->snapshot_.movement_metric = 0.9f;
sdk_->snapshot_.ready_to_publish = false;
};
component_.loop();
EXPECT_FALSE(motion_.has_state());
EXPECT_TRUE(std::isnan(movement_.state));
ASSERT_EQ(published_states.size(), 1u);
EXPECT_TRUE(std::isnan(published_states.front()));
}
TEST_F(ESPectreTest, EntityCallbackQueuesRecalibration) {
component_.setup();
movement_.add_on_state_callback([this](float) { component_.recalibrate(); });
make_ready_();
EXPECT_EQ(sdk_->recalibration_calls, 0u);
component_.loop();
EXPECT_EQ(sdk_->recalibration_calls, 1u);
}
TEST_F(ESPectreTest, CalibrationFailureWarnsOnlyBeforeFirstSuccess) {
component_.setup();
sdk_->listener->on_calibration_finished(sdk_->snapshot_, false);
EXPECT_TRUE(component_.status_has_warning());
EXPECT_FALSE(component_.is_failed());
sdk_->listener->on_calibration_finished(sdk_->snapshot_, true);
EXPECT_FALSE(component_.status_has_warning());
sdk_->listener->on_calibration_finished(sdk_->snapshot_, false);
EXPECT_FALSE(component_.status_has_warning());
}
TEST_F(ESPectreTest, RuntimeFaultStopsAndWaitsToRestart) {
component_.setup();
make_ready_();
sdk_->listener->on_runtime_fault("test fault");
component_.loop();
EXPECT_FALSE(component_.is_failed());
EXPECT_TRUE(component_.status_has_error());
EXPECT_TRUE(sdk_->shutdown_called);
EXPECT_FALSE(motion_.has_state());
EXPECT_TRUE(std::isnan(movement_.state));
component_.loop();
EXPECT_EQ(sdk_->setup_calls, 1u);
}
TEST_F(ESPectreTest, FailedSetupStopsAndWaitsToRestart) {
sdk_->setup_result = false;
component_.setup();
EXPECT_FALSE(component_.is_failed());
EXPECT_TRUE(component_.status_has_error());
EXPECT_TRUE(sdk_->shutdown_called);
}
TEST_F(ESPectreTest, TrafficModeRequestAppliesInLoop) {
component_.setup();
component_.request_traffic_generator_mode(::espectre::TrafficGeneratorMode::EXTERNAL_HOST);
EXPECT_EQ(sdk_->config_.traffic_generator_mode, ::espectre::TrafficGeneratorMode::PING);
component_.loop();
EXPECT_EQ(sdk_->config_.traffic_generator_mode, ::espectre::TrafficGeneratorMode::EXTERNAL_HOST);
}
TEST_F(ESPectreTest, TrafficModeRequestByName) {
component_.setup();
component_.request_traffic_generator_mode("unknown");
component_.loop();
EXPECT_EQ(sdk_->config_.traffic_generator_mode, ::espectre::TrafficGeneratorMode::PING);
component_.request_traffic_generator_mode("dns_tcp");
component_.loop();
EXPECT_EQ(sdk_->config_.traffic_generator_mode, ::espectre::TrafficGeneratorMode::DNS_TCP);
}
TEST_F(ESPectreTest, ShutdownInvalidatesEntities) {
component_.setup();
make_ready_();
component_.on_shutdown();
EXPECT_TRUE(sdk_->shutdown_called);
EXPECT_FALSE(motion_.has_state());
EXPECT_FALSE(calibrating_.has_state());
EXPECT_TRUE(std::isnan(movement_.state));
}
} // namespace esphome::espectre::testing
#endif // ESPECTRE_SDK_TEST_DOUBLE
+2
View File
@@ -1,5 +1,7 @@
esphome:
debug_scheduler: true
loop_interval: 50ms
suspend_loop: false
platformio_options:
board_build.flash_mode: dio
build_flags:
+5 -1
View File
@@ -1 +1,5 @@
<<: !include common.yaml
packages:
base: !include common.yaml
esphome:
suspend_loop: true
@@ -1 +1,5 @@
<<: !include common.yaml
packages:
base: !include common.yaml
esphome:
suspend_loop: true
@@ -1 +1,5 @@
<<: !include common.yaml
packages:
base: !include common.yaml
esphome:
suspend_loop: true
@@ -7,6 +7,12 @@ ethernet:
mode: CLK_EXT_IN
phy_addr: 0
power_pin: 33
phy_registers:
- address: 0x10
value: 0x1FFA
page_id: 0x07
- address: 0x19
value: 0x0006
manual_ip:
static_ip: 192.168.178.56
gateway: 192.168.178.1
@@ -0,0 +1,12 @@
network:
enable_ipv6: true
ethernet:
type: IP101
mdc_pin: 23
mdio_pin: 18
clk:
pin: 0
mode: CLK_EXT_IN
phy_addr: 1
power_pin: 5
+24
View File
@@ -19,6 +19,12 @@ event:
ESP_LOGD("test", "Event type: %.*s", (int) event_type.size(), event_type.c_str());
}
- platform: template
name: Other Event
id: other_event
event_types:
- template_event_type1
button:
- platform: template
name: Trigger Event
@@ -30,3 +36,21 @@ button:
id: some_event
event_type: !lambda |-
return id(some_event).has_event() ? "template_event_type2" : "template_event_type1";
- platform: template
name: Event Types Lambdas
on_press:
- lambda: |-
// How external components and configs read and set event types
const auto &types = id(some_event).get_event_types();
for (const char *type : types)
ESP_LOGD("test", "%s", type);
if (types.empty()) {
ESP_LOGD("test", "no types");
} else if (types.size() > 1) {
ESP_LOGD("test", "%s %s", types[0], types.at(1));
const auto *it = std::find(types.begin(), types.end(), types[1]);
ESP_LOGD("test", "Index %d", (int) (it - types.begin()));
}
id(other_event).set_event_types(id(some_event).get_event_types());
id(other_event).set_event_types({"other_a", "other_b"});
@@ -0,0 +1,52 @@
#pragma once
#include <gtest/gtest.h>
#include <cstdlib>
#include <filesystem>
#include <optional>
#include <string>
#include "esphome/components/exponential_moving_average/exponential_moving_average_sensor.h"
#include "esphome/core/preferences.h"
#ifdef USE_HOST
#include "esphome/components/host/preferences.h"
#endif
namespace esphome::exponential_moving_average::testing {
class TestableExponentialMovingAverageSensor : public ExponentialMovingAverageSensor {
public:
using ExponentialMovingAverageSensor::ExponentialMovingAverageSensor;
using ExponentialMovingAverageSensor::process_;
};
// Unnamed sensors share one preference key, so a second instance created after
// the first one behaves like the same sensor after a reboot.
class ExponentialMovingAverageTest : public ::testing::Test {
protected:
void SetUp() override {
if (const char *prefdir = getenv("ESPHOME_PREFDIR"); prefdir != nullptr)
this->saved_prefdir_ = prefdir;
// Keep preferences away from the user's home directory.
setenv("ESPHOME_PREFDIR", std::filesystem::temp_directory_path().c_str(), 1);
#ifdef USE_HOST
host::setup_preferences();
#endif
global_preferences->reset();
}
void TearDown() override {
global_preferences->reset();
if (this->saved_prefdir_.has_value()) {
setenv("ESPHOME_PREFDIR", this->saved_prefdir_->c_str(), 1);
} else {
unsetenv("ESPHOME_PREFDIR");
}
}
std::optional<std::string> saved_prefdir_;
sensor::Sensor source_;
};
} // namespace esphome::exponential_moving_average::testing
@@ -0,0 +1,25 @@
sensor:
- platform: template
id: ema_source
name: EMA Source
unit_of_measurement: "°C"
accuracy_decimals: 1
lambda: return 21.5;
update_interval: 10s
- platform: exponential_moving_average
id: ema_alpha
name: EMA Alpha
sensor: ema_source
alpha: 0.2
- platform: exponential_moving_average
name: EMA Time Constant
sensor: ema_source
time_constant: 5min
time_weighting: previous
restore: false
button:
- platform: template
name: EMA Reset
on_press:
- sensor.exponential_moving_average.reset: ema_alpha
@@ -0,0 +1,356 @@
#include <cmath>
#include "../common.h"
namespace esphome::exponential_moving_average::testing {
TEST_F(ExponentialMovingAverageTest, FirstValueStartsTheAverage) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.setup();
EXPECT_FALSE(ema.has_state());
ema.process_(10.0f, 0);
EXPECT_FLOAT_EQ(ema.state, 10.0f);
}
TEST_F(ExponentialMovingAverageTest, AlphaWeightsEachValue) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_alpha(0.5f);
ema.setup();
ema.process_(10.0f, 0);
ema.process_(20.0f, 0);
EXPECT_FLOAT_EQ(ema.state, 15.0f);
ema.process_(20.0f, 0);
EXPECT_FLOAT_EQ(ema.state, 17.5f);
}
TEST_F(ExponentialMovingAverageTest, NanValuesAreIgnored) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_alpha(0.5f);
ema.setup();
ema.process_(10.0f, 0);
ema.process_(NAN, 0);
EXPECT_FLOAT_EQ(ema.state, 10.0f);
ema.process_(20.0f, 0);
EXPECT_FLOAT_EQ(ema.state, 15.0f);
}
TEST_F(ExponentialMovingAverageTest, FollowsSourceSensor) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_alpha(0.25f);
ema.setup();
this->source_.publish_state(8.0f);
this->source_.publish_state(0.0f);
EXPECT_FLOAT_EQ(ema.state, 6.0f);
}
TEST_F(ExponentialMovingAverageTest, TimeConstantWeightsByElapsedTime) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_time_constant(1000);
ema.setup();
ema.process_(0.0f, 0);
ema.process_(1.0f, 1000);
EXPECT_NEAR(ema.state, 1.0f - std::exp(-1.0f), 1e-5f);
}
TEST_F(ExponentialMovingAverageTest, TimeConstantResultDoesNotDependOnSampleRate) {
TestableExponentialMovingAverageSensor fast(&this->source_);
fast.set_time_constant(1000);
fast.set_restore(false);
fast.setup();
fast.process_(0.0f, 0);
for (uint32_t t = 100; t <= 1000; t += 100)
fast.process_(1.0f, t);
TestableExponentialMovingAverageSensor slow(&this->source_);
slow.set_time_constant(1000);
slow.set_restore(false);
slow.setup();
slow.process_(0.0f, 0);
slow.process_(1.0f, 1000);
EXPECT_NEAR(fast.state, slow.state, 1e-5f);
}
TEST_F(ExponentialMovingAverageTest, TimeConstantIgnoresRepeatAtSameTime) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_time_constant(1000);
ema.setup();
ema.process_(5.0f, 0);
ema.process_(100.0f, 0);
EXPECT_FLOAT_EQ(ema.state, 5.0f);
}
TEST_F(ExponentialMovingAverageTest, TimeConstantHandlesTimerWraparound) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_time_constant(1000);
ema.setup();
ema.process_(0.0f, UINT32_MAX - 499);
ema.process_(1.0f, 500);
EXPECT_NEAR(ema.state, 1.0f - std::exp(-1.0f), 1e-5f);
}
TEST_F(ExponentialMovingAverageTest, ResetStartsANewAverage) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_alpha(0.5f);
ema.setup();
ema.process_(10.0f, 0);
ema.reset();
EXPECT_TRUE(std::isnan(ema.state));
ema.process_(40.0f, 0);
EXPECT_FLOAT_EQ(ema.state, 40.0f);
}
TEST_F(ExponentialMovingAverageTest, AverageIsRestoredAfterReboot) {
{
TestableExponentialMovingAverageSensor before(&this->source_);
before.set_alpha(0.5f);
before.setup();
before.process_(10.0f, 0);
before.process_(20.0f, 0);
}
TestableExponentialMovingAverageSensor after(&this->source_);
after.set_alpha(0.5f);
after.setup();
ASSERT_TRUE(after.has_state());
EXPECT_FLOAT_EQ(after.state, 15.0f);
// Continues from the restored value rather than starting again.
after.process_(25.0f, 0);
EXPECT_FLOAT_EQ(after.state, 20.0f);
}
TEST_F(ExponentialMovingAverageTest, NothingRestoredWhenRestoreIsOff) {
{
TestableExponentialMovingAverageSensor before(&this->source_);
before.setup();
before.process_(10.0f, 0);
}
TestableExponentialMovingAverageSensor after(&this->source_);
after.set_restore(false);
after.setup();
EXPECT_FALSE(after.has_state());
after.process_(30.0f, 0);
EXPECT_FLOAT_EQ(after.state, 30.0f);
}
TEST_F(ExponentialMovingAverageTest, ResetClearsTheSavedAverage) {
{
TestableExponentialMovingAverageSensor before(&this->source_);
before.setup();
before.process_(10.0f, 0);
before.reset();
}
TestableExponentialMovingAverageSensor after(&this->source_);
after.setup();
EXPECT_FALSE(after.has_state());
}
TEST_F(ExponentialMovingAverageTest, PreviousWeightingCountsGapAtPreviousValue) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_time_constant(1000);
ema.set_time_weighting(TIME_WEIGHTING_PREVIOUS);
ema.setup();
// The value stayed at 20 for an hour before changing to 25.
ema.process_(20.0f, 0);
ema.process_(25.0f, 3600000);
EXPECT_FLOAT_EQ(ema.state, 20.0f);
// The 25 is counted over the following interval.
ema.process_(25.0f, 3601000);
EXPECT_NEAR(ema.state, 20.0f + 5.0f * (1.0f - std::exp(-1.0f)), 1e-4f);
}
TEST_F(ExponentialMovingAverageTest, NewWeightingCountsGapAtNewValue) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_time_constant(1000);
ema.setup();
ema.process_(20.0f, 0);
ema.process_(25.0f, 3600000);
EXPECT_FLOAT_EQ(ema.state, 25.0f);
}
TEST_F(ExponentialMovingAverageTest, LinearWeightingFollowsStraightLine) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_time_constant(1000);
ema.set_time_weighting(TIME_WEIGHTING_LINEAR);
ema.setup();
// An average of a value rising steadily from 0 to 1 over one time constant ends at exp(-1).
ema.process_(0.0f, 0);
ema.process_(1.0f, 1000);
EXPECT_NEAR(ema.state, std::exp(-1.0f), 1e-5f);
}
TEST_F(ExponentialMovingAverageTest, LinearWeightingMatchesManySmallSteps) {
TestableExponentialMovingAverageSensor coarse(&this->source_);
coarse.set_time_constant(1000);
coarse.set_time_weighting(TIME_WEIGHTING_LINEAR);
coarse.set_restore(false);
coarse.setup();
coarse.process_(0.0f, 0);
coarse.process_(10.0f, 2000);
TestableExponentialMovingAverageSensor fine(&this->source_);
fine.set_time_constant(1000);
fine.set_time_weighting(TIME_WEIGHTING_LINEAR);
fine.set_restore(false);
fine.setup();
fine.process_(0.0f, 0);
for (uint32_t t = 10; t <= 2000; t += 10)
fine.process_(t / 200.0f, t);
EXPECT_NEAR(coarse.state, fine.state, 1e-3f);
}
TEST_F(ExponentialMovingAverageTest, LinearWeightingIgnoresRepeatAtSameTime) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_time_constant(1000);
ema.set_time_weighting(TIME_WEIGHTING_LINEAR);
ema.setup();
ema.process_(5.0f, 0);
ema.process_(100.0f, 0);
EXPECT_FLOAT_EQ(ema.state, 5.0f);
}
TEST_F(ExponentialMovingAverageTest, FirstValueAfterRebootUsesNewValue) {
{
TestableExponentialMovingAverageSensor before(&this->source_);
before.setup();
before.process_(10.0f, 0);
}
// No reading from before the reboot is known, so the new value is used for the first interval.
TestableExponentialMovingAverageSensor after(&this->source_);
after.set_time_constant(1000);
after.set_time_weighting(TIME_WEIGHTING_PREVIOUS);
after.setup();
after.process_(20.0f, 1000);
EXPECT_NEAR(after.state, 10.0f + 10.0f * (1.0f - std::exp(-1.0f)), 1e-4f);
}
TEST_F(ExponentialMovingAverageTest, StartsFromSourceThatAlreadyHasAValue) {
sensor::Sensor source;
source.publish_state(12.0f);
TestableExponentialMovingAverageSensor ema(&source);
ema.set_alpha(0.5f);
ema.set_restore(false);
ema.setup();
ASSERT_TRUE(ema.has_state());
EXPECT_FLOAT_EQ(ema.state, 12.0f);
// The value read at setup is only counted once.
source.publish_state(20.0f);
EXPECT_FLOAT_EQ(ema.state, 16.0f);
}
TEST_F(ExponentialMovingAverageTest, SourceValueAtSetupBlendsWithRestoredAverage) {
{
TestableExponentialMovingAverageSensor before(&this->source_);
before.setup();
before.process_(10.0f, 0);
}
sensor::Sensor source;
source.publish_state(20.0f);
TestableExponentialMovingAverageSensor after(&source);
after.set_alpha(0.5f);
after.setup();
EXPECT_FLOAT_EQ(after.state, 15.0f);
}
TEST_F(ExponentialMovingAverageTest, SourceNanAtSetupIsIgnored) {
sensor::Sensor source;
source.publish_state(NAN);
TestableExponentialMovingAverageSensor ema(&source);
ema.set_restore(false);
ema.setup();
EXPECT_FALSE(ema.has_state());
}
// Reference weights from the Taylor series, accurate for the small ratios used below.
static double series_gain(double x) { return x - x * x / 2 + x * x * x / 6; }
static double series_weight_new(double x) { return x / 2 - x * x / 6 + x * x * x / 24; }
TEST_F(ExponentialMovingAverageTest, LinearWeightingAccurateWithLongTimeConstant) {
constexpr uint32_t time_constant = 43200000; // 12 hours
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_time_constant(time_constant);
ema.set_time_weighting(TIME_WEIGHTING_LINEAR);
ema.set_restore(false);
ema.setup();
const double x = 1000.0 / time_constant;
const double weight_new = series_weight_new(x);
const double weight_previous = series_gain(x) - weight_new;
ema.process_(0.0f, 0);
ema.process_(10.0f, 1000);
const double expected = weight_new * 10.0;
EXPECT_NEAR(ema.state, expected, expected * 1e-4);
ema.process_(20.0f, 2000);
const double expected2 = expected + weight_previous * (10.0 - expected) + weight_new * (20.0 - expected);
EXPECT_NEAR(ema.state, expected2, expected2 * 1e-4);
}
TEST_F(ExponentialMovingAverageTest, VeryShortIntervalStillMovesAverage) {
constexpr uint32_t time_constant = 4 * 24 * 3600000; // 4 days, with a reading on every 16 ms loop
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_time_constant(time_constant);
ema.set_restore(false);
ema.setup();
ema.process_(0.0f, 0);
ema.process_(1000.0f, 16);
const double expected = series_gain(16.0 / time_constant) * 1000.0;
EXPECT_NEAR(ema.state, expected, expected * 1e-4);
}
TEST(TimeWeightingTest, Names) {
EXPECT_STREQ(LOG_STR_ARG(time_weighting_to_string(TIME_WEIGHTING_NEW)), "new");
EXPECT_STREQ(LOG_STR_ARG(time_weighting_to_string(TIME_WEIGHTING_PREVIOUS)), "previous");
EXPECT_STREQ(LOG_STR_ARG(time_weighting_to_string(TIME_WEIGHTING_LINEAR)), "linear");
}
struct ScaleDurationCase {
uint32_t ms;
float value;
const char *unit;
uint8_t decimals;
};
class ScaleDurationTest : public ::testing::TestWithParam<ScaleDurationCase> {};
TEST_P(ScaleDurationTest, PicksLargestUnitOfAtLeastOne) {
const ScaleDurationCase &c = GetParam();
const ScaledDuration scaled = scale_duration(c.ms);
EXPECT_FLOAT_EQ(scaled.value, c.value);
EXPECT_STREQ(LOG_STR_ARG(scaled.unit), c.unit);
EXPECT_EQ(scaled.decimals, c.decimals);
}
INSTANTIATE_TEST_SUITE_P(
Units, ScaleDurationTest,
::testing::Values(ScaleDurationCase{1, 1.0f, "ms", 0}, ScaleDurationCase{999, 999.0f, "ms", 0},
ScaleDurationCase{1000, 1.0f, "s", 1}, ScaleDurationCase{95000, 1.5833334f, "min", 1},
ScaleDurationCase{59999, 59.999f, "s", 1}, ScaleDurationCase{60000, 1.0f, "min", 1},
ScaleDurationCase{300000, 5.0f, "min", 1}, ScaleDurationCase{3599999, 59.999983f, "min", 1},
ScaleDurationCase{3600000, 1.0f, "h", 1}, ScaleDurationCase{86400000, 24.0f, "h", 1}));
} // namespace esphome::exponential_moving_average::testing
@@ -0,0 +1,2 @@
packages:
exponential_moving_average: !include common.yaml
+19 -2
View File
@@ -66,9 +66,26 @@ binary_sensor:
}
return false;
# Exercise fan.turn_on with various field combinations so the
# register_apply_action codegen paths get build coverage.
button:
# Read and set preset modes the way external fan components do
- platform: template
name: "Fan Preset Modes Lambdas"
on_press:
- lambda: |-
// How external components and configs read and set fan preset modes
auto traits = id(test_fan).get_traits();
const auto &modes = traits.supported_preset_modes();
for (const auto &mode : modes)
ESP_LOGD("test", "%s", mode);
for (const char *mode : traits.supported_preset_modes())
ESP_LOGD("test", "%s", mode);
std::vector<const char *> runtime_modes{"Eco", "Turbo"};
id(test_fan).set_supported_preset_modes(runtime_modes);
id(test_fan).set_supported_preset_modes({"Eco"});
id(test_fan).set_supported_preset_modes({});
# Exercise fan.turn_on with various field combinations so the
# register_apply_action codegen paths get build coverage.
- platform: template
name: "Fan Speed Only"
on_press:
@@ -6,7 +6,7 @@
namespace esphome::hoermann_hcp::testing {
// The intermediate positions are named in the second register, which repeats that name on release.
// The intermediate positions are named in the second register, next to 0x0100 in the first.
TEST(HoermannHcpButtonTest, VentButtonSendsTheVentCommand) {
TestableHoermannHcp door;
HoermannHcpVentButton vent(&door);
@@ -14,13 +14,13 @@ TEST(HoermannHcpButtonTest, VentButtonSendsTheVentCommand) {
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);
auto [value, value_2] = poll_command(door);
EXPECT_EQ(value, 0x0100);
EXPECT_EQ(value_2, 0x4000);
// Sent once, in the first register as well as in the second.
auto [after, after_2] = poll_command(door);
EXPECT_EQ(after, 0x0000);
EXPECT_EQ(after_2, 0x0000);
}
TEST(HoermannHcpButtonTest, HalfOpenButtonSendsTheHalfOpenCommand) {
@@ -30,13 +30,13 @@ TEST(HoermannHcpButtonTest, HalfOpenButtonSendsTheHalfOpenCommand) {
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);
auto [value, value_2] = poll_command(door);
EXPECT_EQ(value, 0x0100);
EXPECT_EQ(value_2, 0x0400);
// Sent once, in the first register as well as in the second.
auto [after, after_2] = poll_command(door);
EXPECT_EQ(after, 0x0000);
EXPECT_EQ(after_2, 0x0000);
}
// The door drives to the vent position on its own, so a position the cover was still travelling to must not
+15 -21
View File
@@ -1,7 +1,5 @@
#pragma once
#include <chrono>
#include <initializer_list>
#include <thread>
#include <utility>
#include <gtest/gtest.h>
#include "esphome/components/hoermann_hcp/hoermann_hcp.h"
@@ -14,9 +12,11 @@ using modbus::RegisterValues;
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);
// The lamp commands a status answer carries in its two command registers.
constexpr uint16_t LIGHT_ON = 0x0880;
constexpr uint16_t LIGHT_ON_2 = 0x0000;
constexpr uint16_t LIGHT_OFF = 0x0800;
constexpr uint16_t LIGHT_OFF_2 = 0x0100;
inline RegisterValues make_registers(std::initializer_list<uint16_t> values) {
RegisterValues registers;
@@ -35,16 +35,15 @@ inline void connect_controller(HoermannHcp &door) {
door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000}));
}
// Runs one status poll (write 2 / read 8) and returns the whole answer. The bus controller writes its counter
// with command 0x03 here; most tests do not care and pass zero.
inline RegisterValues status_answer(HoermannHcp &door, uint16_t command_reg = 0x0000) {
// Runs one status poll (write 2 / read 8) and returns the whole answer.
inline RegisterValues status_answer(HoermannHcp &door, uint16_t command_reg = 0x0003) {
door.on_write_registers(COMMAND_REG, make_registers({command_reg, 0x0000}));
RegisterValues response;
door.on_read_holding_registers(STATE_REG, 8, response);
return response;
}
// Runs one command poll (write 2 / read 8) and returns both key-press registers.
// Runs one status poll and returns both command registers.
inline std::pair<uint16_t, uint16_t> poll_command(HoermannHcp &door) {
const RegisterValues response = status_answer(door);
EXPECT_EQ(response.size(), 8u);
@@ -53,29 +52,24 @@ inline std::pair<uint16_t, uint16_t> poll_command(HoermannHcp &door) {
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);
}
// Lets the controller fetch the queued command, leaving the slot free.
inline void consume_command(HoermannHcp &door) { 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::start_window_ms_;
#ifdef USE_HOERMANN_HCP_IDENTITY
using HoermannHcp::identity_asked_at_;
using HoermannHcp::identity_request_;
using HoermannHcp::firmware_unreadable_;
using HoermannHcp::serial_unreadable_;
#endif
using HoermannHcp::is_light_toggle_pending_;
using HoermannHcp::key_press_delay_ms_;
using HoermannHcp::light_toggle_released_at_;
using HoermannHcp::light_toggles_in_flight_;
using HoermannHcp::last_stop_at_;
using HoermannHcp::light_requested_;
using HoermannHcp::light_since_;
using HoermannHcp::light_command_sent_;
using HoermannHcp::set_valid_;
};
@@ -68,7 +68,7 @@ TEST(HoermannHcpCoverTest, OpenCommandOpensTheDoor) {
connect_controller(door);
cover.make_call().set_command_open().perform();
EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
}
// The same for cover.close, which arrives as a position of 0.0.
@@ -79,7 +79,7 @@ TEST(HoermannHcpCoverTest, CloseCommandClosesTheDoor) {
connect_controller(door);
cover.make_call().set_command_close().perform();
EXPECT_EQ(poll_command(door).first, 0x0220); // COMMAND_CLOSE pressed
EXPECT_EQ(poll_command(door).first, 0x0120); // COMMAND_CLOSE
}
TEST(HoermannHcpCoverTest, ToggleCommandSendsAnImpulse) {
@@ -89,7 +89,7 @@ TEST(HoermannHcpCoverTest, ToggleCommandSendsAnImpulse) {
connect_controller(door);
cover.make_call().set_command_toggle().perform();
EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
}
TEST(HoermannHcpCoverTest, StopCommandStopsAMovingDoor) {
@@ -101,7 +101,7 @@ TEST(HoermannHcpCoverTest, StopCommandStopsAMovingDoor) {
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100}));
cover.make_call().set_command_stop().perform();
EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
}
// A position between the end stops starts the door in the right direction; it is stopped there later.
@@ -112,7 +112,7 @@ TEST(HoermannHcpCoverTest, PositionCommandStartsTheDoorTowardsTheTarget) {
connect_controller(door);
cover.make_call().set_position(0.5f).perform();
EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
}
// A command the door cannot take is assumed to have worked by whoever sent it, so the unchanged state has
@@ -33,30 +33,30 @@ TEST(HoermannHcpReadWrite, BusScanReturnsIdentification) {
EXPECT_EQ(response[4], 0xa845);
}
// Without a queued command, the command poll (write 2 / read 8) reports idle and no key press.
// Without a queued command, the command poll (write 2 / read 8) reports idle and no command.
TEST(HoermannHcpReadWrite, IdleCommandPollHasNoCommand) {
HoermannHcp door;
EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value());
EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0003, 0x0000})).has_value());
RegisterValues response;
auto status = door.on_read_holding_registers(STATE_REG, 8, response);
EXPECT_FALSE(status.has_value());
ASSERT_EQ(response.size(), 8u);
EXPECT_EQ(response[1], 0x0001);
EXPECT_EQ(response[1], 0x0301);
EXPECT_EQ(response[2], 0x0000);
EXPECT_EQ(response[3], 0x0000);
}
// A queued control command is injected into the next command poll as a simulated key press.
// A queued control command is injected into the next command poll.
TEST(HoermannHcpReadWrite, QueuedCommandIsInjectedIntoPoll) {
HoermannHcp door;
connect_controller(door);
door.open_door();
EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value());
EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0003, 0x0000})).has_value());
RegisterValues response;
auto status = door.on_read_holding_registers(STATE_REG, 8, response);
EXPECT_FALSE(status.has_value());
ASSERT_EQ(response.size(), 8u);
EXPECT_EQ(response[2], 0x0210); // COMMAND_OPEN "key pressed" value
EXPECT_EQ(response[2], 0x0110); // COMMAND_OPEN
EXPECT_EQ(response[3], 0x0000);
}
@@ -68,20 +68,368 @@ TEST(HoermannHcpReadWrite, UnknownAddressIsRejected) {
EXPECT_EQ(door.on_write_registers(0x1234, make_registers({0x0000})), modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS);
}
// A command is held for the key-press duration, then released, and only then can the next one be queued.
TEST(HoermannHcpReadWrite, CommandIsReleasedAfterTheKeyPressDelay) {
// A door command goes out in one answer and frees the slot at once.
TEST(HoermannHcpReadWrite, DoorCommandIsSentOnceAndFreesTheSlot) {
TestableHoermannHcp door;
connect_controller(door);
door.open_door();
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();
EXPECT_TRUE(door.open_door());
// Refused while one is unfetched.
EXPECT_FALSE(door.close_door());
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
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).first, 0x0220); // COMMAND_CLOSE pressed
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
EXPECT_EQ(poll_command(door).first, 0x0000);
// Once the door has run and come to rest, the slot takes the next command.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100}));
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C8, 0x2000}));
EXPECT_TRUE(door.close_door());
EXPECT_EQ(poll_command(door).first, 0x0120); // COMMAND_CLOSE
}
// A moving door is only ever stopped, whatever it is asked to do.
TEST(HoermannHcpReadWrite, MovingDoorIsOnlyStopped) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
ASSERT_EQ(door.get_door_state(), DoorState::OPENING);
EXPECT_TRUE(door.close_door());
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
}
// A second stop within 500 ms is the same press and must not restart the door.
TEST(HoermannHcpReadWrite, SecondStopWithinHalfASecondIsIgnored) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
ASSERT_TRUE(door.stop_door());
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
// Still reported moving while it slows down.
EXPECT_TRUE(door.open_door());
EXPECT_EQ(poll_command(door).first, 0x0000);
door.last_stop_at_ -= 500;
EXPECT_TRUE(door.stop_door());
EXPECT_EQ(poll_command(door).first, 0x0140);
}
// A stop outranks a command still waiting, so a door at rest does not start after it.
TEST(HoermannHcpReadWrite, StopCancelsAnUnfetchedCommand) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
ASSERT_TRUE(door.open_door());
EXPECT_TRUE(door.stop_door());
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// The impulse would start a door that came to rest while the stop waited, so the stop is dropped instead.
TEST(HoermannHcpReadWrite, StopIsDroppedWhenTheDoorRestsBeforeTheFetch) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
ASSERT_TRUE(door.stop_door());
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C8, 0x2000}));
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// Until the door reports the start it reads as at rest, so a command in between only stops it, once it moves.
TEST(HoermannHcpReadWrite, CommandBeforeTheStartIsReportedBecomesAStop) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
ASSERT_TRUE(door.open_door());
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
EXPECT_TRUE(door.close_door());
EXPECT_EQ(poll_command(door).first, 0x0000);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0004, 0x0100}));
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
}
// A door that never reports moving after its command is at rest after all, so later commands are its own.
TEST(HoermannHcpReadWrite, StartWindowClosesWhenTheDoorNeverMoves) {
TestableHoermannHcp door;
door.start_window_ms_ = 20;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
ASSERT_TRUE(door.open_door());
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
std::this_thread::sleep_for(std::chrono::milliseconds(30));
connect_controller(door);
door.update();
EXPECT_TRUE(door.open_door());
EXPECT_EQ(poll_command(door).first, 0x0110);
}
// Only the read half of a status poll carries a command, so a second read without a new write gets none.
TEST(HoermannHcpReadWrite, SecondReadWithoutAWriteCarriesNoCommand) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
door.on_write_registers(COMMAND_REG, make_registers({0x0003, 0x0000}));
RegisterValues first;
door.on_read_holding_registers(STATE_REG, 8, first);
ASSERT_TRUE(door.open_door());
RegisterValues second;
door.on_read_holding_registers(STATE_REG, 8, second);
ASSERT_EQ(second.size(), 8u);
EXPECT_EQ(second[2], 0x0000);
// The next status poll takes it.
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
}
// A command queued for a door at rest only stops it if the door was started from elsewhere before the fetch.
TEST(HoermannHcpReadWrite, CommandForADoorStartedBeforeTheFetchStopsIt) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C8, 0x2000}));
ASSERT_TRUE(door.open_door());
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C0, 0x0200}));
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
}
// A door started from elsewhere the way the queued command wants keeps going, and the command is dropped.
TEST(HoermannHcpReadWrite, CommandForADoorAlreadyMovingThatWayIsDropped) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
ASSERT_TRUE(door.open_door());
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0004, 0x0100}));
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// The same holds for a close queued while the door was open and then started closing from elsewhere.
TEST(HoermannHcpReadWrite, CloseForADoorAlreadyClosingIsDropped) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C8, 0x2000}));
ASSERT_TRUE(door.close_door());
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C0, 0x0200}));
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// Another rest state after a fetched start is not the door's answer yet, so the start window stays open.
TEST(HoermannHcpReadWrite, RestStateAfterAStartKeepsTheWindow) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0010, 0x0061}));
ASSERT_EQ(door.get_door_state(), DoorState::VENT);
ASSERT_TRUE(door.open_door());
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0010, 0x0000}));
ASSERT_EQ(door.get_door_state(), DoorState::STOPPED);
// Still starting, so a close is only a stop, held until the door moves.
ASSERT_TRUE(door.close_door());
EXPECT_EQ(poll_command(door).first, 0x0000);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100}));
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
}
// A first report showing the door where the command sent it ends the start window, even without a change.
TEST(HoermannHcpReadWrite, FirstReportAtTheDestinationEndsTheStart) {
TestableHoermannHcp door;
connect_controller(door);
ASSERT_TRUE(door.close_door());
EXPECT_EQ(poll_command(door).first, 0x0120); // COMMAND_CLOSE
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
ASSERT_EQ(door.get_door_state(), DoorState::CLOSED);
ASSERT_TRUE(door.open_door());
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
}
// A stop held for a late start report is sent once the door reports moving.
TEST(HoermannHcpReadWrite, HeldStopSurvivesALateStart) {
TestableHoermannHcp door;
door.connection_timeout_ms_ = 20;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
ASSERT_TRUE(door.open_door());
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
ASSERT_TRUE(door.stop_door());
std::this_thread::sleep_for(std::chrono::milliseconds(30));
connect_controller(door);
door.update();
// The start is reported after the stop's own fetch deadline, which then starts over.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0004, 0x0100}));
door.update();
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
}
// A held stop goes when the door never reports its start, and never becomes an impulse.
TEST(HoermannHcpReadWrite, HeldStopIsDroppedWhenTheDoorNeverStarts) {
TestableHoermannHcp door;
door.start_window_ms_ = 20;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
ASSERT_TRUE(door.open_door());
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
ASSERT_TRUE(door.stop_door());
std::this_thread::sleep_for(std::chrono::milliseconds(30));
connect_controller(door);
door.update();
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// A command for where the door already rests does not move it, so the next command is its own.
TEST(HoermannHcpReadWrite, CommandForTheCurrentEndOpensNoStartWindow) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C8, 0x2000}));
ASSERT_TRUE(door.open_door());
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
ASSERT_TRUE(door.close_door());
EXPECT_EQ(poll_command(door).first, 0x0120); // COMMAND_CLOSE
}
// The stop lock ends with the door at rest, so a target reached soon after the next start still stops it.
TEST(HoermannHcpReadWrite, StopLockEndsWhenTheDoorRests) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
ASSERT_TRUE(door.stop_door());
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000}));
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100}));
ASSERT_TRUE(door.stop_door());
EXPECT_EQ(poll_command(door).first, 0x0140);
}
// A stop pressed while disconnected is not kept for the reconnect, where it could start a door at rest.
TEST(HoermannHcpReadWrite, StopWhileDisconnectedIsNotQueued) {
TestableHoermannHcp door;
door.connection_timeout_ms_ = 20;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
std::this_thread::sleep_for(std::chrono::milliseconds(30));
door.update();
ASSERT_FALSE(door.is_valid());
ASSERT_EQ(door.get_door_state(), DoorState::OPENING); // the stale report the stop would be judged by
EXPECT_FALSE(door.stop_door());
connect_controller(door);
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// Pressing stop twice before the fetch sends one impulse.
TEST(HoermannHcpReadWrite, SecondStopBeforeTheFetchSendsOneImpulse) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
ASSERT_TRUE(door.stop_door());
ASSERT_TRUE(door.stop_door());
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// A position asked for before the start is reported stops the door once it moves.
TEST(HoermannHcpReadWrite, PositionBeforeTheStartIsReportedIsAStop) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
ASSERT_TRUE(door.open_door());
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
ASSERT_TRUE(door.set_position(0.5f));
EXPECT_EQ(poll_command(door).first, 0x0000);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0004, 0x0100}));
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
}
// A stop left waiting for a door that came to rest does not block the next command.
TEST(HoermannHcpReadWrite, StaleStopDoesNotBlockTheNextCommand) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C0, 0x0100}));
ASSERT_TRUE(door.stop_door());
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C8, 0x2000}));
EXPECT_TRUE(door.close_door());
EXPECT_EQ(poll_command(door).first, 0x0120); // COMMAND_CLOSE
}
// Nor does it block a move to a position.
TEST(HoermannHcpReadWrite, StaleStopDoesNotBlockAPosition) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C0, 0x0100}));
ASSERT_TRUE(door.stop_door());
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C8, 0x2000}));
EXPECT_TRUE(door.set_position(0.5f));
EXPECT_EQ(poll_command(door).first, 0x0120); // COMMAND_CLOSE
}
// Close on a closed door and vent at the vent position are no moves either, but half open at vent is.
TEST(HoermannHcpReadWrite, EveryEndOpensNoStartWindow) {
TestableHoermannHcp door;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
ASSERT_TRUE(door.close_door());
EXPECT_EQ(poll_command(door).first, 0x0120); // COMMAND_CLOSE
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0010, 0x0A00}));
ASSERT_EQ(door.get_door_state(), DoorState::VENT);
ASSERT_TRUE(door.vent_door());
EXPECT_EQ(poll_command(door).first, 0x0100); // COMMAND_VENT
ASSERT_TRUE(door.half_open_door());
EXPECT_EQ(poll_command(door).first, 0x0100); // COMMAND_HALF_OPEN
// Half open from vent is a move, so a stop now waits for its start.
ASSERT_TRUE(door.stop_door());
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// Until the door has reported where it is, no command counts as a no-op.
TEST(HoermannHcpReadWrite, CommandBeforeTheFirstReportOpensAStartWindow) {
TestableHoermannHcp door;
connect_controller(door);
ASSERT_TRUE(door.close_door());
EXPECT_EQ(poll_command(door).first, 0x0120); // COMMAND_CLOSE
ASSERT_TRUE(door.stop_door());
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0200}));
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
}
// A stop dropped with the start window does not come back when the door moves after all.
TEST(HoermannHcpReadWrite, DroppedHeldStopDoesNotFireLater) {
TestableHoermannHcp door;
door.start_window_ms_ = 20;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
ASSERT_TRUE(door.open_door());
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
ASSERT_TRUE(door.stop_door());
std::this_thread::sleep_for(std::chrono::milliseconds(30));
connect_controller(door);
door.update();
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0004, 0x0100}));
EXPECT_EQ(poll_command(door).first, 0x0000);
}
// A start does not hold off the stop that follows it.
TEST(HoermannHcpReadWrite, StopRightAfterAStartIsSent) {
TestableHoermannHcp door;
connect_controller(door);
ASSERT_TRUE(door.impulse_door());
EXPECT_EQ(poll_command(door).first, 0x0140);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100}));
EXPECT_TRUE(door.stop_door());
EXPECT_EQ(poll_command(door).first, 0x0140);
}
// Only a status poll (command 0x03) fetches a command; another 8-register read carries zeros.
TEST(HoermannHcpReadWrite, CommandWaitsForAStatusPoll) {
TestableHoermannHcp door;
connect_controller(door);
ASSERT_TRUE(door.open_door());
// A transfer write (command 0x04) read back as 8 registers.
door.on_write_registers(COMMAND_REG, make_registers({0x0504, 0x0000}));
RegisterValues other;
door.on_read_holding_registers(STATE_REG, 8, other);
ASSERT_EQ(other.size(), 8u);
EXPECT_EQ(other[2], 0x0000);
EXPECT_EQ(other[3], 0x0000);
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
}
// Commands issued while the bus controller is absent are dropped instead of firing when it returns.
@@ -106,7 +454,7 @@ TEST(HoermannHcpReadWrite, ConnectionLossDropsThePendingCommand) {
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).first, 0x0220);
EXPECT_EQ(poll_command(door).first, 0x0120);
}
// The connection is dropped by update() once the controller stops polling, which is what releases a
@@ -141,13 +489,13 @@ TEST(HoermannHcpReadWrite, UnfetchedCommandExpiresWhileConnected) {
std::this_thread::sleep_for(std::chrono::milliseconds(220));
// A status broadcast refreshes the connection without ever fetching the command.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100}));
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0000}));
door.update();
ASSERT_TRUE(door.is_valid());
// With the stale command gone, the door accepts commands again.
door.close_door();
EXPECT_EQ(poll_command(door).first, 0x0220);
EXPECT_EQ(poll_command(door).first, 0x0120);
}
// The 0x17 read half echoes the message counter and command byte written to COMMAND_REG, packed
@@ -232,10 +580,7 @@ TEST(HoermannHcpPosition, NearlyClosedTargetClosesTheDoor) {
HoermannHcp door;
connect_controller(door);
door.set_position(0.02f);
RegisterValues response;
door.on_read_holding_registers(STATE_REG, 8, response);
ASSERT_EQ(response.size(), 8u);
EXPECT_EQ(response[2], 0x0220); // COMMAND_CLOSE "key pressed" value
EXPECT_EQ(poll_command(door).first, 0x0120); // COMMAND_CLOSE
}
// A half-open target starts the door moving towards the requested position.
@@ -243,10 +588,7 @@ TEST(HoermannHcpPosition, HalfOpenTargetOpensTheDoor) {
HoermannHcp door; // starts out fully closed
connect_controller(door);
door.set_position(0.5f);
RegisterValues response;
door.on_read_holding_registers(STATE_REG, 8, response);
ASSERT_EQ(response.size(), 8u);
EXPECT_EQ(response[2], 0x0210); // COMMAND_OPEN "key pressed" value
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
}
// The door has no notion of a target, so it is stopped with an impulse once it travels past the request.
@@ -254,9 +596,7 @@ TEST(HoermannHcpPosition, TargetPositionStopsTheDoor) {
TestableHoermannHcp door;
connect_controller(door);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
// 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}));
@@ -265,7 +605,7 @@ TEST(HoermannHcpPosition, TargetPositionStopsTheDoor) {
// 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).first, 0x0240); // COMMAND_IMPULSE pressed
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
}
// An impulse restarts a stopped door, so a frame reporting the stop and the target crossing at once
@@ -274,8 +614,6 @@ TEST(HoermannHcpPosition, StopReportedWithTheCrossingSendsNoImpulse) {
TestableHoermannHcp door;
connect_controller(door);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door).first, 0x0210);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door).first, 0x0110);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100}));
@@ -292,8 +630,6 @@ TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorStopsShort) {
TestableHoermannHcp door;
connect_controller(door);
door.set_position(0.5f);
EXPECT_EQ(poll_command(door).first, 0x0210);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door).first, 0x0110);
// The door is stopped at 0.3 by a wall button, short of the requested 0.5.
@@ -307,77 +643,53 @@ TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorStopsShort) {
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) {
// A new position while the door moves only stops it.
TEST(HoermannHcpPosition, NewPositionWhileMovingStopsTheDoor) {
TestableHoermannHcp 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).first, 0x0210); // COMMAND_OPEN pressed
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
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_TRUE(door.set_position(0.5f));
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100}));
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).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).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) {
// A target outlives a start reported late, as long as it comes within the start window.
TEST(HoermannHcpPosition, TargetSurvivesALateStart) {
TestableHoermannHcp door;
door.connection_timeout_ms_ = 20;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200}));
ASSERT_EQ(door.get_door_state(), DoorState::CLOSING);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000}));
ASSERT_TRUE(door.set_position(0.5f));
EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN
std::this_thread::sleep_for(std::chrono::milliseconds(30));
connect_controller(door);
door.update();
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100}));
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100}));
EXPECT_EQ(poll_command(door).first, 0x0140); // COMMAND_IMPULSE
}
door.set_position(0.5f);
EXPECT_EQ(poll_command(door).first, 0x0210);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
EXPECT_EQ(poll_command(door).first, 0x0110);
// The stop reported on the way from closing to opening.
// A door that never starts has to lose the target, otherwise it would cut a later move short.
TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorNeverStarts) {
TestableHoermannHcp door;
door.connection_timeout_ms_ = 200;
door.start_window_ms_ = 200;
connect_controller(door);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000}));
ASSERT_EQ(door.get_door_state(), DoorState::STOPPED);
// 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).first, 0x0000);
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100}));
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_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).first, 0x0210);
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
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
// target is the only thing that may expire here.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000}));
// The door ignored the command. Its broadcast keeps the connection alive, so the target is the only thing
// that may expire here.
door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000}));
door.update();
ASSERT_TRUE(door.is_valid());
ASSERT_EQ(door.get_door_state(), DoorState::CLOSED);
// 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}));
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,6 @@
#include <gtest/gtest.h>
#include <string>
#include <thread>
#include "esphome/components/text_sensor/text_sensor.h"
@@ -40,7 +39,7 @@ void write_transfer(HoermannHcp &door, uint8_t counter, uint8_t sub_code, const
// A whole payload transfer, returning the answer the motor reads back.
RegisterValues transfer(HoermannHcp &door, uint8_t counter, uint8_t sub_code, const char *bytes, size_t len,
uint16_t read_registers = 8) {
uint16_t read_registers = 2) {
write_transfer(door, counter, sub_code, bytes, len);
RegisterValues response;
door.on_read_holding_registers(STATE_REG, read_registers, response);
@@ -100,8 +99,8 @@ TEST(HoermannHcpTextSensorTest, NothingChangesWithoutASensor) {
EXPECT_EQ(response[1], 0x0301);
EXPECT_EQ(response[2], 0x0000);
}
const RegisterValues answer = transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14);
EXPECT_EQ(answer[1] & 0x00FF, 0x0001);
// Answered as an ordinary 2-register read, not acknowledged.
EXPECT_THAT(transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14), ::testing::ElementsAre(0x8504, 0x0400));
}
// Like Hoermann's own bus accessory, the first status poll gets an ordinary answer and the next one carries the
@@ -166,7 +165,7 @@ TEST(HoermannHcpTextSensorTest, SerialNumberInTwoHalvesThenTheFirmwareVersion) {
request_serial(door);
RegisterValues answer = transfer(door, FIRST_HALF | 0x05, SUB_SERIAL, SERIAL, 14);
ASSERT_EQ(answer.size(), 8u);
ASSERT_EQ(answer.size(), 2u);
EXPECT_EQ(answer[0], 0x0500);
EXPECT_EQ(answer[1], 0x04FD);
EXPECT_EQ(fixture.serial_shown(), "");
@@ -410,19 +409,16 @@ TEST(HoermannHcpTextSensorTest, FirmwareVersionThatIsNotTextIsLoggedNotShown) {
EXPECT_EQ(door.identity_request_(), 0);
}
// A repeat of a transfer already taken, as after a lost acknowledgement, is acknowledged again. Answered as a
// status poll instead, it would carry the key press waiting in the slot.
TEST(HoermannHcpTextSensorTest, RepeatedTransferIsAcknowledgedNotAnsweredWithAKeyPress) {
// A repeated transfer, as after a lost acknowledgement, is acknowledged again, not answered with a command.
TEST(HoermannHcpTextSensorTest, RepeatedTransferIsAcknowledgedNotAnsweredWithACommand) {
IdentityFixture fixture;
auto &door = fixture.door;
run_identity_exchange(door);
connect_controller(door);
door.open_door();
const RegisterValues answer = transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 12);
EXPECT_EQ(answer[1], 0x04FD);
EXPECT_EQ(answer[2], 0x0000);
EXPECT_EQ(status_poll(door)[2], 0x0210);
EXPECT_THAT(transfer(door, 0x08, SUB_FIRMWARE, FIRMWARE, 12), ::testing::ElementsAre(0x0800, 0x04FD));
EXPECT_EQ(status_poll(door)[2], 0x0110);
}
// An answer belongs to the frame whose write half took the transfer. A frame whose read went elsewhere leaves
@@ -444,29 +440,38 @@ TEST(HoermannHcpTextSensorTest, RequestRidesOnlyOnAStatusPoll) {
IdentityFixture fixture;
auto &door = fixture.door;
status_poll(door);
const RegisterValues other = transfer(door, 0x06, 0x19, "\x00\x0F", 2);
const RegisterValues other = transfer(door, 0x06, 0x19, "\x00\x0F", 2, 8);
EXPECT_EQ(other[1] & 0x00FF, 0x0001);
EXPECT_EQ(status_poll(door, 0x07)[1], 0x0322);
}
// The request travels in the registers a key press would, so it waits for the press, the hold and the release.
TEST(HoermannHcpTextSensorTest, RequestWaitsForTheKeyPress) {
// The request waits for the answer that carries a door command.
TEST(HoermannHcpTextSensorTest, RequestWaitsForTheDoorCommand) {
IdentityFixture fixture;
auto &door = fixture.door;
door.key_press_delay_ms_ = 100;
connect_controller(door);
status_poll(door);
door.open_door();
EXPECT_EQ(status_poll(door)[2], 0x0210);
const RegisterValues held = status_poll(door);
EXPECT_EQ(held[1], 0x0301);
EXPECT_EQ(held[2], 0x0000);
door.key_press_delay_ms_ = 0;
std::this_thread::sleep_for(KEY_PRESS_ELAPSED);
const RegisterValues release = status_poll(door);
EXPECT_EQ(release[1], 0x0301);
EXPECT_EQ(release[2], 0x0110);
const RegisterValues command = status_poll(door);
EXPECT_EQ(command[1], 0x0301);
EXPECT_EQ(command[2], 0x0110);
EXPECT_EQ(status_poll(door)[1], 0x0322);
}
// The same for the lamp command.
TEST(HoermannHcpTextSensorTest, RequestWaitsForTheLampCommand) {
IdentityFixture fixture;
auto &door = fixture.door;
connect_controller(door);
status_poll(door);
door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000));
ASSERT_TRUE(door.set_light(true));
const RegisterValues light = status_poll(door);
EXPECT_EQ(light[1], 0x0301);
EXPECT_EQ(light[2], LIGHT_ON);
EXPECT_EQ(light[3], LIGHT_ON_2);
EXPECT_EQ(status_poll(door)[1], 0x0322);
}
+30 -4
View File
@@ -26,10 +26,6 @@ esphome:
variables:
my_variable: !lambda "return id(ha_hello_world_temperature).state;"
wifi:
ssid: MySSID
password: password1
api:
switch:
@@ -71,6 +67,36 @@ number:
- platform: homeassistant
entity_id: number.hello_world
id: ha_hello_world_number
- platform: homeassistant
entity_id: input_number.hello_world
id: ha_hello_world_input_number
select:
- platform: homeassistant
entity_id: select.hello_world
id: ha_hello_world_select
- platform: homeassistant
entity_id: input_select.hello_world
id: ha_hello_world_input_select
max_options: 8
options_buffer_size: 128
text:
- platform: homeassistant
entity_id: text.hello_world
id: ha_hello_world_text_input
- platform: homeassistant
entity_id: input_text.hello_world
id: ha_hello_world_input_text
mode: password
button:
- platform: homeassistant
entity_id: button.hello_world
id: ha_hello_world_button
- platform: homeassistant
entity_id: input_button.hello_world
id: ha_hello_world_input_button
sensor:
- platform: homeassistant
@@ -1,2 +1,6 @@
packages:
common: !include common.yaml
wifi:
ssid: MySSID
password: password1
@@ -1,2 +1,6 @@
packages:
common: !include common.yaml
wifi:
ssid: MySSID
password: password1
@@ -1,2 +1,6 @@
packages:
common: !include common.yaml
wifi:
ssid: MySSID
password: password1
@@ -0,0 +1,2 @@
packages:
common: !include common.yaml
@@ -1,2 +1,6 @@
packages:
common: !include common.yaml
wifi:
ssid: MySSID
password: password1
@@ -1,2 +1,6 @@
packages:
common: !include common.yaml
wifi:
ssid: MySSID
password: password1
+15
View File
@@ -28,6 +28,21 @@ esphome:
ESP_LOGI("test", "Name length: %d", (int) name.size());
}
# get_effects() shapes used by published configs and external components
- lambda: |-
const auto &effects = id(test_monochromatic_light).get_effects();
auto &same = id(test_monochromatic_light).get_effects();
uint32_t total = effects.size();
for (auto *effect : effects) {
ESP_LOGD("test", "Effect %s", effect->get_name().c_str());
}
if (total > 0) {
ESP_LOGD("test", "First %s", effects.at(0)->get_name().c_str());
// Raw pointer iterators on purpose: external code binds std::find's result to `const auto *`
auto *it = std::find(same.begin(), same.end(), effects[0]);
ESP_LOGD("test", "Index %d", (int) (it - same.begin()));
}
# Test LightState::get_effect_name() returns StringRef
- lambda: |-
// Test LightState::get_effect_name() returns StringRef
@@ -0,0 +1,2 @@
logger:
hardware_uart: UART0
+45
View File
@@ -53,3 +53,48 @@ display:
reset_pin:
allow_other_uses: true
number: ${reset_pin}
- platform: mipi_spi
id: amoled_display
model: RM690B0
dc_pin:
allow_other_uses: true
number: ${dc_pin}
cs_pin:
allow_other_uses: true
number: ${cs_pin}
reset_pin:
allow_other_uses: true
number: ${reset_pin}
- platform: mipi_spi
id: custom_display
model: custom
brightness: 0x80
dimensions:
width: 240
height: 240
init_sequence:
- [0xd0, 1]
dc_pin:
allow_other_uses: true
number: ${dc_pin}
cs_pin:
allow_other_uses: true
number: ${cs_pin}
reset_pin:
allow_other_uses: true
number: ${reset_pin}
light:
- platform: mipi_spi
id: display_brightness
name: Display Brightness
display_id: amoled_display
- platform: mipi_spi
id: custom_display_brightness
name: Custom Display Brightness
display_id: custom_display
min_brightness: 16
max_brightness: 200
@@ -10,6 +10,16 @@ light:
name: Flicker Effect With Custom Values
update_interval: 16ms
intensity: 5%
- addressable_color_wipe:
name: Color Wipe With Random
colors:
- red: 100%
green: 0%
blue: 0%
num_leds: 2
gradient: true
- random: true
num_leds: 1
type: GRBW
variant: SK6812
method: esp8266_uart
@@ -0,0 +1,2 @@
packages:
noise: !include common.yaml
@@ -0,0 +1,15 @@
# Provisioning window on LN882x, where the fallback AP runs on its own: closing
# the window hands the radio back to the networks.
provisioning:
timeout: 1min
api:
encryption:
wifi:
ssid: MySSID
password: password1
ap:
ssid: MyAP
captive_portal:
@@ -3,5 +3,7 @@ button:
name: Clear motion calibration
on_press:
- motion.clear_calibration:
id: qmi8658_motion
- motion.clear_calibration:
id: qmi8658_motion
save: true
+41
View File
@@ -3,6 +3,7 @@ sensor:
name: "QMI8658 Temperature"
- platform: motion
motion_id: qmi8658_motion
type: acceleration_x
name: "Accel X"
accuracy_decimals: 4
@@ -11,45 +12,85 @@ sensor:
window_size: 4
send_every: 1
- platform: motion
motion_id: qmi8658_motion
type: acceleration_y
name: "Accel Y"
accuracy_decimals: 4
- platform: motion
motion_id: qmi8658_motion
type: acceleration_z
name: "Accel Z"
accuracy_decimals: 4
# Gyroscope axes (unit: °/s)
- platform: motion
motion_id: qmi8658_motion
type: gyroscope_x
name: "Gyro X"
- platform: motion
motion_id: qmi8658_motion
type: gyroscope_y
name: "Gyro Y"
- platform: motion
motion_id: qmi8658_motion
type: gyroscope_z
name: "Gyro Z"
- platform: motion
motion_id: qmi8658_motion
type: angular_rate_x
name: "Angular Rate X"
- platform: motion
motion_id: qmi8658_motion
type: angular_rate_y
name: "Angular Rate Y"
- platform: motion
motion_id: qmi8658_motion
type: angular_rate_z
name: "Angular Rate Z"
- platform: motion
motion_id: qmi8658_motion
type: pitch
name: "Pitch"
- platform: motion
motion_id: qmi8658_motion
type: roll
name: "Roll"
- platform: motion
motion_id: qmi8658_motion
type: orientation
name: "Orientation"
flat_threshold: 30
binary_sensor:
- platform: motion
motion_id: qmi8658_motion
type: face_up
name: "Face Up"
- platform: motion
motion_id: qmi8658_motion
type: face_down
name: "Face Down"
- platform: motion
motion_id: qmi8658_motion
type: free_fall
name: "Free Fall"
- platform: motion
motion_id: qmi8658_motion
type: moving
name: "Moving"
event:
- platform: motion
motion_id: qmi8658_motion
name: "Shake"
motion:
- platform: qmi8658
id: qmi8658_motion
i2c_id: i2c_bus
update_interval: 100ms
# Accelerometer full-scale range: 2G | 4G | 8G | 16G
accelerometer_range: 4G
+58
View File
@@ -33,6 +33,20 @@ button:
cycle: false
- select.first: select_test_select
- select.last: select_test_select
- if:
condition:
select.is:
id: select_test_select
options: [one, two]
then:
- select.next: select_test_select
- if:
condition:
select.is:
id: select_test_select
lambda: return current == "three";
then:
- select.first: select_test_select
- select.operation:
id: select_test_select
operation: next
@@ -41,3 +55,47 @@ button:
id: select_test_select
operation: !lambda return SELECT_OP_PREVIOUS;
cycle: !lambda return true;
- platform: template
name: "Test Select Options Lambdas"
on_press:
- lambda: |-
auto &options = id(select_test_select).traits.get_options();
for (size_t i = 0; i < options.size(); i++)
ESP_LOGD("test", "%s", options[i]);
- lambda: |-
std::string wanted = "two";
const auto &opts = id(select_test_select).traits.get_options();
if (opts.empty())
return;
for (int i = 0; i < (int) opts.size(); i++) {
if (opts[i] == wanted)
ESP_LOGD("test", "found at %d", i);
}
if (id(select_test_select).traits.get_options()[0] == wanted)
ESP_LOGD("test", "first");
auto it = std::find(opts.begin(), opts.end(), std::string("three"));
ESP_LOGD("test", "index %d", (int) (it - opts.begin()));
- lambda: |-
static std::vector<std::string> names{"red", "green"};
FixedVector<const char *> fresh;
fresh.init(names.size());
for (auto &name : names)
fresh.push_back(name.c_str());
if (id(select_test_select).traits.get_options().size() != fresh.size())
id(select_test_select).traits.set_options(fresh);
id(select_test_select).traits.set_options({"one", "two", "three"});
- lambda: |-
const char *value = "two";
const auto &options = id(select_test_select).traits.get_options();
const auto *it = std::find_if(options.begin(), options.end(),
[value](const char *option) { return strcmp(option, value) == 0; });
if (it == options.end())
return;
uint32_t index = (uint32_t) (it - options.begin());
ESP_LOGD("test", "index %u", (unsigned) index);
const auto *it2 = std::find(options.begin(), options.end(), options[0]);
ESP_LOGD("test", "first at %d", (int) (it2 - options.begin()));
- lambda: |-
auto &options = id(select_test_select).traits.get_options();
for (size_t i = 0; i < options.size(); i++)
ESP_LOGD("test", "%u: %s", (unsigned) i, options.at(i));
@@ -0,0 +1,83 @@
#include <gtest/gtest.h>
#include <string>
#include <vector>
#include "esphome/components/select/select_traits.h"
namespace esphome::select::testing {
static constexpr const char *const OPTIONS[] = {"low", "medium", "high"};
TEST(SelectTraits, ViewsTheTableWithoutCopying) {
SelectTraits traits;
EXPECT_TRUE(traits.get_options().empty());
traits.set_options_static(OPTIONS, 3);
const auto &options = traits.get_options();
EXPECT_EQ(options.size(), 3U);
EXPECT_FALSE(options.empty());
EXPECT_EQ(options.data(), OPTIONS);
EXPECT_STREQ(options[1], "medium");
EXPECT_STREQ(options.at(2), "high");
std::vector<std::string> seen;
for (const char *option : options)
seen.emplace_back(option);
EXPECT_EQ(seen, (std::vector<std::string>{"low", "medium", "high"}));
}
TEST(SelectTraits, RuntimeListsAreCopied) {
SelectTraits traits;
traits.set_options({"a", "b"});
EXPECT_EQ(traits.get_options().size(), 2U);
EXPECT_STREQ(traits.get_options()[1], "b");
FixedVector<const char *> list;
list.init(3);
list.push_back("x");
list.push_back("y");
list.push_back("z");
traits.set_options(list);
EXPECT_NE(traits.get_options().data(), list.begin());
EXPECT_EQ(traits.get_options().size(), 3U);
EXPECT_STREQ(traits.get_options().at(2), "z");
// A later runtime list replaces the earlier copy
traits.set_options({"only"});
EXPECT_EQ(traits.get_options().size(), 1U);
EXPECT_STREQ(traits.get_options()[0], "only");
}
TEST(SelectTraits, CopyingAnotherSelectSurvivesItsNextRuntimeList) {
SelectTraits source;
source.set_options({"a", "b"});
SelectTraits copy;
copy.set_options(source.get_options());
EXPECT_NE(copy.get_options().data(), source.get_options().data());
source.set_options({"c"});
ASSERT_EQ(copy.get_options().size(), 2U);
EXPECT_STREQ(copy.get_options()[0], "a");
EXPECT_STREQ(copy.get_options()[1], "b");
}
TEST(SelectTraits, StaticTablesAreNeverOwned) {
SelectTraits traits;
traits.set_options_static(OPTIONS, 3);
EXPECT_EQ(traits.get_options().data(), OPTIONS);
EXPECT_EQ(traits.get_options().size(), 3U);
// A runtime list after a static one copies and leaves the static table alone
traits.set_options({"x"});
EXPECT_NE(traits.get_options().data(), OPTIONS);
EXPECT_EQ(traits.get_options().size(), 1U);
EXPECT_STREQ(OPTIONS[0], "low");
}
TEST(SelectTraits, CopyOfItsOwnOptionsStaysValid) {
SelectTraits traits;
traits.set_options({"a", "b"});
traits.set_options(traits.get_options());
ASSERT_EQ(traits.get_options().size(), 2U);
EXPECT_STREQ(traits.get_options()[1], "b");
}
} // namespace esphome::select::testing
@@ -4,3 +4,4 @@ packages:
switch:
- platform: sendspin
name: "Sendspin Enabled"
type: enabled
+2 -1
View File
@@ -14,6 +14,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
await real_to_code(config)
cg.add_define("USE_NETWORK_IPV6", True)
# The gtests link against the filtered helper files.
socket_component.require_tcp_client_link()
socket_component.require_tcp_listener()
cg.add_define("USE_SOCKET_IPV4_ALLOW")
manifest.to_code = to_code_testing
@@ -0,0 +1,110 @@
#include <gtest/gtest.h>
#include <iterator>
#include "esphome/components/socket/ipv4_allow.h"
#include "esphome/components/socket/socket.h"
#ifdef USE_HOST
namespace esphome::socket::testing {
// The size_t count packs into the pointer's padding; no RAM over a uint8_t.
static_assert(sizeof(Ipv4Allow) == 2 * sizeof(void *), "unexpected padding in Ipv4Allow");
// 192.168.175.20/32 and 192.168.175.0/24, network order, host bits cleared,
// mirroring what add_ipv4_allow emits.
static const Ipv4AllowEntry ENTRIES[] = {
{htonl(0xC0A8AF14), htonl(0xFFFFFFFF)},
{htonl(0xC0A8AF00), htonl(0xFFFFFF00)},
};
// Runs the peer through the same parser production addresses go through.
static bool allows_peer(const Ipv4Allow &list, const char *ip) {
struct sockaddr_storage peer {};
EXPECT_NE(set_sockaddr(reinterpret_cast<struct sockaddr *>(&peer), sizeof(peer), ip, 0), 0);
return list.allows(reinterpret_cast<const struct sockaddr *>(&peer));
}
TEST(Ipv4Allow, EmptyAllowsEveryPeer) {
Ipv4Allow list;
EXPECT_TRUE(list.allows(htonl(0xC0A8AF01)));
EXPECT_TRUE(allows_peer(list, "10.0.0.1"));
EXPECT_TRUE(allows_peer(list, "fe80::1"));
}
TEST(Ipv4Allow, MatchesHostAndNetworkEntries) {
Ipv4Allow list;
list.set(ENTRIES, std::size(ENTRIES));
EXPECT_TRUE(list.allows(htonl(0xC0A8AF14)));
EXPECT_TRUE(list.allows(htonl(0xC0A8AF01)));
EXPECT_TRUE(list.allows(htonl(0xC0A8AFFF)));
EXPECT_FALSE(list.allows(htonl(0xC0A8B001)));
}
TEST(Ipv4Allow, ChecksTheV4PeerInsideASockaddr) {
Ipv4Allow list;
list.set(ENTRIES, std::size(ENTRIES));
EXPECT_TRUE(allows_peer(list, "192.168.175.66"));
EXPECT_FALSE(allows_peer(list, "10.0.0.1"));
}
TEST(Ipv4Allow, UnwrapsAV4MappedIpv6Peer) {
Ipv4Allow list;
list.set(ENTRIES, std::size(ENTRIES));
EXPECT_TRUE(allows_peer(list, "::ffff:192.168.175.66"));
// A native IPv6 peer cannot match an IPv4 list.
EXPECT_FALSE(allows_peer(list, "fe80::1"));
}
TEST(Ipv4Allow, InstancesKeepIndependentLists) {
// One bridge per allow list; each instance points at its own entries.
static const Ipv4AllowEntry OTHER[] = {{htonl(0x0A000000), htonl(0xFF000000)}};
Ipv4Allow first;
Ipv4Allow second;
first.set(ENTRIES, std::size(ENTRIES));
second.set(OTHER, std::size(OTHER));
EXPECT_TRUE(first.allows(htonl(0xC0A8AF14)));
EXPECT_FALSE(second.allows(htonl(0xC0A8AF14)));
EXPECT_TRUE(second.allows(htonl(0x0A00002A)));
EXPECT_FALSE(first.allows(htonl(0x0A00002A)));
}
TEST(Ipv4Allow, HostEntryMatchesOnlyThatAddress) {
static const Ipv4AllowEntry HOST[] = {{htonl(0xC0A8AF14), htonl(0xFFFFFFFF)}};
Ipv4Allow list;
list.set(HOST, std::size(HOST));
EXPECT_TRUE(allows_peer(list, "192.168.175.20"));
EXPECT_FALSE(allows_peer(list, "192.168.175.21"));
EXPECT_FALSE(allows_peer(list, "192.168.175.19"));
}
TEST(Ipv4Allow, CatchAllAllowsEveryV4PeerOnly) {
static const Ipv4AllowEntry ANY[] = {{0, 0}};
Ipv4Allow list;
list.set(ANY, std::size(ANY));
EXPECT_TRUE(allows_peer(list, "0.0.0.0"));
EXPECT_TRUE(allows_peer(list, "255.255.255.255"));
EXPECT_TRUE(allows_peer(list, "::ffff:10.1.2.3"));
// Unlike an empty list, 0.0.0.0/0 still turns a native IPv6 peer away.
EXPECT_FALSE(allows_peer(list, "fe80::1"));
}
TEST(Ipv4Allow, LastEntryOfAFullListMatches) {
// 255 is the schema's cap: 10.0.0.1/32 to 10.0.0.255/32.
static Ipv4AllowEntry full[255];
for (uint32_t i = 0; i < std::size(full); i++) {
full[i] = {htonl(0x0A000001 + i), htonl(0xFFFFFFFF)};
}
Ipv4Allow list;
list.set(full, std::size(full));
EXPECT_EQ(list.size(), 255u);
EXPECT_TRUE(allows_peer(list, "10.0.0.1"));
EXPECT_TRUE(allows_peer(list, "10.0.0.255"));
EXPECT_FALSE(allows_peer(list, "10.0.1.0"));
EXPECT_FALSE(allows_peer(list, "10.0.0.0"));
}
} // namespace esphome::socket::testing
#endif
@@ -0,0 +1,211 @@
#include <gtest/gtest.h>
#include <cerrno>
#include <csignal>
#include <cstring>
#include <fcntl.h>
#include <memory>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include "esphome/components/socket/tcp_client_link.h"
#include "esphome/core/application.h"
#ifdef USE_HOST
// Host only: ESP-IDF has no poll.h.
#include <poll.h>
namespace esphome::socket::testing {
class LinkPeer {
public:
LinkPeer() {
// EPIPE must come back as an errno, not a signal.
signal(SIGPIPE, SIG_IGN);
int fds[2];
EXPECT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0);
this->peer_fd_ = fds[1];
this->link_.set_host("peer");
this->link_.set_port(1);
this->link_.begin("link_test");
this->link_.adopt(std::make_unique<Socket>(fds[0]));
}
~LinkPeer() {
if (this->peer_fd_ >= 0) {
::close(this->peer_fd_);
}
this->link_.close();
}
void close_peer() {
::close(this->peer_fd_);
this->peer_fd_ = -1;
}
TcpClientLink link_;
int peer_fd_{-1};
};
// Sets the cached loop time the link's clock reads, as the main loop does.
static void set_loop_time(uint32_t now) { LoopBlockingGuard guard(nullptr, LOG_STR("test"), now); }
class LinkUnderTest : public TcpClientLink {
public:
void set_socket(std::unique_ptr<Socket> sock) { this->sock_ = std::move(sock); }
bool has_socket() const { return this->sock_ != nullptr; }
int fd() const { return this->sock_->get_fd(); }
};
class TcpClientLinkClock : public ::testing::Test {
protected:
void SetUp() override {
signal(SIGPIPE, SIG_IGN);
set_loop_time(1000);
}
void TearDown() override {
this->link_.close();
if (this->peer_fd_ >= 0) {
::close(this->peer_fd_);
}
set_loop_time(0);
}
// A connected socket whose send buffer is full selects as not writable,
// which poll_connect() reports as a connect still in progress.
void set_pending_socket() {
int fds[2];
ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0);
this->peer_fd_ = fds[1];
ASSERT_EQ(fcntl(fds[0], F_SETFL, fcntl(fds[0], F_GETFL, 0) | O_NONBLOCK), 0);
char fill[1024]{};
while (::write(fds[0], fill, sizeof(fill)) > 0) {
}
ASSERT_EQ(errno, EAGAIN);
this->link_.set_socket(std::make_unique<Socket>(fds[0]));
}
LinkUnderTest link_;
int peer_fd_{-1};
};
TEST_F(TcpClientLinkClock, PendingConnectTimesOutIntoBackoff) {
this->link_.begin("link_test");
this->set_pending_socket();
this->link_.note_attempt();
set_loop_time(1000 + 9999);
this->link_.poll();
ASSERT_TRUE(this->link_.has_socket());
EXPECT_FALSE(this->link_.connected());
set_loop_time(1000 + 10000);
this->link_.poll();
EXPECT_FALSE(this->link_.has_socket());
EXPECT_TRUE(this->link_.in_backoff());
}
TEST_F(TcpClientLinkClock, ConnectTimeoutFollowsALongerInterval) {
this->link_.set_reconnect_interval(20000);
this->link_.begin("link_test");
this->set_pending_socket();
this->link_.note_attempt();
set_loop_time(1000 + 19999);
this->link_.poll();
ASSERT_TRUE(this->link_.has_socket());
set_loop_time(1000 + 20000);
this->link_.poll();
EXPECT_FALSE(this->link_.has_socket());
}
TEST_F(TcpClientLinkClock, ResolveFailureBacksOff) {
// An IPv6 literal fails the IPv4 lookup without DNS.
this->link_.set_host("::1");
this->link_.set_port(1);
this->link_.begin("link_test");
this->link_.poll();
ASSERT_FALSE(this->link_.in_backoff());
// The failure is consumed by the next attempt and restarts the clock.
this->link_.poll();
EXPECT_FALSE(this->link_.has_socket());
EXPECT_TRUE(this->link_.in_backoff());
set_loop_time(1000 + 4999);
EXPECT_TRUE(this->link_.in_backoff());
set_loop_time(1000 + 5000);
EXPECT_FALSE(this->link_.in_backoff());
}
TEST_F(TcpClientLinkClock, RefusedConnectBacksOff) {
// A loopback port that was just free refuses the connect.
int probe = ::socket(AF_INET, SOCK_STREAM, 0);
ASSERT_GE(probe, 0);
struct sockaddr_in addr {};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
socklen_t len = sizeof(addr);
ASSERT_EQ(::bind(probe, reinterpret_cast<struct sockaddr *>(&addr), len), 0);
ASSERT_EQ(::getsockname(probe, reinterpret_cast<struct sockaddr *>(&addr), &len), 0);
::close(probe);
this->link_.set_host("127.0.0.1");
this->link_.set_port(ntohs(addr.sin_port));
this->link_.begin("link_test");
// The refusal comes back from connect() itself or from a later poll.
this->link_.poll();
if (this->link_.has_socket()) {
// Wait for the stack to finish the connect, then let poll() read the result.
struct pollfd pfd {
this->link_.fd(), POLLOUT, 0
};
ASSERT_EQ(::poll(&pfd, 1, 1000), 1);
this->link_.poll();
}
EXPECT_FALSE(this->link_.connected());
EXPECT_FALSE(this->link_.has_socket());
EXPECT_TRUE(this->link_.in_backoff());
}
TEST_F(TcpClientLinkClock, BackoffSpansAMillisWrap) {
set_loop_time(UINT32_MAX - 999);
this->link_.begin("link_test");
// begin() back-dates the clock so the first attempt is immediate.
EXPECT_FALSE(this->link_.in_backoff());
this->link_.note_attempt();
set_loop_time(3999);
EXPECT_TRUE(this->link_.in_backoff());
set_loop_time(4000);
EXPECT_FALSE(this->link_.in_backoff());
}
TEST(TcpClientLink, AdoptedSocketFlushesQueuedBytes) {
LinkPeer p;
ASSERT_TRUE(p.link_.connected());
EXPECT_EQ(p.link_.queue(reinterpret_cast<const uint8_t *>("ping"), 4), 4u);
EXPECT_TRUE(p.link_.flush_tx());
char buf[8];
EXPECT_EQ(::read(p.peer_fd_, buf, sizeof(buf)), 4);
EXPECT_EQ(std::memcmp(buf, "ping", 4), 0);
}
TEST(TcpClientLink, CloseClearsQueuedBytes) {
LinkPeer p;
EXPECT_EQ(p.link_.queue(reinterpret_cast<const uint8_t *>("MARKER"), 6), 6u);
p.link_.close();
EXPECT_FALSE(p.link_.connected());
EXPECT_EQ(p.link_.tx_free(), 0u);
// An uncleared buffer would make flush_tx() report it as still pending.
EXPECT_TRUE(p.link_.flush_tx());
}
TEST(TcpClientLink, FatalWriteInsideFlushDropsTheLink) {
LinkPeer p;
EXPECT_EQ(p.link_.queue(reinterpret_cast<const uint8_t *>("MARKER"), 6), 6u);
p.close_peer();
// Still connected from the link's point of view: the drop must happen
// inside this flush, the exact ordering TcpUart::flush() reports FAILED.
ASSERT_TRUE(p.link_.connected());
bool emptied = p.link_.flush_tx();
EXPECT_TRUE(emptied);
EXPECT_FALSE(p.link_.connected());
}
} // namespace esphome::socket::testing
#endif
@@ -0,0 +1,132 @@
#include <gtest/gtest.h>
#include <fcntl.h>
#include <memory>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include "esphome/components/socket/tcp_listener.h"
#ifdef USE_HOST
// Host only: ESP-IDF has no poll.h.
#include <poll.h>
namespace esphome::socket::testing {
class ListenerUnderTest : public TcpListener {
public:
void set_listen(std::unique_ptr<ListenSocket> sock) { this->listen_ = std::move(sock); }
bool listening() const { return this->listen_ != nullptr; }
void accept(TcpClientLink &link) { this->accept_(link); }
};
class TcpListenerAccept : public ::testing::Test {
protected:
void SetUp() override {
this->link_.set_port(1);
this->link_.begin("listener_test");
this->listener_.begin("listener_test");
}
void TearDown() override {
this->listener_.close();
this->link_.close();
if (this->client_fd_ >= 0) {
::close(this->client_fd_);
}
}
// A non-blocking IPv4 listener on a free loopback port; returns the port.
uint16_t listen_on_loopback() {
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
EXPECT_GE(fd, 0);
struct sockaddr_in addr {};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
socklen_t len = sizeof(addr);
EXPECT_EQ(::bind(fd, reinterpret_cast<struct sockaddr *>(&addr), len), 0);
EXPECT_EQ(::listen(fd, 1), 0);
EXPECT_EQ(::getsockname(fd, reinterpret_cast<struct sockaddr *>(&addr), &len), 0);
EXPECT_EQ(fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK), 0);
this->listen_fd_ = fd;
this->listener_.set_listen(std::make_unique<ListenSocket>(fd));
return ntohs(addr.sin_port);
}
// A blocking connect to the loopback listener; it completes into the backlog.
void connect_client(uint16_t port) {
this->client_fd_ = ::socket(AF_INET, SOCK_STREAM, 0);
ASSERT_GE(this->client_fd_, 0);
struct sockaddr_in addr {};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = htons(port);
ASSERT_EQ(::connect(this->client_fd_, reinterpret_cast<struct sockaddr *>(&addr), sizeof(addr)), 0);
// The stack may queue the connection for accept() a moment after connect() returns.
struct pollfd pfd {
this->listen_fd_, POLLIN, 0
};
ASSERT_EQ(::poll(&pfd, 1, 1000), 1);
}
TcpClientLink link_;
ListenerUnderTest listener_;
int listen_fd_{-1};
int client_fd_{-1};
};
TEST_F(TcpListenerAccept, AcceptErrorRebuildsAfterTheBackoff) {
// accept() on a bad descriptor fails with EBADF, which no retry fixes.
this->listener_.set_listen(std::make_unique<ListenSocket>(-1));
this->listener_.accept(this->link_);
EXPECT_FALSE(this->listener_.listening());
EXPECT_TRUE(this->link_.in_backoff());
// No new listen socket until the backoff has passed.
this->listener_.poll(this->link_, true);
EXPECT_FALSE(this->listener_.listening());
}
TEST_F(TcpListenerAccept, NothingPendingKeepsTheListener) {
this->listen_on_loopback();
this->listener_.accept(this->link_);
EXPECT_TRUE(this->listener_.listening());
EXPECT_FALSE(this->link_.connected());
EXPECT_FALSE(this->link_.in_backoff());
}
TEST_F(TcpListenerAccept, AcceptedClientIsAdopted) {
this->connect_client(this->listen_on_loopback());
this->listener_.accept(this->link_);
EXPECT_TRUE(this->link_.connected());
EXPECT_TRUE(this->listener_.listening());
}
#ifdef USE_SOCKET_IPV4_ALLOW
TEST_F(TcpListenerAccept, PeerOutsideTheAllowListIsClosed) {
static const Ipv4AllowEntry ONLY_TEN[] = {{htonl(0x0A000000), htonl(0xFF000000)}};
this->listener_.set_allow(ONLY_TEN, 1);
this->connect_client(this->listen_on_loopback());
this->listener_.accept(this->link_);
EXPECT_FALSE(this->link_.connected());
EXPECT_TRUE(this->listener_.listening());
EXPECT_FALSE(this->link_.in_backoff());
// The peer sees the close.
struct pollfd pfd {
this->client_fd_, POLLIN, 0
};
ASSERT_EQ(::poll(&pfd, 1, 1000), 1);
char b;
EXPECT_EQ(::read(this->client_fd_, &b, 1), 0);
}
TEST_F(TcpListenerAccept, PeerInsideTheAllowListIsAdopted) {
static const Ipv4AllowEntry LOOPBACK_ONLY[] = {{htonl(INADDR_LOOPBACK), htonl(0xFFFFFFFF)}};
this->listener_.set_allow(LOOPBACK_ONLY, 1);
this->connect_client(this->listen_on_loopback());
this->listener_.accept(this->link_);
EXPECT_TRUE(this->link_.connected());
}
#endif
} // namespace esphome::socket::testing
#endif
+15 -2
View File
@@ -12,10 +12,9 @@ audio_dac:
volume_max_db: 0dB
update_interval: 1s
- platform: tas58xx
model: TAS5805M
model: TAS5825M
id: tas58xx_amp_2
i2c_id: i2c_bus
address: 0x2C
ignore_enable_pin_warning: true
esphome:
@@ -59,3 +58,17 @@ binary_sensor:
name: Second Amp Any Fault
left_channel_dc_fault:
name: Second Amp Left Channel DC Fault
left_channel_cbc_over_current:
name: Left Channel CBC Current Fault
right_channel_cbc_over_current:
name: Right Channel CBC Current Fault
over_temp_shutdown:
name: Over Temperature Shutdown Fault
left_channel_cbc_over_current_warning:
name: Left Channel CBC Current Warning
right_channel_cbc_over_current_warning:
name: Right Channel CBC Current Warning
over_temp_146c_warning:
name: Over Temperature 146C Warning
over_temp_warning:
name: Over Temperature 134C Warning
+15
View File
@@ -0,0 +1,15 @@
# This file's presence makes pytest treat this directory as a package named
# "tcp_uart"; required for cpp unit testing.
from esphome.components import socket as socket_component
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.multi_conf = False
async def to_code_testing(config: ConfigType) -> None:
# The server-role gtest needs the listener.
socket_component.require_tcp_listener()
manifest.to_code = to_code_testing
+3
View File
@@ -0,0 +1,3 @@
# The disconnects counter is compiled only with USE_SENSOR; declaring the
# sensor domain makes the C++ unit test build define it.
sensor:
+11 -6
View File
@@ -3,18 +3,23 @@ wifi:
password: password1
tcp_uart:
- id: tcp_uart_1
host: 192.0.2.10
port: 502
- id: tcp_uart_server
role: server
port: 5020
reconnect_interval: 10s
allowed_ips:
- 192.0.2.20
- 192.0.2.0/24
connected:
name: TCP UART Connected
name: TCP UART Server Connected
disconnects:
name: TCP UART Server Disconnects
interval:
- interval: 60s
then:
- lambda: |-
uint8_t byte;
if (id(tcp_uart_1).available() && id(tcp_uart_1).read_byte(&byte)) {
id(tcp_uart_1).write_byte(byte);
if (id(tcp_uart_server).available() && id(tcp_uart_server).read_byte(&byte)) {
id(tcp_uart_server).write_byte(byte);
}
@@ -0,0 +1,13 @@
wifi:
ssid: MySSID
password: password1
tcp_uart:
- id: tcp_uart_1
host: 192.0.2.10
port: 502
reconnect_interval: 10s
connected:
name: TCP UART Connected
disconnects:
name: TCP UART Disconnects
@@ -1,2 +1,10 @@
packages:
tcp_uart: !include common.yaml
# load_settings() exists on ESP8266 and ESP32 only; both overloads must resolve.
interval:
- interval: 60s
then:
- lambda: |-
id(tcp_uart_server).load_settings();
id(tcp_uart_server).load_settings(false);
@@ -1,2 +1,10 @@
packages:
tcp_uart: !include common.yaml
# load_settings() exists on ESP8266 and ESP32 only; both overloads must resolve.
interval:
- interval: 60s
then:
- lambda: |-
id(tcp_uart_server).load_settings();
id(tcp_uart_server).load_settings(false);
@@ -0,0 +1,234 @@
#include <gtest/gtest.h>
#include <arpa/inet.h>
#include <csignal>
#include <cstring>
#include <memory>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include "esphome/components/tcp_uart/tcp_uart.h"
#include "esphome/core/application.h"
#include "esphome/core/wake.h"
#ifdef USE_HOST
namespace esphome::tcp_uart::testing {
class TcpUartLoopDriver : public TcpUart {
public:
TcpUartLoopDriver() {
this->set_host("peer");
this->set_port(1);
this->link_.begin("buffers_test");
}
socket::TcpClientLink &link() { return this->link_; }
};
class TcpUartBuffers : public ::testing::Test {
protected:
void SetUp() override {
// EPIPE must come back as an errno, not a signal.
signal(SIGPIPE, SIG_IGN);
this->connect_peer();
}
void TearDown() override {
this->close_peer();
this->uart_.link().close();
}
// Hands the UART a fresh session, as an accept or a reconnect would.
void connect_peer() {
int fds[2];
ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0);
this->peer_fd_ = fds[1];
this->uart_.link().adopt(std::make_unique<socket::Socket>(fds[0]));
}
void close_peer() {
if (this->peer_fd_ >= 0) {
::close(this->peer_fd_);
this->peer_fd_ = -1;
}
}
void send(const void *data, size_t len) { ASSERT_EQ(::write(this->peer_fd_, data, len), static_cast<ssize_t>(len)); }
void loops(int count) {
for (int i = 0; i < count; i++) {
this->uart_.loop();
}
}
TcpUartLoopDriver uart_;
int peer_fd_{-1};
};
TEST_F(TcpUartBuffers, BytesReceivedBeforeACloseStayReadable) {
this->loops(1);
this->send("HELLO-0123456789", 16);
this->close_peer();
// Read the bytes, see the close, run the down edge, then idle.
this->loops(4);
ASSERT_FALSE(this->uart_.is_connected());
ASSERT_EQ(this->uart_.available(), 16u);
uint8_t got[16];
ASSERT_TRUE(this->uart_.read_array(got, sizeof(got)));
EXPECT_EQ(std::memcmp(got, "HELLO-0123456789", sizeof(got)), 0);
EXPECT_EQ(this->uart_.available(), 0u);
}
TEST_F(TcpUartBuffers, UnreadBytesAreGoneWhenTheNextSessionStarts) {
this->loops(1);
this->send("OLD", 3);
this->close_peer();
this->loops(4);
ASSERT_FALSE(this->uart_.is_connected());
this->connect_peer();
this->send("NEW", 3);
this->loops(1);
ASSERT_EQ(this->uart_.available(), 3u);
uint8_t got[3];
ASSERT_TRUE(this->uart_.read_array(got, sizeof(got)));
EXPECT_EQ(std::memcmp(got, "NEW", sizeof(got)), 0);
}
TEST_F(TcpUartBuffers, FullBufferWaitsAndCompactsAfterARead) {
static constexpr size_t TOTAL = 1500;
uint8_t data[TOTAL];
for (size_t i = 0; i < TOTAL; i++) {
data[i] = static_cast<uint8_t>(i % 251);
}
this->loops(1);
this->send(data, TOTAL);
this->loops(1);
ASSERT_EQ(this->uart_.available(), 1024u);
// Nothing read, no room: the rest stays in the socket.
this->loops(2);
ASSERT_EQ(this->uart_.available(), 1024u);
uint8_t got[TOTAL];
ASSERT_TRUE(this->uart_.read_array(got, 100));
// The read freed the front; the next pass moves the rest down and refills.
this->loops(1);
ASSERT_EQ(this->uart_.available(), 1024u);
ASSERT_TRUE(this->uart_.read_array(got + 100, 1024));
this->loops(1);
ASSERT_EQ(this->uart_.available(), TOTAL - 1124);
ASSERT_TRUE(this->uart_.read_array(got + 1124, TOTAL - 1124));
EXPECT_EQ(std::memcmp(got, data, TOTAL), 0);
EXPECT_TRUE(this->uart_.is_connected());
}
TEST_F(TcpUartBuffers, WritesBeyondTheSendBufferAreDropped) {
static constexpr size_t TOTAL = 1500;
uint8_t data[TOTAL];
for (size_t i = 0; i < TOTAL; i++) {
data[i] = static_cast<uint8_t>(i % 251);
}
this->loops(1);
this->uart_.write_array(data, TOTAL);
EXPECT_EQ(this->uart_.available_for_write(), 0u);
EXPECT_EQ(this->uart_.flush(), uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS);
uint8_t got[TOTAL];
size_t total = 0;
while (total < 1024) {
ssize_t n = ::read(this->peer_fd_, got + total, sizeof(got) - total);
ASSERT_GT(n, 0);
total += static_cast<size_t>(n);
}
EXPECT_EQ(total, 1024u);
EXPECT_EQ(std::memcmp(got, data, 1024), 0);
// The 476 bytes past the buffer never reach the peer.
EXPECT_EQ(::recv(this->peer_fd_, got, sizeof(got), MSG_DONTWAIT), -1);
}
#ifdef USE_SOCKET_TCP_LISTENER
// Server role through the real listener on a loopback port.
class TcpUartServer : public ::testing::Test {
protected:
void SetUp() override {
signal(SIGPIPE, SIG_IGN);
// Find a free port for the listener.
int probe = ::socket(AF_INET, SOCK_STREAM, 0);
ASSERT_GE(probe, 0);
struct sockaddr_in addr = loopback(0);
ASSERT_EQ(::bind(probe, reinterpret_cast<struct sockaddr *>(&addr), sizeof(addr)), 0);
socklen_t len = sizeof(addr);
ASSERT_EQ(::getsockname(probe, reinterpret_cast<struct sockaddr *>(&addr), &len), 0);
::close(probe);
this->port_ = ntohs(addr.sin_port);
this->uart_.set_server(true);
this->uart_.set_port(this->port_);
this->uart_.set_reconnect_interval(0);
this->uart_.setup();
this->pass();
}
void TearDown() override {
this->close_peer();
this->uart_.on_shutdown();
}
static struct sockaddr_in loopback(uint16_t port) {
struct sockaddr_in addr {};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = htons(port);
return addr;
}
// One main loop pass: select() marks readable sockets, then the component runs.
void pass() {
internal::wakeable_delay(5);
this->now_ += 16;
LoopBlockingGuard dispatch{nullptr, nullptr, this->now_};
this->uart_.loop();
}
void connect_peer() {
this->peer_fd_ = ::socket(AF_INET, SOCK_STREAM, 0);
ASSERT_GE(this->peer_fd_, 0);
struct sockaddr_in addr = loopback(this->port_);
ASSERT_EQ(::connect(this->peer_fd_, reinterpret_cast<struct sockaddr *>(&addr), sizeof(addr)), 0);
for (int i = 0; i < 50 && !this->uart_.is_connected(); i++)
this->pass();
ASSERT_TRUE(this->uart_.is_connected());
}
void close_peer() {
if (this->peer_fd_ >= 0) {
::close(this->peer_fd_);
this->peer_fd_ = -1;
}
}
void send(const void *data, size_t len) { ASSERT_EQ(::write(this->peer_fd_, data, len), static_cast<ssize_t>(len)); }
void pass_until_available(size_t count) {
for (int i = 0; i < 50 && this->uart_.available() < count; i++)
this->pass();
}
TcpUart uart_;
uint16_t port_{0};
int peer_fd_{-1};
uint32_t now_{0};
};
TEST_F(TcpUartServer, NextAcceptedClientStartsWithAnEmptyBuffer) {
this->connect_peer();
this->send("OLD", 3);
this->pass_until_available(3);
ASSERT_EQ(this->uart_.available(), 3u);
this->close_peer();
for (int i = 0; i < 50 && this->uart_.is_connected(); i++)
this->pass();
ASSERT_FALSE(this->uart_.is_connected());
// Unread bytes stay readable while no client is connected.
EXPECT_EQ(this->uart_.available(), 3u);
this->connect_peer();
this->send("NEW", 3);
this->pass_until_available(3);
ASSERT_EQ(this->uart_.available(), 3u);
uint8_t got[3];
ASSERT_TRUE(this->uart_.read_array(got, sizeof(got)));
EXPECT_EQ(std::memcmp(got, "NEW", sizeof(got)), 0);
}
#endif
} // namespace esphome::tcp_uart::testing
#endif
@@ -0,0 +1,100 @@
#include <gtest/gtest.h>
#include <cmath>
#include <csignal>
#include <memory>
#include <sys/socket.h>
#include <unistd.h>
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/tcp_uart/tcp_uart.h"
#ifdef USE_HOST
namespace esphome::tcp_uart::testing {
class TcpUartDisconnectUnderTest : public TcpUart {
public:
TcpUartDisconnectUnderTest() {
this->set_host("peer");
this->set_port(1);
this->link_.begin("disconnect_test");
}
socket::TcpClientLink &link() { return this->link_; }
};
class TcpUartDisconnect : public ::testing::Test {
protected:
void SetUp() override {
signal(SIGPIPE, SIG_IGN);
this->uart_.set_disconnects_sensor(&this->disconnects_);
this->connect_peer();
}
void TearDown() override {
this->close_peer();
this->uart_.link().close();
}
// Hands the UART a fresh session, as an accept or a reconnect would.
void connect_peer() {
int fds[2];
ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0);
this->peer_fd_ = fds[1];
this->uart_.link().adopt(std::make_unique<socket::Socket>(fds[0]));
}
void close_peer() {
if (this->peer_fd_ >= 0) {
::close(this->peer_fd_);
this->peer_fd_ = -1;
}
}
void loops(int count) {
for (int i = 0; i < count; i++) {
this->uart_.loop();
}
}
TcpUartDisconnectUnderTest uart_;
sensor::Sensor disconnects_;
int peer_fd_{-1};
};
TEST_F(TcpUartDisconnect, CountsTheFallingEdgeOnce) {
this->uart_.loop();
EXPECT_TRUE(this->uart_.is_connected());
EXPECT_TRUE(std::isnan(this->disconnects_.state));
this->uart_.link().close();
this->uart_.loop();
EXPECT_FALSE(this->uart_.is_connected());
EXPECT_FLOAT_EQ(this->disconnects_.state, 1);
this->uart_.loop();
EXPECT_FLOAT_EQ(this->disconnects_.state, 1);
}
TEST_F(TcpUartDisconnect, CountsACloseByThePeer) {
this->loops(1);
this->close_peer();
// Read the EOF, then run the down edge, then idle.
this->loops(3);
EXPECT_FALSE(this->uart_.is_connected());
EXPECT_FLOAT_EQ(this->disconnects_.state, 1);
}
TEST_F(TcpUartDisconnect, CountsEverySessionAfterAReconnect) {
for (int session = 1; session <= 3; session++) {
if (session > 1) {
this->connect_peer();
}
this->loops(1);
ASSERT_TRUE(this->uart_.is_connected());
this->close_peer();
this->loops(3);
ASSERT_FALSE(this->uart_.is_connected());
EXPECT_FLOAT_EQ(this->disconnects_.state, session);
}
}
} // namespace esphome::tcp_uart::testing
#endif
@@ -0,0 +1,73 @@
#include <gtest/gtest.h>
#include <csignal>
#include <memory>
#include <sys/socket.h>
#include <unistd.h>
#include "esphome/components/tcp_uart/tcp_uart.h"
#ifdef USE_HOST
namespace esphome::tcp_uart::testing {
class TcpUartUnderTest : public TcpUart {
public:
TcpUartUnderTest() {
this->set_host("peer");
this->set_port(1);
this->link_.begin("flush_test");
}
socket::TcpClientLink &link() { return this->link_; }
};
class TcpUartFlush : public ::testing::Test {
protected:
void SetUp() override {
// EPIPE must come back as an errno, not a signal.
signal(SIGPIPE, SIG_IGN);
int fds[2];
ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0);
this->peer_fd_ = fds[1];
this->uart_.link().adopt(std::make_unique<socket::Socket>(fds[0]));
}
void TearDown() override {
if (this->peer_fd_ >= 0) {
::close(this->peer_fd_);
}
this->uart_.link().close();
}
TcpUartUnderTest uart_;
int peer_fd_{-1};
};
TEST_F(TcpUartFlush, SuccessWhenTheByteGoesOut) {
uint8_t b = 'x';
this->uart_.write_array(&b, 1);
EXPECT_EQ(this->uart_.flush(), uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS);
char got;
EXPECT_EQ(::read(this->peer_fd_, &got, 1), 1);
EXPECT_EQ(got, 'x');
}
TEST_F(TcpUartFlush, FailedWhenTheFlushDropsTheLink) {
uint8_t b = 'x';
this->uart_.write_array(&b, 1);
::close(this->peer_fd_);
this->peer_fd_ = -1;
// The drop happens inside this flush; checking connected() first would
// wrongly report success.
ASSERT_TRUE(this->uart_.is_connected());
EXPECT_EQ(this->uart_.flush(), uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED);
EXPECT_FALSE(this->uart_.is_connected());
}
TEST_F(TcpUartFlush, FailedWhileTheLinkIsDown) {
this->uart_.link().close();
EXPECT_EQ(this->uart_.flush(), uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED);
}
} // namespace esphome::tcp_uart::testing
#endif
@@ -275,6 +275,28 @@ button:
name: "Template Button"
on_press:
- logger.log: Button Pressed
- platform: template
name: "Climate Custom Modes Lambdas"
on_press:
- lambda: |-
// How external components and configs read and set climate custom modes
auto traits = id(template_climate_custom_modes).get_traits();
const auto &fan_modes = traits.get_supported_custom_fan_modes();
for (const auto *mode : fan_modes)
ESP_LOGD("test", "%s", mode);
for (const auto &preset : traits.get_supported_custom_presets())
ESP_LOGD("test", "%s", preset);
for (const char *mode : traits.get_supported_custom_fan_modes())
ESP_LOGD("test", "%s", mode);
std::vector<const char *> presets{"eco_plus", "max"};
id(template_climate_custom_modes).set_supported_custom_presets(presets);
id(template_climate_custom_modes).set_supported_custom_presets({"eco_plus"});
id(template_climate_custom_modes).set_supported_custom_fan_modes({});
std::vector<const char *> new_fan_modes{"turbo", "silent"};
id(template_climate_custom_modes).set_supported_custom_fan_modes(new_fan_modes);
static const char *const MODES[] = {"turbo", "silent"};
id(template_climate_custom_modes).set_supported_custom_fan_modes(MODES);
id(template_climate_custom_modes).set_supported_custom_presets(MODES);
cover:
- platform: template
@@ -0,0 +1,14 @@
uart:
- id: uart_id
tx_pin: PA2
rx_pin: PA3
baud_rate: 9600
data_bits: 8
parity: NONE
stop_bits: 1
switch:
- platform: uart
name: "UART Switch"
uart_id: uart_id
data: [0x01, 0x02, 0x03]
+7
View File
@@ -0,0 +1,7 @@
# This file's presence makes pytest treat this directory as a package named
# "uart_tcp"; required for cpp unit testing.
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
manifest.multi_conf = False
+3
View File
@@ -0,0 +1,3 @@
# The disconnects counter is compiled only with USE_SENSOR; declaring the
# sensor domain makes the C++ unit test build define it.
sensor:
+16
View File
@@ -0,0 +1,16 @@
wifi:
ssid: MySSID
password: password1
uart_tcp:
- id: bridge
uart_id: uart_bus
role: server
port: 502
allowed_ips:
- 192.168.1.10
- 192.168.1.0/24
connected:
name: UART TCP Connected
disconnects:
name: UART TCP Disconnects
@@ -0,0 +1,18 @@
packages:
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
wifi:
ssid: MySSID
password: password1
uart_tcp:
- id: bridge
uart_id: uart_bus
role: client
host: 192.0.2.20
port: 502
reconnect_interval: 10s
connected:
name: UART TCP Connected
disconnects:
name: UART TCP Disconnects
@@ -0,0 +1,3 @@
packages:
uart: !include ../../test_build_components/common/uart/bk72xx-ard.yaml
uart_tcp: !include common.yaml
@@ -0,0 +1,3 @@
packages:
uart: !include ../../test_build_components/common/uart/esp32-idf.yaml
uart_tcp: !include common.yaml
@@ -0,0 +1,3 @@
packages:
uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml
uart_tcp: !include common.yaml
+14
View File
@@ -0,0 +1,14 @@
uart:
- id: uart_bus
baud_rate: 9600
port: /dev/ttyS0
uart_tcp:
- id: bridge
uart_id: uart_bus
host: 127.0.0.1
port: 44502
connected:
name: UART TCP Connected
disconnects:
name: UART TCP Disconnects
@@ -0,0 +1,3 @@
packages:
uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml
uart_tcp: !include common.yaml
@@ -0,0 +1,123 @@
#include <gtest/gtest.h>
#include <arpa/inet.h>
#include <cmath>
#include <csignal>
#include <cstdint>
#include <fcntl.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include "esphome/components/binary_sensor/binary_sensor.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/uart_tcp/uart_tcp.h"
#include "esphome/core/application.h"
#include "esphome/core/wake.h"
#ifdef USE_HOST
namespace esphome::uart_tcp::testing {
// An idle UART: nothing to read, unknown free TX space.
class IdleUart : public uart::UARTComponent {
public:
IdleUart() { this->set_baud_rate(9600); }
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 len == 0; }
size_t available() override { return 0; }
size_t available_for_write() override { return SIZE_MAX; }
uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; }
void check_logger_conflict() override {}
};
// Client role against a loopback listener the test owns.
class UartTcpDisconnect : public ::testing::Test {
protected:
void SetUp() override {
signal(SIGPIPE, SIG_IGN);
this->listen_fd_ = ::socket(AF_INET, SOCK_STREAM, 0);
ASSERT_GE(this->listen_fd_, 0);
struct sockaddr_in addr {};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
ASSERT_EQ(::bind(this->listen_fd_, reinterpret_cast<struct sockaddr *>(&addr), sizeof(addr)), 0);
ASSERT_EQ(::listen(this->listen_fd_, 1), 0);
ASSERT_EQ(::fcntl(this->listen_fd_, F_SETFL, O_NONBLOCK), 0);
socklen_t len = sizeof(addr);
ASSERT_EQ(::getsockname(this->listen_fd_, reinterpret_cast<struct sockaddr *>(&addr), &len), 0);
this->bridge_.set_uart_parent(&this->uart_);
this->bridge_.set_host("127.0.0.1");
this->bridge_.set_port(ntohs(addr.sin_port));
// A zero interval lets a dropped link retry on the next pass.
this->bridge_.set_reconnect_interval(0);
this->bridge_.set_connected_sensor(&this->connected_);
this->bridge_.set_disconnects_sensor(&this->disconnects_);
this->tick();
this->bridge_.setup();
}
void TearDown() override {
this->bridge_.on_shutdown();
this->close_peer();
::close(this->listen_fd_);
}
// Publish the next loop start time, as Application::loop() does.
void tick() {
this->now_ += 16;
LoopBlockingGuard dispatch{nullptr, nullptr, this->now_};
}
// One main loop pass: select() marks readable sockets, then the component runs.
void pass() {
internal::wakeable_delay(5);
this->tick();
this->bridge_.loop();
}
void connect() {
for (int i = 0; i < 50 && this->peer_fd_ < 0; i++) {
this->pass();
this->peer_fd_ = ::accept(this->listen_fd_, nullptr, nullptr);
}
ASSERT_GE(this->peer_fd_, 0);
for (int i = 0; i < 50 && !this->connected_.state; i++)
this->pass();
ASSERT_TRUE(this->connected_.state);
}
// The peer closes; pass until the bridge has run its down edge.
void peer_closes() {
this->close_peer();
for (int i = 0; i < 50 && this->connected_.state; i++)
this->pass();
ASSERT_FALSE(this->connected_.state);
}
void close_peer() {
if (this->peer_fd_ >= 0) {
::close(this->peer_fd_);
this->peer_fd_ = -1;
}
}
IdleUart uart_;
UartTcp bridge_;
binary_sensor::BinarySensor connected_;
sensor::Sensor disconnects_;
int listen_fd_{-1};
int peer_fd_{-1};
uint32_t now_{0};
};
TEST_F(UartTcpDisconnect, StartsAtZero) { EXPECT_FLOAT_EQ(this->disconnects_.state, 0); }
TEST_F(UartTcpDisconnect, CountsEveryCloseByThePeerAcrossReconnects) {
for (int session = 1; session <= 3; session++) {
this->connect();
this->peer_closes();
EXPECT_FLOAT_EQ(this->disconnects_.state, session);
}
}
} // namespace esphome::uart_tcp::testing
#endif
@@ -0,0 +1,275 @@
#include <gtest/gtest.h>
#include <arpa/inet.h>
#include <cerrno>
#include <csignal>
#include <cstdint>
#include <fcntl.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <vector>
#include "esphome/components/uart_tcp/uart_tcp.h"
#include "esphome/core/application.h"
#include "esphome/core/wake.h"
#ifdef USE_HOST
namespace esphome::uart_tcp::testing {
// A UART the test fills and drains directly; available_for_write() is settable.
class FakeUart : public uart::UARTComponent {
public:
FakeUart() { this->set_baud_rate(9600); }
void write_array(const uint8_t *data, size_t len) override {
this->tx.insert(this->tx.end(), data, data + len);
this->writes.push_back(len);
}
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;
std::copy(this->rx.begin(), this->rx.begin() + len, data);
this->rx.erase(this->rx.begin(), this->rx.begin() + len);
return true;
}
size_t available() override { return this->rx.size(); }
size_t available_for_write() override { return this->room; }
uart::UARTFlushResult flush() override { return uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS; }
void check_logger_conflict() override {}
void feed(const char *text) {
for (const char *p = text; *p != '\0'; p++)
this->rx.push_back(static_cast<uint8_t>(*p));
}
std::vector<uint8_t> rx;
std::vector<uint8_t> tx;
std::vector<size_t> writes;
size_t room{SIZE_MAX};
};
// Client role against a loopback listener the test owns.
class UartTcpClient : public ::testing::Test {
protected:
void SetUp() override {
// EPIPE must come back as an errno, not a signal.
signal(SIGPIPE, SIG_IGN);
App.set_loop_interval(16);
this->listen_fd_ = ::socket(AF_INET, SOCK_STREAM, 0);
ASSERT_GE(this->listen_fd_, 0);
struct sockaddr_in addr {};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
ASSERT_EQ(::bind(this->listen_fd_, reinterpret_cast<struct sockaddr *>(&addr), sizeof(addr)), 0);
ASSERT_EQ(::listen(this->listen_fd_, 1), 0);
ASSERT_EQ(::fcntl(this->listen_fd_, F_SETFL, O_NONBLOCK), 0);
socklen_t len = sizeof(addr);
ASSERT_EQ(::getsockname(this->listen_fd_, reinterpret_cast<struct sockaddr *>(&addr), &len), 0);
this->bridge_.set_uart_parent(&this->uart_);
this->bridge_.set_host("127.0.0.1");
this->bridge_.set_port(ntohs(addr.sin_port));
// A zero interval lets a dropped link retry on the next pass.
this->bridge_.set_reconnect_interval(0);
this->bridge_.set_connected_sensor(&this->sensor_);
this->tick(0);
this->bridge_.setup();
}
void TearDown() override {
this->bridge_.on_shutdown();
this->close_peer();
::close(this->listen_fd_);
App.set_loop_interval(16);
}
// Advance the test clock and publish it as the loop start time, as Application::loop() does.
void tick(uint32_t elapsed_ms) {
this->now_ += elapsed_ms;
LoopBlockingGuard dispatch{nullptr, nullptr, this->now_};
}
// One main loop pass that started elapsed_ms after the previous one: select()
// marks readable sockets, then the component runs.
void pass(uint32_t elapsed_ms = 16) {
internal::wakeable_delay(5);
this->tick(elapsed_ms);
this->bridge_.loop();
}
// Pass until the bridge connected and the test accepted it.
void connect() {
for (int i = 0; i < 50 && this->peer_fd_ < 0; i++) {
this->pass();
this->peer_fd_ = ::accept(this->listen_fd_, nullptr, nullptr);
}
ASSERT_GE(this->peer_fd_, 0);
for (int i = 0; i < 50 && !this->sensor_.state; i++)
this->pass();
ASSERT_TRUE(this->sensor_.state);
}
void close_peer() {
if (this->peer_fd_ >= 0) {
::close(this->peer_fd_);
this->peer_fd_ = -1;
}
}
void send(size_t count) {
std::vector<uint8_t> data(count);
for (size_t i = 0; i < count; i++)
data[i] = static_cast<uint8_t>(i);
ASSERT_EQ(::write(this->peer_fd_, data.data(), count), static_cast<ssize_t>(count));
}
std::string receive(size_t count) {
std::string out;
for (int i = 0; i < 50 && out.size() < count; i++) {
this->pass();
char buf[64];
ssize_t n = ::recv(this->peer_fd_, buf, sizeof(buf), MSG_DONTWAIT);
if (n > 0)
out.append(buf, static_cast<size_t>(n));
}
return out;
}
FakeUart uart_;
UartTcp bridge_;
binary_sensor::BinarySensor sensor_;
int listen_fd_{-1};
int peer_fd_{-1};
uint32_t now_{100000};
};
TEST_F(UartTcpClient, CopiesBothWays) {
this->connect();
this->uart_.feed("up");
EXPECT_EQ(this->receive(2), "up");
ASSERT_EQ(::write(this->peer_fd_, "down", 4), 4);
for (int i = 0; i < 50 && this->uart_.tx.size() < 4; i++)
this->pass();
EXPECT_EQ(std::string(this->uart_.tx.begin(), this->uart_.tx.end()), "down");
}
TEST_F(UartTcpClient, DiscardsStaleUartBytesOnConnect) {
// More than one discard chunk, so the drain loop runs several times.
for (int i = 0; i < 100; i++)
this->uart_.rx.push_back('s');
this->connect();
EXPECT_TRUE(this->uart_.rx.empty());
this->uart_.feed("live");
EXPECT_EQ(this->receive(4), "live");
}
TEST_F(UartTcpClient, ReconnectsAndDropsBytesFromTheGap) {
this->connect();
this->close_peer();
for (int i = 0; i < 50 && this->sensor_.state; i++)
this->pass();
ASSERT_FALSE(this->sensor_.state);
this->uart_.feed("gap");
this->connect();
this->uart_.feed("new");
EXPECT_EQ(this->receive(3), "new");
}
TEST_F(UartTcpClient, PacesToTheDefaultLoopInterval) {
this->connect();
this->send(100);
this->pass();
this->pass();
// 9600 baud at 10 bits per byte for 16 ms.
EXPECT_EQ(this->uart_.writes, (std::vector<size_t>{15, 15}));
}
TEST_F(UartTcpClient, PacesAnEarlyPassToTheTimeSinceTheLastWrite) {
App.set_loop_interval(100);
this->connect();
this->send(200);
this->pass(100);
// A socket wake 5 ms later gets 5 ms of UART time, not a full interval.
this->pass(5);
this->pass(100);
EXPECT_EQ(this->uart_.writes, (std::vector<size_t>{96, 4, 96}));
}
TEST_F(UartTcpClient, CapsALongGapAtOneLoopInterval) {
App.set_loop_interval(100);
this->connect();
this->send(250);
// A 1000 ms gap gets one interval (96 bytes), a 50 ms pass gets 50 ms.
this->pass(1000);
this->pass(50);
this->pass(1000);
EXPECT_EQ(this->uart_.writes, (std::vector<size_t>{96, 48, 96}));
}
TEST_F(UartTcpClient, CapsTheSpanAtFourSeconds) {
App.set_loop_interval(10000);
this->uart_.set_baud_rate(300);
this->connect();
this->send(200);
// 300 baud is 30 bytes/s: a 6000 ms gap gets 4 s, a 1000 ms pass gets 1 s.
this->pass(6000);
this->pass(1000);
EXPECT_EQ(this->uart_.writes, (std::vector<size_t>{120, 30}));
}
TEST_F(UartTcpClient, PacesEachBaudRate) {
this->connect();
this->send(1000);
// 16 ms and 1 ms passes; a write is at most one 128-byte read chunk.
for (uint32_t baud : {9600, 115200, 921600}) {
this->uart_.set_baud_rate(baud);
this->pass(16);
this->pass(1);
}
EXPECT_EQ(this->uart_.writes, (std::vector<size_t>{15, 1, 128, 11, 128, 92}));
}
TEST_F(UartTcpClient, DoesNotOverflowAtAHighBaudRate) {
App.set_loop_interval(10000);
// baud * 4000 wraps a 32-bit product to 3520 at this rate.
this->uart_.set_baud_rate(5368710);
this->connect();
this->send(200);
this->pass(6000);
EXPECT_EQ(this->uart_.writes, (std::vector<size_t>{128}));
}
TEST_F(UartTcpClient, WritesAtLeastOneBytePerPass) {
this->connect();
this->send(100);
this->pass();
// Less than one byte of UART time since the last write still moves a byte.
this->pass(0);
this->pass(1);
EXPECT_EQ(this->uart_.writes, (std::vector<size_t>{15, 1, 1}));
}
TEST_F(UartTcpClient, FullUartHoldsSocketBytesUntilThereIsRoom) {
this->connect();
this->uart_.room = 0;
this->send(40);
for (int i = 0; i < 5; i++)
this->pass();
EXPECT_TRUE(this->uart_.tx.empty());
EXPECT_TRUE(this->sensor_.state);
this->uart_.room = 16;
this->pass();
ASSERT_EQ(this->uart_.writes.size(), 1u);
EXPECT_EQ(this->uart_.writes.front(), 16u);
this->uart_.room = SIZE_MAX;
for (int i = 0; i < 50 && this->uart_.tx.size() < 40; i++)
this->pass();
ASSERT_EQ(this->uart_.tx.size(), 40u);
for (size_t i = 0; i < 40; i++)
EXPECT_EQ(this->uart_.tx[i], static_cast<uint8_t>(i));
}
} // namespace esphome::uart_tcp::testing
#endif
@@ -0,0 +1,9 @@
wifi:
networks:
- ssid: MySSID
password: password1
ap:
ssid: Fallback Hotspot
password: password2
captive_portal:
+4
View File
@@ -14,9 +14,12 @@ sensor:
name: "Analog 1"
lambda: return 10.0;
accuracy_decimals: 0
device_class: temperature
unit_of_measurement: "°C"
- platform: template
name: "Analog 2"
lambda: return 11.0;
unit_of_measurement: "°C"
- platform: template
name: "Analog 3"
lambda: return 12.0;
@@ -41,6 +44,7 @@ number:
min_value: 2
max_value: 100
step: 1
unit_of_measurement: "°C"
time:
- platform: zigbee
@@ -1,4 +1,4 @@
<<: !include common_nrf52.yaml
<<: !include common.yaml
zigbee:
router: true
@@ -1,4 +1,4 @@
<<: !include common_nrf52.yaml
<<: !include common.yaml
zigbee:
wipe_on_boot: once