mirror of
https://github.com/esphome/esphome.git
synced 2026-10-19 04:32:50 +00:00
253 lines
8.3 KiB
C++
253 lines
8.3 KiB
C++
#include <gmock/gmock.h>
|
|
#include <gtest/gtest.h>
|
|
|
|
#include <algorithm>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <initializer_list>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "esphome/components/pylontech/pylontech.h"
|
|
#include "esphome/components/uart/uart_component.h"
|
|
|
|
namespace esphome::pylontech::testing {
|
|
|
|
namespace {
|
|
|
|
class MockUARTComponent : public uart::UARTComponent {
|
|
public:
|
|
std::vector<uint8_t> rx;
|
|
|
|
void push_rx(std::initializer_list<std::string> lines) {
|
|
for (const std::string &line : lines) {
|
|
this->rx.insert(this->rx.end(), line.begin(), line.end());
|
|
}
|
|
}
|
|
|
|
void write_array(const uint8_t *data, size_t len) override {}
|
|
bool read_array(uint8_t *data, size_t len) override {
|
|
if (this->rx.size() < len) {
|
|
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(); }
|
|
|
|
MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override));
|
|
MOCK_METHOD(uart::UARTFlushResult, flush, (), (override));
|
|
MOCK_METHOD(void, check_logger_conflict, (), (override));
|
|
#if defined(USE_ESP8266) || defined(USE_ESP32)
|
|
void load_settings(bool dump_config) override {}
|
|
#endif
|
|
};
|
|
|
|
// Every record any listener gets, in publish order
|
|
std::vector<PylontechListener::LineContents> published; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
|
|
|
class TestListener final : public PylontechListener {
|
|
public:
|
|
explicit TestListener(uint8_t bat_num) : PylontechListener(bat_num) {}
|
|
void on_line_read(LineContents *line) override {
|
|
EXPECT_EQ(line->bat_num, this->bat_num_);
|
|
published.push_back(*line);
|
|
}
|
|
void dump_config() override {}
|
|
};
|
|
|
|
// clang-format off
|
|
constexpr const char *HEADER =
|
|
"Power Volt Curr Tempr Tlow Thigh Vlow Vhigh Base.St Volt.St Curr.St Temp.St Coulomb Time"
|
|
" B.V.St B.T.St MosTempr M.T.St\r\n";
|
|
constexpr const char *BAT1 =
|
|
"1 50548 8910 25000 24200 25000 3368 3371 Charge Normal Normal Normal 97% "
|
|
"2021-06-30 20:49:45 Normal Normal 22700 Normal\r\n";
|
|
constexpr const char *BAT2 =
|
|
"2 46012 -1255 9100 5300 5500 3047 3091 SysError Low Normal Normal 4% "
|
|
"2025-11-28 17:56:33 Low Normal 7800 Normal\r\n";
|
|
constexpr const char *NEW_HEADER =
|
|
"Power Volt Curr Tempr Tlow Tlow.Id Thigh Thigh.Id Vlow Vlow.Id Vhigh Vhigh.Id Base.St Volt.St Curr.St Temp.St "
|
|
"Coulomb Time B.V.St B.T.St MosTempr M.T.St SysAlarm.St\r\n";
|
|
constexpr const char *NEW_BAT1 =
|
|
"1 49405 0 17600 13700 8 14500 0 3293 2 3294 0 Idle Normal Normal Normal "
|
|
"60% 2025-12-05 00:53:41 Normal Normal 16600 Normal Normal\r\n";
|
|
// clang-format on
|
|
|
|
// An older firmware line for battery bat with pack voltage volt (mV)
|
|
std::string bat_line(int bat, int volt) {
|
|
char buf[192];
|
|
snprintf(buf, sizeof(buf),
|
|
"%d %d 0 17000 16000 18000 3200 3300 Idle Normal Normal Normal 50%% "
|
|
"2025-11-28 17:56:33 Normal Normal 16000 Normal\r\n",
|
|
bat, volt);
|
|
return buf;
|
|
}
|
|
|
|
class PylontechTest : public ::testing::Test {
|
|
protected:
|
|
void SetUp() override {
|
|
published.clear();
|
|
this->hub_.set_uart_parent(&this->uart_);
|
|
for (TestListener &listener : this->listeners_) {
|
|
this->hub_.register_listener(&listener);
|
|
}
|
|
}
|
|
|
|
std::vector<PylontechListener::LineContents> &published_() { return published; }
|
|
|
|
MockUARTComponent uart_;
|
|
PylontechComponent hub_;
|
|
TestListener listeners_[3]{TestListener(1), TestListener(2), TestListener(3)};
|
|
};
|
|
|
|
} // namespace
|
|
|
|
TEST_F(PylontechTest, BurstIsPublishedAfterTheDrainOnePerLoop) {
|
|
this->uart_.push_rx({HEADER, BAT1, BAT2});
|
|
this->hub_.loop();
|
|
EXPECT_TRUE(this->uart_.rx.empty());
|
|
EXPECT_TRUE(this->published_().empty());
|
|
|
|
this->hub_.loop();
|
|
ASSERT_EQ(this->published_().size(), 1u);
|
|
this->hub_.loop();
|
|
ASSERT_EQ(this->published_().size(), 2u);
|
|
this->hub_.loop();
|
|
EXPECT_EQ(this->published_().size(), 2u);
|
|
|
|
const auto &b1 = this->published_()[0];
|
|
EXPECT_EQ(b1.bat_num, 1);
|
|
EXPECT_EQ(b1.volt, 50548);
|
|
EXPECT_EQ(b1.curr, 8910);
|
|
EXPECT_EQ(b1.tempr, 25000);
|
|
EXPECT_EQ(b1.tlow, 24200);
|
|
EXPECT_EQ(b1.thigh, 25000);
|
|
EXPECT_EQ(b1.vlow, 3368);
|
|
EXPECT_EQ(b1.vhigh, 3371);
|
|
EXPECT_STREQ(b1.base_st, "Charge");
|
|
EXPECT_STREQ(b1.volt_st, "Normal");
|
|
EXPECT_EQ(b1.coulomb, 97);
|
|
EXPECT_EQ(b1.mostempr, 22700);
|
|
|
|
const auto &b2 = this->published_()[1];
|
|
EXPECT_EQ(b2.bat_num, 2);
|
|
EXPECT_EQ(b2.curr, -1255);
|
|
EXPECT_STREQ(b2.base_st, "SysError");
|
|
EXPECT_STREQ(b2.volt_st, "Low");
|
|
EXPECT_EQ(b2.coulomb, 4);
|
|
EXPECT_EQ(b2.mostempr, 7800);
|
|
}
|
|
|
|
TEST_F(PylontechTest, NothingIsPublishedWhileBytesKeepArriving) {
|
|
this->uart_.push_rx({HEADER, BAT1});
|
|
this->hub_.loop();
|
|
this->uart_.push_rx({BAT2});
|
|
this->hub_.loop();
|
|
EXPECT_TRUE(this->published_().empty());
|
|
this->hub_.loop();
|
|
this->hub_.loop();
|
|
EXPECT_EQ(this->published_().size(), 2u);
|
|
}
|
|
|
|
TEST_F(PylontechTest, NewerFirmwareSkipsIdColumns) {
|
|
this->uart_.push_rx({NEW_HEADER, NEW_BAT1});
|
|
this->hub_.loop();
|
|
this->hub_.loop();
|
|
ASSERT_EQ(this->published_().size(), 1u);
|
|
const auto &b1 = this->published_()[0];
|
|
EXPECT_EQ(b1.volt, 49405);
|
|
EXPECT_EQ(b1.tlow, 13700);
|
|
EXPECT_EQ(b1.thigh, 14500);
|
|
EXPECT_EQ(b1.vlow, 3293);
|
|
EXPECT_EQ(b1.vhigh, 3294);
|
|
EXPECT_STREQ(b1.base_st, "Idle");
|
|
EXPECT_EQ(b1.coulomb, 60);
|
|
EXPECT_EQ(b1.mostempr, 16600);
|
|
}
|
|
|
|
TEST_F(PylontechTest, OverlongHeaderStillSetsTheColumnLayout) {
|
|
// Past the 199 character line limit: the line is dropped but its start still selects the layout
|
|
const std::string pad(40, ' ');
|
|
std::string new_header(NEW_HEADER);
|
|
new_header.insert(new_header.size() - 2, pad + "Extra.St");
|
|
ASSERT_GT(new_header.size(), 201u);
|
|
this->uart_.push_rx({new_header, NEW_BAT1});
|
|
this->hub_.loop();
|
|
this->hub_.loop();
|
|
ASSERT_EQ(this->published_().size(), 1u);
|
|
EXPECT_EQ(this->published_()[0].thigh, 14500);
|
|
EXPECT_EQ(this->published_()[0].mostempr, 16600);
|
|
|
|
// An overlong older header switches back
|
|
std::string old_header(HEADER);
|
|
old_header.insert(old_header.size() - 2, pad + pad + "Extra.St");
|
|
ASSERT_GT(old_header.size(), 201u);
|
|
this->uart_.push_rx({old_header, BAT1});
|
|
this->hub_.loop();
|
|
this->hub_.loop();
|
|
ASSERT_EQ(this->published_().size(), 2u);
|
|
EXPECT_EQ(this->published_()[1].thigh, 25000);
|
|
EXPECT_EQ(this->published_()[1].mostempr, 22700);
|
|
}
|
|
|
|
TEST_F(PylontechTest, BatteryWithoutListenerIsIgnored) {
|
|
this->uart_.push_rx(
|
|
{HEADER, bat_line(5, 49000), bat_line(17, 49000), bat_line(0, 49000), bat_line(INT32_MIN, 49000), BAT1, BAT2});
|
|
for (int i = 0; i < 4; i++) {
|
|
this->hub_.loop();
|
|
}
|
|
ASSERT_EQ(this->published_().size(), 2u);
|
|
EXPECT_EQ(this->published_()[0].bat_num, 1);
|
|
EXPECT_EQ(this->published_()[1].bat_num, 2);
|
|
}
|
|
|
|
TEST_F(PylontechTest, OutOfRangeValueRejectsTheLine) {
|
|
this->uart_.push_rx({HEADER, bat_line(1, -1), bat_line(1, 70000), BAT2});
|
|
for (int i = 0; i < 3; i++) {
|
|
this->hub_.loop();
|
|
}
|
|
ASSERT_EQ(this->published_().size(), 1u);
|
|
EXPECT_EQ(this->published_()[0].bat_num, 2);
|
|
}
|
|
|
|
TEST_F(PylontechTest, InvalidAndUnknownLinesAreSkipped) {
|
|
this->uart_.push_rx({"pwr\r\n@\r\n", HEADER, "1 abc\r\n", BAT2, "$$\r\n"});
|
|
for (int i = 0; i < 3; i++) {
|
|
this->hub_.loop();
|
|
}
|
|
ASSERT_EQ(this->published_().size(), 1u);
|
|
EXPECT_EQ(this->published_()[0].bat_num, 2);
|
|
}
|
|
|
|
TEST_F(PylontechTest, RepeatedBatteryLineOverwrites) {
|
|
this->uart_.push_rx({HEADER, bat_line(3, 49000), BAT1, bat_line(3, 49500)});
|
|
for (int i = 0; i < 4; i++) {
|
|
this->hub_.loop();
|
|
}
|
|
ASSERT_EQ(this->published_().size(), 2u);
|
|
EXPECT_EQ(this->published_()[0].bat_num, 1);
|
|
EXPECT_EQ(this->published_()[1].bat_num, 3);
|
|
EXPECT_EQ(this->published_()[1].volt, 49500);
|
|
|
|
// The next reply fills the slot again
|
|
this->uart_.push_rx({HEADER, bat_line(3, 48000)});
|
|
this->hub_.loop();
|
|
this->hub_.loop();
|
|
ASSERT_EQ(this->published_().size(), 3u);
|
|
EXPECT_EQ(this->published_()[2].volt, 48000);
|
|
}
|
|
|
|
TEST_F(PylontechTest, BadLineClearsTheBatteryPendingRecord) {
|
|
this->uart_.push_rx({HEADER, BAT1, "1 abc\r\n", BAT2});
|
|
for (int i = 0; i < 3; i++) {
|
|
this->hub_.loop();
|
|
}
|
|
ASSERT_EQ(this->published_().size(), 1u);
|
|
EXPECT_EQ(this->published_()[0].bat_num, 2);
|
|
}
|
|
|
|
} // namespace esphome::pylontech::testing
|