mirror of
https://github.com/esphome/esphome.git
synced 2026-10-18 12:12:48 +00:00
[pylontech] Add individual cell voltage and temperature sensors (#16852)
Co-authored-by: Michael <michael_e@gmx-topmail.com> Co-authored-by: ritschys <117527311+ritschys@users.noreply.github.com> Co-authored-by: J. Nick Koston <nick@home-assistant.io> Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
co-authored by
Michael
ritschys
J. Nick Koston
J. Nick Koston
parent
5f8f075e76
commit
f72857151a
@@ -1,8 +1,10 @@
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import uart
|
||||
from esphome.components.const import CONF_ENABLED
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.core import CORE
|
||||
@@ -29,11 +31,13 @@ CV_NUM_BATTERIES = cv.int_range(1, 16)
|
||||
|
||||
_request_listener_slot = cg.slot_counter("PYLONTECH_LISTENER_COUNT")
|
||||
_request_battery_slot = cg.slot_counter("PYLONTECH_BATTERY_COUNT")
|
||||
_request_cell_battery_slot = cg.slot_counter("PYLONTECH_CELL_BATTERY_COUNT")
|
||||
|
||||
|
||||
@dataclass
|
||||
class PylontechData:
|
||||
batteries: set[tuple[str, int]] = field(default_factory=set)
|
||||
cell_batteries: set[tuple[str, int]] = field(default_factory=set)
|
||||
|
||||
|
||||
def _get_data() -> PylontechData:
|
||||
@@ -42,15 +46,26 @@ def _get_data() -> PylontechData:
|
||||
return CORE.data[DOMAIN]
|
||||
|
||||
|
||||
async def register_listener(config: ConfigType, listener: MockObj) -> None:
|
||||
"""Register a battery listener; the hub keeps one line slot per distinct battery."""
|
||||
async def register_listener(
|
||||
config: ConfigType, listener: MockObj, *, cells: bool = False
|
||||
) -> None:
|
||||
"""Register a battery listener; the hub keeps one line slot per distinct battery.
|
||||
|
||||
With cells, the hub also reads that battery's cells after every pwr reply and keeps
|
||||
one cell slot per distinct battery.
|
||||
"""
|
||||
hub = await cg.get_variable(config[CONF_PYLONTECH_ID])
|
||||
hub_key = str(hub)
|
||||
_request_listener_slot(hub_key)
|
||||
data = _get_data()
|
||||
if (key := (hub_key, config[CONF_BATTERY])) not in data.batteries:
|
||||
key = (hub_key, config[CONF_BATTERY])
|
||||
if key not in data.batteries:
|
||||
data.batteries.add(key)
|
||||
_request_battery_slot(hub_key)
|
||||
if cells and key not in data.cell_batteries:
|
||||
data.cell_batteries.add(key)
|
||||
_request_cell_battery_slot(hub_key)
|
||||
cg.add(hub.add_cell_battery(config[CONF_BATTERY]))
|
||||
cg.add(hub.register_listener(listener))
|
||||
|
||||
|
||||
@@ -80,6 +95,17 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
|
||||
stop_bits=1,
|
||||
)
|
||||
|
||||
automation.register_apply_action(
|
||||
"pylontech.set_cell_polling",
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(PylontechComponent),
|
||||
cv.Required(CONF_ENABLED): cv.templatable(cv.boolean),
|
||||
}
|
||||
),
|
||||
automation.ApplyField(CONF_ENABLED, "set_cell_polling_enabled", cg.bool_),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#include "pylontech.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
|
||||
@@ -20,13 +22,13 @@
|
||||
|
||||
#define PARSE_INT(field, field_name) \
|
||||
{ \
|
||||
get_token(token_buf); \
|
||||
get_token(cursor, token_buf); \
|
||||
PARSE_TOKEN_INT(field, field_name) \
|
||||
}
|
||||
|
||||
#define PARSE_STR(field, field_name) \
|
||||
{ \
|
||||
get_token(field); \
|
||||
get_token(cursor, field); \
|
||||
if (strlen(field) < 2) { \
|
||||
ESP_LOGV(TAG, "too short " field_name " in line %s", buffer); \
|
||||
this->reject_line_(); \
|
||||
@@ -38,6 +40,11 @@ namespace esphome::pylontech {
|
||||
|
||||
ESPHOME_LOG_TAG(TAG, "pylontech");
|
||||
|
||||
#ifdef PYLONTECH_CELL_BATTERY_COUNT
|
||||
// The pack drops a command that follows its reply without a pause
|
||||
static constexpr uint32_t BAT_COMMAND_DELAY_MS = 20;
|
||||
#endif
|
||||
|
||||
// Rejects values that would wrap in a narrowed field
|
||||
template<typename T> static bool in_range(int value) {
|
||||
if constexpr (sizeof(T) >= sizeof(int)) {
|
||||
@@ -47,6 +54,37 @@ template<typename T> static bool in_range(int value) {
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef PYLONTECH_LISTENER_COUNT
|
||||
// Copies the next whitespace separated token, at most TEXT_SENSOR_MAX_LEN - 1 characters, and moves cursor past it
|
||||
static void get_token(const char *&cursor, char *token_buf) {
|
||||
// Skip leading whitespace
|
||||
while (*cursor == ' ' || *cursor == '\t') {
|
||||
cursor++;
|
||||
}
|
||||
|
||||
if (*cursor == '\0') {
|
||||
token_buf[0] = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const char *start = cursor;
|
||||
|
||||
// Find end of field
|
||||
while (*cursor != '\0' && *cursor != ' ' && *cursor != '\t') {
|
||||
cursor++;
|
||||
}
|
||||
|
||||
size_t token_len = std::min(static_cast<size_t>(cursor - start), static_cast<size_t>(TEXT_SENSOR_MAX_LEN - 1));
|
||||
memcpy(token_buf, start, token_len);
|
||||
token_buf[token_len] = 0;
|
||||
}
|
||||
|
||||
// Slots are kept in battery order, so a battery's slot index is the number of lower batteries in the mask
|
||||
static uint8_t slot_index(battery_mask_t batteries, uint8_t bat_index) {
|
||||
return __builtin_popcount(batteries & ((1u << bat_index) - 1u));
|
||||
}
|
||||
#endif
|
||||
|
||||
PylontechComponent::PylontechComponent() {}
|
||||
|
||||
void PylontechComponent::dump_config() {
|
||||
@@ -67,13 +105,34 @@ void PylontechComponent::setup() {
|
||||
}
|
||||
}
|
||||
|
||||
void PylontechComponent::update() { this->write_str("pwr\n"); }
|
||||
void PylontechComponent::update() {
|
||||
#ifdef PYLONTECH_CELL_BATTERY_COUNT
|
||||
// A "bat N" that was not sent yet, or whose reply never finished, must not get in the way of "pwr"
|
||||
this->bat_command_due_ = false;
|
||||
this->late_reply_ = this->bat_reply_ != 0;
|
||||
this->bat_reply_ = 0;
|
||||
#endif
|
||||
this->write_str("pwr\n");
|
||||
}
|
||||
|
||||
void PylontechComponent::loop() {
|
||||
// Drain the UART quickly; pylontech sends large bursts. Logging and publishing wait until it is idle.
|
||||
size_t avail = this->available();
|
||||
#ifdef PYLONTECH_LISTENER_COUNT
|
||||
if (avail == 0) {
|
||||
#ifdef PYLONTECH_CELL_BATTERY_COUNT
|
||||
if (this->bat_command_due_ &&
|
||||
static_cast<int32_t>(App.get_loop_component_start_time() - this->bat_command_at_) >= 0) {
|
||||
this->bat_command_due_ = false;
|
||||
if (this->cell_polling_enabled_) {
|
||||
char cmd[9]; // "bat 16\n"
|
||||
snprintf(cmd, sizeof(cmd), "bat %u\n", this->bat_reply_);
|
||||
this->write_str(cmd);
|
||||
} else {
|
||||
this->bat_reply_ = 0; // turned off during the pause: nothing is coming
|
||||
}
|
||||
}
|
||||
#endif
|
||||
this->dispatch_one_();
|
||||
return;
|
||||
}
|
||||
@@ -120,20 +179,59 @@ void PylontechComponent::dispatch_one_() {
|
||||
}
|
||||
return;
|
||||
}
|
||||
#ifdef PYLONTECH_CELL_BATTERY_COUNT
|
||||
if (this->cells_pending_ != 0) {
|
||||
const uint8_t bat_index = __builtin_ctz(this->cells_pending_);
|
||||
this->cells_pending_ &= ~(1u << bat_index);
|
||||
const uint8_t slot = slot_index(this->cell_batteries_, bat_index);
|
||||
ESP_LOGV(TAG, "Publishing cells of battery %u", bat_index + 1);
|
||||
for (PylontechListener *listener : this->listeners_) {
|
||||
if (listener->get_battery() == bat_index + 1) {
|
||||
listener->on_cells_read(this->cell_slots_[slot], this->cells_read_[slot]);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
if (this->rejected_ != 0) {
|
||||
ESP_LOGW(TAG, "Rejected %u malformed battery lines", this->rejected_);
|
||||
ESP_LOGW(TAG, "Rejected %u malformed lines", this->rejected_);
|
||||
this->rejected_ = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void PylontechComponent::parse_header_(const char *buffer) {
|
||||
this->has_tlow_id_ = strstr(buffer, "Tlow.Id") != nullptr;
|
||||
#ifdef PYLONTECH_CELL_BATTERY_COUNT
|
||||
// The header opens a "pwr" reply, so what follows are battery lines even after a late "bat N" reply
|
||||
this->bat_reply_ = 0;
|
||||
this->late_reply_ = false;
|
||||
this->pwr_reply_ = true;
|
||||
#endif
|
||||
ESP_LOGV(TAG, "header line %s Tlow.Id: %s", this->has_tlow_id_ ? LOG_STR_LITERAL("with") : LOG_STR_LITERAL("without"),
|
||||
buffer);
|
||||
}
|
||||
|
||||
// Runs while the UART drains: no logging above VERBOSE and no publishing here
|
||||
void PylontechComponent::process_line_(const char *buffer) {
|
||||
#ifdef PYLONTECH_CELL_BATTERY_COUNT
|
||||
if (strcmp(buffer, "$$") == 0) {
|
||||
// Every reply ends with "$$"; after the "pwr" reply one "bat N" follows per battery with cell sensors.
|
||||
// A "$$" that ends neither of the two, such as a late reply to an abandoned "bat N", sends nothing.
|
||||
if (this->bat_reply_ != 0) {
|
||||
this->cells_pending_ |= 1u << (this->bat_reply_ - 1);
|
||||
this->send_next_bat_();
|
||||
} else if (this->pwr_reply_) {
|
||||
this->pwr_reply_ = false;
|
||||
this->send_next_bat_();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The "pwr" header always starts a "pwr" reply, whatever was expected
|
||||
if (this->bat_reply_ != 0 && strncmp(buffer, "Power", 5) != 0) {
|
||||
this->parse_cell_line_(buffer);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
// clang-format off
|
||||
// example lines to parse:
|
||||
// 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
|
||||
@@ -147,31 +245,7 @@ void PylontechComponent::process_line_(const char *buffer) {
|
||||
const char *cursor = buffer;
|
||||
char token_buf[TEXT_SENSOR_MAX_LEN] = {0};
|
||||
|
||||
// Helper Lambda to extract tokens
|
||||
auto get_token = [&](char *token_buf) -> void {
|
||||
// Skip leading whitespace
|
||||
while (*cursor == ' ' || *cursor == '\t') {
|
||||
cursor++;
|
||||
}
|
||||
|
||||
if (*cursor == '\0') {
|
||||
token_buf[0] = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const char *start = cursor;
|
||||
|
||||
// Find end of field
|
||||
while (*cursor != '\0' && *cursor != ' ' && *cursor != '\t') {
|
||||
cursor++;
|
||||
}
|
||||
|
||||
size_t token_len = std::min(static_cast<size_t>(cursor - start), static_cast<size_t>(TEXT_SENSOR_MAX_LEN - 1));
|
||||
memcpy(token_buf, start, token_len);
|
||||
token_buf[token_len] = 0;
|
||||
};
|
||||
|
||||
get_token(token_buf);
|
||||
get_token(cursor, token_buf);
|
||||
auto bat = parse_number<int>(token_buf);
|
||||
if (!bat.has_value()) {
|
||||
if (strcmp(token_buf, "Power") == 0) {
|
||||
@@ -182,20 +256,20 @@ void PylontechComponent::process_line_(const char *buffer) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
#ifdef PYLONTECH_CELL_BATTERY_COUNT
|
||||
if (this->late_reply_) {
|
||||
ESP_LOGV(TAG, "dropped late line %s", buffer); // cell rows of the abandoned "bat N" look like battery lines
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
const unsigned bat_index = static_cast<unsigned>(*bat) - 1u;
|
||||
if (bat_index >= MAX_BATTERIES || (this->batteries_ & (1u << bat_index)) == 0) {
|
||||
return; // no listener for this battery
|
||||
}
|
||||
const battery_mask_t bat_bit = 1u << bat_index;
|
||||
// The slot index is the number of listened batteries below this one
|
||||
uint8_t slot = 0;
|
||||
for (battery_mask_t below = this->batteries_ & (bat_bit - 1); below != 0; below &= below - 1) {
|
||||
slot++;
|
||||
}
|
||||
const battery_mask_t slot_bit = 1u << slot;
|
||||
const battery_mask_t slot_bit = 1u << slot_index(this->batteries_, bat_index);
|
||||
// Cleared until the whole line parses, so a bad line never publishes a half written slot
|
||||
this->pending_ &= ~slot_bit;
|
||||
PylontechListener::LineContents &l = this->slots_[slot];
|
||||
PylontechListener::LineContents &l = this->slots_[__builtin_ctz(slot_bit)];
|
||||
l.bat_num = *bat;
|
||||
|
||||
PARSE_INT(l.volt, "Volt");
|
||||
@@ -203,40 +277,99 @@ void PylontechComponent::process_line_(const char *buffer) {
|
||||
PARSE_INT(l.tempr, "Tempr");
|
||||
PARSE_INT(l.tlow, "Tlow");
|
||||
if (this->has_tlow_id_) {
|
||||
get_token(token_buf); // Skip Tlow.Id
|
||||
get_token(cursor, token_buf); // Skip Tlow.Id
|
||||
}
|
||||
PARSE_INT(l.thigh, "Thigh");
|
||||
if (this->has_tlow_id_) {
|
||||
get_token(token_buf); // Skip Thigh.Id
|
||||
get_token(cursor, token_buf); // Skip Thigh.Id
|
||||
}
|
||||
PARSE_INT(l.vlow, "Vlow");
|
||||
if (this->has_tlow_id_) {
|
||||
get_token(token_buf); // Skip Vlow.Id
|
||||
get_token(cursor, token_buf); // Skip Vlow.Id
|
||||
}
|
||||
PARSE_INT(l.vhigh, "Vhigh");
|
||||
if (this->has_tlow_id_) {
|
||||
get_token(token_buf); // Skip Vhigh.Id
|
||||
get_token(cursor, token_buf); // Skip Vhigh.Id
|
||||
}
|
||||
PARSE_STR(l.base_st, "Base.St");
|
||||
PARSE_STR(l.volt_st, "Volt.St");
|
||||
PARSE_STR(l.curr_st, "Curr.St");
|
||||
PARSE_STR(l.temp_st, "Temp.St");
|
||||
get_token(token_buf);
|
||||
get_token(cursor, token_buf);
|
||||
if (char *pct = strchr(token_buf, '%')) {
|
||||
*pct = '\0';
|
||||
}
|
||||
PARSE_TOKEN_INT(l.coulomb, "Coulomb");
|
||||
get_token(token_buf); // Skip Date
|
||||
get_token(token_buf); // Skip Time
|
||||
get_token(token_buf); // Skip B.V.St
|
||||
get_token(token_buf); // Skip B.T.St
|
||||
get_token(cursor, token_buf); // Skip Date
|
||||
get_token(cursor, token_buf); // Skip Time
|
||||
get_token(cursor, token_buf); // Skip B.V.St
|
||||
get_token(cursor, token_buf); // Skip B.T.St
|
||||
PARSE_INT(l.mostempr, "Mostempr");
|
||||
|
||||
this->pending_ |= slot_bit;
|
||||
}
|
||||
|
||||
#ifdef PYLONTECH_CELL_BATTERY_COUNT
|
||||
// Picks the next cell battery above the one just read and schedules "bat N" for it; the cycle ends when none
|
||||
// is left or cell polling was turned off
|
||||
void PylontechComponent::send_next_bat_() {
|
||||
// Battery n is bit n - 1, so clearing the low bat_reply_ bits leaves the batteries above it
|
||||
const battery_mask_t remaining =
|
||||
this->cell_batteries_ & static_cast<battery_mask_t>(~((1u << this->bat_reply_) - 1u));
|
||||
if (remaining == 0 || !this->cell_polling_enabled_) {
|
||||
this->bat_reply_ = 0;
|
||||
return;
|
||||
}
|
||||
const uint8_t bat_index = __builtin_ctz(remaining);
|
||||
this->bat_reply_ = bat_index + 1;
|
||||
// The slot is about to be rewritten, so an unpublished record must not go out half filled
|
||||
this->cells_pending_ &= ~(1u << bat_index);
|
||||
this->cells_read_[slot_index(this->cell_batteries_, bat_index)] = 0;
|
||||
// loop() writes the command once the pause has passed and the line is idle
|
||||
this->bat_command_due_ = true;
|
||||
this->bat_command_at_ = App.get_loop_component_start_time() + BAT_COMMAND_DELAY_MS;
|
||||
}
|
||||
|
||||
// Runs while the UART drains, like process_line_
|
||||
void PylontechComponent::parse_cell_line_(const char *buffer) {
|
||||
// clang-format off
|
||||
// example lines to parse, the reply to "bat N":
|
||||
// Battery Volt Curr Tempr Base State Volt. State Curr. State Temp. State SOC Coulomb BAL
|
||||
// 0 3308 -914 18300 Dischg Normal Normal Normal 70% 34015 mAH N
|
||||
// clang-format on
|
||||
|
||||
const char *cursor = buffer;
|
||||
char token_buf[TEXT_SENSOR_MAX_LEN] = {0};
|
||||
|
||||
get_token(cursor, token_buf);
|
||||
auto cell = parse_number<int>(token_buf);
|
||||
if (!cell.has_value() || *cell < 0 || *cell >= MAX_CELLS) {
|
||||
// the header, the echoed command or "Command completed successfully"
|
||||
ESP_LOGV(TAG, "unknown line %s", buffer);
|
||||
return;
|
||||
}
|
||||
PylontechListener::CellContents c{};
|
||||
PARSE_INT(c.volt, "Volt");
|
||||
get_token(cursor, token_buf); // Skip Curr
|
||||
int32_t tempr; // m°C
|
||||
PARSE_INT(tempr, "Tempr");
|
||||
tempr /= 10;
|
||||
if (!in_range<int16_t>(tempr)) {
|
||||
ESP_LOGV(TAG, "invalid Tempr in line %s", buffer);
|
||||
this->reject_line_();
|
||||
return;
|
||||
}
|
||||
c.tempr = tempr;
|
||||
|
||||
const uint8_t slot = slot_index(this->cell_batteries_, this->bat_reply_ - 1);
|
||||
this->cell_slots_[slot][*cell] = c;
|
||||
this->cells_read_[slot] |= 1u << *cell;
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
} // namespace esphome::pylontech
|
||||
|
||||
#undef PARSE_TOKEN_INT
|
||||
#undef PARSE_INT
|
||||
#undef PARSE_STR
|
||||
|
||||
@@ -16,6 +16,10 @@ static constexpr uint8_t TEXT_SENSOR_MAX_LEN = 14;
|
||||
static constexpr uint8_t MAX_BATTERIES = 16;
|
||||
using battery_mask_t = uint16_t;
|
||||
static_assert(MAX_BATTERIES <= sizeof(battery_mask_t) * 8, "battery mask too narrow");
|
||||
// A battery reports cells 0 to MAX_CELLS - 1; bit c of a cell_mask_t stands for cell c
|
||||
static constexpr uint8_t MAX_CELLS = 15;
|
||||
using cell_mask_t = uint16_t;
|
||||
static_assert(MAX_CELLS <= sizeof(cell_mask_t) * 8, "cell mask too narrow");
|
||||
|
||||
class PylontechListener {
|
||||
public:
|
||||
@@ -27,12 +31,22 @@ class PylontechListener {
|
||||
char base_st[TEXT_SENSOR_MAX_LEN], volt_st[TEXT_SENSOR_MAX_LEN], curr_st[TEXT_SENSOR_MAX_LEN],
|
||||
temp_st[TEXT_SENSOR_MAX_LEN];
|
||||
};
|
||||
struct CellContents {
|
||||
int16_t tempr; // 0.01 °C
|
||||
uint16_t volt; // mV
|
||||
};
|
||||
using CellArray = std::array<CellContents, MAX_CELLS>;
|
||||
|
||||
explicit PylontechListener(uint8_t bat_num) : bat_num_(bat_num) {}
|
||||
uint8_t get_battery() const { return this->bat_num_; }
|
||||
|
||||
/// Called only with records for this listener's battery.
|
||||
virtual void on_line_read(LineContents *line) = 0;
|
||||
#ifdef PYLONTECH_CELL_BATTERY_COUNT
|
||||
/// Called with the cells of this listener's battery after a "bat N" reply; bit c of read is set when cell c
|
||||
/// was in the reply.
|
||||
virtual void on_cells_read(const CellArray &cells, cell_mask_t read) {}
|
||||
#endif
|
||||
virtual void dump_config() = 0;
|
||||
|
||||
protected:
|
||||
@@ -51,11 +65,22 @@ class PylontechComponent final : public PollingComponent, public uart::UARTDevic
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
/// Turn the "bat N" cell readings after each "pwr" reply off or on; nothing to turn off without cell sensors.
|
||||
void set_cell_polling_enabled(bool enabled) {
|
||||
#ifdef PYLONTECH_CELL_BATTERY_COUNT
|
||||
this->cell_polling_enabled_ = enabled;
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef PYLONTECH_LISTENER_COUNT
|
||||
void register_listener(PylontechListener *listener) {
|
||||
this->listeners_.push_back(listener);
|
||||
this->batteries_ |= 1u << (listener->get_battery() - 1);
|
||||
}
|
||||
#ifdef PYLONTECH_CELL_BATTERY_COUNT
|
||||
/// Read the cells of this battery with "bat N" after every "pwr" reply.
|
||||
void add_cell_battery(uint8_t bat_num) { this->cell_batteries_ |= 1u << (bat_num - 1); }
|
||||
#endif
|
||||
|
||||
protected:
|
||||
void process_line_(const char *buffer);
|
||||
@@ -67,17 +92,41 @@ class PylontechComponent final : public PollingComponent, public uart::UARTDevic
|
||||
}
|
||||
}
|
||||
|
||||
// The longest line, the newer firmware header, is 182 characters
|
||||
LineReader<200> line_reader_;
|
||||
#ifdef PYLONTECH_CELL_BATTERY_COUNT
|
||||
void parse_cell_line_(const char *buffer);
|
||||
void send_next_bat_();
|
||||
#endif
|
||||
|
||||
// Members are ordered by alignment, widest first, so the small ones pack without padding
|
||||
StaticVector<PylontechListener *, PYLONTECH_LISTENER_COUNT> listeners_;
|
||||
// One slot per listened battery, in battery order; parsed while the UART drains, published one per
|
||||
// loop once it is idle. A newer line for a battery replaces the unpublished one.
|
||||
static_assert(PYLONTECH_BATTERY_COUNT <= MAX_BATTERIES, "more slots than batteries");
|
||||
std::array<PylontechListener::LineContents, PYLONTECH_BATTERY_COUNT> slots_;
|
||||
#ifdef PYLONTECH_CELL_BATTERY_COUNT
|
||||
// One cell slot per battery with cell sensors, in battery order, filled by its "bat N" reply and published
|
||||
// to that battery's listeners once the reply has ended and the UART is idle
|
||||
std::array<PylontechListener::CellArray, PYLONTECH_CELL_BATTERY_COUNT> cell_slots_;
|
||||
uint32_t bat_command_at_{0}; // loop time the pause before the next "bat N" ends at
|
||||
#endif
|
||||
battery_mask_t batteries_{0}; // batteries with a listener
|
||||
battery_mask_t pending_{0}; // bit i set: slots_[i] is waiting to be published
|
||||
#ifdef PYLONTECH_CELL_BATTERY_COUNT
|
||||
std::array<cell_mask_t, PYLONTECH_CELL_BATTERY_COUNT> cells_read_; // cells the current reply has filled
|
||||
battery_mask_t cell_batteries_{0}; // batteries whose cells are read
|
||||
battery_mask_t cells_pending_{0}; // batteries whose cell slot is waiting to be published
|
||||
#endif
|
||||
bool has_tlow_id_{false};
|
||||
uint8_t rejected_{0}; // malformed battery lines since the last idle pass
|
||||
uint8_t rejected_{0}; // malformed lines since the last idle pass
|
||||
#ifdef PYLONTECH_CELL_BATTERY_COUNT
|
||||
uint8_t bat_reply_{0}; // battery whose "bat N" reply is arriving, 0 while the "pwr" reply arrives
|
||||
bool pwr_reply_{false}; // a "pwr" header arrived and its "$$" has not
|
||||
bool bat_command_due_{false}; // a "bat N" waits for the pause after the last reply
|
||||
bool late_reply_{false}; // an abandoned "bat N" may still answer; its lines are dropped until "pwr" answers
|
||||
bool cell_polling_enabled_{true};
|
||||
#endif
|
||||
// The longest line, the newer firmware header, is 182 characters
|
||||
LineReader<200> line_reader_;
|
||||
#endif
|
||||
};
|
||||
|
||||
|
||||
@@ -28,6 +28,13 @@ CONF_TEMPERATURE_HIGH = "temperature_high"
|
||||
CONF_VOLTAGE_LOW = "voltage_low"
|
||||
CONF_VOLTAGE_HIGH = "voltage_high"
|
||||
CONF_MOS_TEMPERATURE = "mos_temperature"
|
||||
CONF_CELLS = "cells"
|
||||
CONF_CELL = "cell"
|
||||
|
||||
# A battery reports cells 0 to NUM_CELLS - 1
|
||||
NUM_CELLS = 15
|
||||
|
||||
_request_cell_slot = cg.slot_counter("PYLONTECH_SENSOR_CELL_COUNT")
|
||||
|
||||
TYPES: dict[str, cv.Schema] = {
|
||||
CONF_VOLTAGE: sensor.sensor_schema(
|
||||
@@ -86,8 +93,34 @@ TYPES: dict[str, cv.Schema] = {
|
||||
),
|
||||
}
|
||||
|
||||
CELL_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_CELL): cv.int_range(min=0, max=NUM_CELLS - 1),
|
||||
cv.Optional(CONF_VOLTAGE): TYPES[CONF_VOLTAGE],
|
||||
cv.Optional(CONF_TEMPERATURE): TYPES[CONF_TEMPERATURE],
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key(CONF_VOLTAGE, CONF_TEMPERATURE),
|
||||
)
|
||||
|
||||
|
||||
def _unique_cells(cells: list[ConfigType]) -> list[ConfigType]:
|
||||
seen: set[int] = set()
|
||||
for cell in cells:
|
||||
if (number := cell[CONF_CELL]) in seen:
|
||||
raise cv.Invalid(f"Cell {number} is listed more than once")
|
||||
seen.add(number)
|
||||
return cells
|
||||
|
||||
|
||||
CONFIG_SCHEMA = PYLONTECH_COMPONENT_SCHEMA.extend(
|
||||
{cv.GenerateID(): cv.declare_id(PylontechSensor)}
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(PylontechSensor),
|
||||
cv.Optional(CONF_CELLS): cv.All(
|
||||
cv.ensure_list(CELL_SCHEMA), cv.Length(min=1), _unique_cells
|
||||
),
|
||||
}
|
||||
).extend({cv.Optional(marker): schema for marker, schema in TYPES.items()})
|
||||
|
||||
|
||||
@@ -99,4 +132,15 @@ async def to_code(config: ConfigType) -> None:
|
||||
sens = await sensor.new_sensor(marker_config)
|
||||
cg.add(getattr(bat, f"set_{marker}_sensor")(sens))
|
||||
|
||||
await register_listener(config, bat)
|
||||
cells = config.get(CONF_CELLS, [])
|
||||
for cell_config in cells:
|
||||
_request_cell_slot(str(bat))
|
||||
voltage = cg.nullptr
|
||||
if voltage_config := cell_config.get(CONF_VOLTAGE):
|
||||
voltage = await sensor.new_sensor(voltage_config)
|
||||
temperature = cg.nullptr
|
||||
if temperature_config := cell_config.get(CONF_TEMPERATURE):
|
||||
temperature = await sensor.new_sensor(temperature_config)
|
||||
cg.add(bat.add_cell(cell_config[CONF_CELL], voltage, temperature))
|
||||
|
||||
await register_listener(config, bat, cells=bool(cells))
|
||||
|
||||
@@ -20,6 +20,13 @@ void PylontechSensor::dump_config() {
|
||||
LOG_SENSOR(" ", "Voltage high", this->voltage_high_sensor_);
|
||||
LOG_SENSOR(" ", "Coulomb", this->coulomb_sensor_);
|
||||
LOG_SENSOR(" ", "MOS Temperature", this->mos_temperature_sensor_);
|
||||
#ifdef PYLONTECH_SENSOR_CELL_COUNT
|
||||
for (const CellSensors &c : this->cells_) {
|
||||
ESP_LOGCONFIG(TAG, " Cell %u", c.cell);
|
||||
LOG_SENSOR(" ", "Voltage", c.voltage);
|
||||
LOG_SENSOR(" ", "Temperature", c.temperature);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void PylontechSensor::on_line_read(PylontechListener::LineContents *line) {
|
||||
@@ -52,4 +59,20 @@ void PylontechSensor::on_line_read(PylontechListener::LineContents *line) {
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef PYLONTECH_SENSOR_CELL_COUNT
|
||||
void PylontechSensor::on_cells_read(const CellArray &cells, cell_mask_t read) {
|
||||
for (const CellSensors &c : this->cells_) {
|
||||
if ((read & (1u << c.cell)) == 0) {
|
||||
continue;
|
||||
}
|
||||
if (c.voltage != nullptr) {
|
||||
c.voltage->publish_state(((float) cells[c.cell].volt) / 1000.0f);
|
||||
}
|
||||
if (c.temperature != nullptr) {
|
||||
c.temperature->publish_state(((float) cells[c.cell].tempr) / 100.0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace esphome::pylontech
|
||||
|
||||
@@ -17,11 +17,25 @@ class PylontechSensor final : public PylontechListener {
|
||||
SUB_SENSOR(temperature_high)
|
||||
SUB_SENSOR(voltage_low)
|
||||
SUB_SENSOR(voltage_high)
|
||||
|
||||
SUB_SENSOR(coulomb)
|
||||
SUB_SENSOR(mos_temperature)
|
||||
|
||||
void on_line_read(LineContents *line) override;
|
||||
|
||||
#ifdef PYLONTECH_SENSOR_CELL_COUNT
|
||||
void add_cell(uint8_t cell, sensor::Sensor *voltage, sensor::Sensor *temperature) {
|
||||
this->cells_.push_back({voltage, temperature, cell});
|
||||
}
|
||||
void on_cells_read(const CellArray &cells, cell_mask_t read) override;
|
||||
|
||||
protected:
|
||||
struct CellSensors {
|
||||
sensor::Sensor *voltage;
|
||||
sensor::Sensor *temperature;
|
||||
uint8_t cell;
|
||||
};
|
||||
StaticVector<CellSensors, PYLONTECH_SENSOR_CELL_COUNT> cells_;
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace esphome::pylontech
|
||||
|
||||
@@ -179,7 +179,9 @@
|
||||
#define PN71XX_ON_TAG_REMOVED_TRIGGER_COUNT 1
|
||||
#define PN71XX_ON_TAG_TRIGGER_COUNT 1
|
||||
#define PYLONTECH_BATTERY_COUNT 1
|
||||
#define PYLONTECH_CELL_BATTERY_COUNT 1
|
||||
#define PYLONTECH_LISTENER_COUNT 2
|
||||
#define PYLONTECH_SENSOR_CELL_COUNT 2
|
||||
#define REMOTE_BASE_DUMPER_COUNT 1
|
||||
#define REMOTE_BASE_LISTENER_COUNT 1
|
||||
#define USE_REMOTE_PROTOCOL_ABBWELCOME
|
||||
|
||||
@@ -5,9 +5,11 @@ from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
async def to_code_testing(config: ConfigType) -> None:
|
||||
# Storage is sized by code generation; the gtests register three batteries.
|
||||
# Storage is sized by code generation; the gtests register three batteries and
|
||||
# read the cells of two of them.
|
||||
cg.add_define("PYLONTECH_LISTENER_COUNT", 3)
|
||||
cg.add_define("PYLONTECH_BATTERY_COUNT", 3)
|
||||
cg.add_define("PYLONTECH_CELL_BATTERY_COUNT", 2)
|
||||
cg.add_define("USE_LINE_READER")
|
||||
|
||||
# A MULTI_CONF component gets no entry in the host build, so its to_code would never run.
|
||||
|
||||
@@ -14,6 +14,15 @@ sensor:
|
||||
id: pyl01_soc
|
||||
mos_temperature:
|
||||
id: pyl01_mos_temperature
|
||||
cells:
|
||||
- cell: 0
|
||||
voltage:
|
||||
id: pyl01_cell_0_voltage
|
||||
temperature:
|
||||
id: pyl01_cell_0_temperature
|
||||
- cell: 14
|
||||
voltage:
|
||||
id: pyl01_cell_14_voltage
|
||||
- platform: pylontech
|
||||
pylontech_id: pylontech1
|
||||
battery: 1
|
||||
@@ -40,3 +49,15 @@ text_sensor:
|
||||
id: pyl0_current_state
|
||||
temperature_state:
|
||||
id: pyl0_temperature_state
|
||||
|
||||
button:
|
||||
- platform: template
|
||||
name: Pylontech cell polling
|
||||
on_press:
|
||||
- pylontech.set_cell_polling:
|
||||
id: pylontech0
|
||||
enabled: !lambda return false;
|
||||
# pylontech1 has no cells, so this does nothing
|
||||
- pylontech.set_cell_polling:
|
||||
id: pylontech1
|
||||
enabled: true
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/pylontech/pylontech.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/components/uart/uart_component.h"
|
||||
|
||||
namespace esphome::pylontech::testing {
|
||||
@@ -19,6 +20,7 @@ namespace {
|
||||
class MockUARTComponent : public uart::UARTComponent {
|
||||
public:
|
||||
std::vector<uint8_t> rx;
|
||||
std::string tx; // everything the hub wrote, in order
|
||||
|
||||
void push_rx(std::initializer_list<std::string> lines) {
|
||||
for (const std::string &line : lines) {
|
||||
@@ -26,7 +28,9 @@ class MockUARTComponent : public uart::UARTComponent {
|
||||
}
|
||||
}
|
||||
|
||||
void write_array(const uint8_t *data, size_t len) override {}
|
||||
void write_array(const uint8_t *data, size_t len) override {
|
||||
this->tx.append(reinterpret_cast<const char *>(data), len);
|
||||
}
|
||||
bool read_array(uint8_t *data, size_t len) override {
|
||||
if (this->rx.size() < len) {
|
||||
return false;
|
||||
@@ -48,6 +52,14 @@ class MockUARTComponent : public uart::UARTComponent {
|
||||
// Every record any listener gets, in publish order
|
||||
std::vector<PylontechListener::LineContents> published; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
struct PublishedCells {
|
||||
uint8_t bat_num;
|
||||
cell_mask_t read;
|
||||
PylontechListener::CellArray cells;
|
||||
};
|
||||
// Every cell record any listener gets, in publish order
|
||||
std::vector<PublishedCells> published_cells; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
class TestListener final : public PylontechListener {
|
||||
public:
|
||||
explicit TestListener(uint8_t bat_num) : PylontechListener(bat_num) {}
|
||||
@@ -55,6 +67,9 @@ class TestListener final : public PylontechListener {
|
||||
EXPECT_EQ(line->bat_num, this->bat_num_);
|
||||
published.push_back(*line);
|
||||
}
|
||||
void on_cells_read(const CellArray &cells, cell_mask_t read) override {
|
||||
published_cells.push_back({this->bat_num_, read, cells});
|
||||
}
|
||||
void dump_config() override {}
|
||||
};
|
||||
|
||||
@@ -74,8 +89,21 @@ constexpr const char *NEW_HEADER =
|
||||
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";
|
||||
constexpr const char *REPLY_END = "Command completed successfully\r\n$$\r\n";
|
||||
constexpr const char *CELL_HEADER =
|
||||
"Battery Volt Curr Tempr Base State Volt. State Curr. State Temp. State SOC Coulomb BAL\r\n";
|
||||
// clang-format on
|
||||
|
||||
// A line of the "bat N" reply for cell cell with volt (mV) and tempr (m°C); the record keeps 0.01 °C
|
||||
std::string cell_line(int cell, int volt, int tempr) {
|
||||
char buf[160];
|
||||
snprintf(buf, sizeof(buf),
|
||||
"%d %d -914 %d Dischg Normal Normal Normal 70%% "
|
||||
"34015 mAH N\r\n",
|
||||
cell, volt, tempr);
|
||||
return buf;
|
||||
}
|
||||
|
||||
// An older firmware line for battery bat with pack voltage volt (mV)
|
||||
std::string bat_line(int bat, int volt) {
|
||||
char buf[192];
|
||||
@@ -103,6 +131,34 @@ class PylontechTest : public ::testing::Test {
|
||||
TestListener listeners_[3]{TestListener(1), TestListener(2), TestListener(3)};
|
||||
};
|
||||
|
||||
// The cells of batteries 1 and 3 are read
|
||||
class PylontechCellTest : public PylontechTest {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
PylontechTest::SetUp();
|
||||
published_cells.clear();
|
||||
this->hub_.add_cell_battery(1);
|
||||
this->hub_.add_cell_battery(3);
|
||||
}
|
||||
|
||||
// Lets the hub drain what was pushed and publish everything pending
|
||||
void drain_and_publish_() {
|
||||
for (int i = 0; i < 8; i++) {
|
||||
this->hub_.loop();
|
||||
}
|
||||
}
|
||||
|
||||
// Lets the pause before a bat command pass: moves the loop clock on and runs one idle loop, which also
|
||||
// publishes one pending record
|
||||
void let_pause_pass_() {
|
||||
this->now_ += 50;
|
||||
LoopBlockingGuard{nullptr, nullptr, this->now_};
|
||||
this->hub_.loop();
|
||||
}
|
||||
|
||||
uint32_t now_{1000};
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_F(PylontechTest, BurstIsPublishedAfterTheDrainOnePerLoop) {
|
||||
@@ -249,4 +305,206 @@ TEST_F(PylontechTest, BadLineClearsTheBatteryPendingRecord) {
|
||||
EXPECT_EQ(this->published_()[0].bat_num, 2);
|
||||
}
|
||||
|
||||
TEST_F(PylontechCellTest, CellsAreReadAfterThePwrReplyOneBatteryAtATime) {
|
||||
// Each pause's idle loop publishes one pending record: the battery record, then one battery's cells
|
||||
this->uart_.push_rx({"pwr\r\n@\r\n", HEADER, BAT1, REPLY_END});
|
||||
this->hub_.loop();
|
||||
EXPECT_TRUE(this->uart_.tx.empty()); // the pack needs a pause after its reply
|
||||
EXPECT_TRUE(this->published_().empty());
|
||||
this->let_pause_pass_();
|
||||
EXPECT_EQ(this->uart_.tx, "bat 1\n");
|
||||
ASSERT_EQ(this->published_().size(), 1u);
|
||||
EXPECT_EQ(this->published_()[0].bat_num, 1);
|
||||
|
||||
this->uart_.push_rx({"pylon>bat 1\r\n@\r\n", CELL_HEADER, cell_line(0, 3308, 18300), cell_line(1, 3310, 18400),
|
||||
cell_line(14, 3299, 17900), REPLY_END});
|
||||
this->hub_.loop();
|
||||
EXPECT_TRUE(published_cells.empty());
|
||||
this->let_pause_pass_();
|
||||
EXPECT_EQ(this->uart_.tx, "bat 1\nbat 3\n");
|
||||
ASSERT_EQ(published_cells.size(), 1u);
|
||||
|
||||
this->uart_.push_rx({"pylon>bat 3\r\n@\r\n", CELL_HEADER, cell_line(5, 3250, 16000), REPLY_END});
|
||||
this->hub_.loop();
|
||||
this->let_pause_pass_();
|
||||
EXPECT_EQ(this->uart_.tx, "bat 1\nbat 3\n");
|
||||
ASSERT_EQ(published_cells.size(), 2u);
|
||||
this->hub_.loop();
|
||||
ASSERT_EQ(published_cells.size(), 2u);
|
||||
|
||||
const PublishedCells &b1 = published_cells[0];
|
||||
EXPECT_EQ(b1.bat_num, 1);
|
||||
EXPECT_EQ(b1.read, (1u << 0) | (1u << 1) | (1u << 14));
|
||||
EXPECT_EQ(b1.cells[0].volt, 3308);
|
||||
EXPECT_EQ(b1.cells[0].tempr, 1830);
|
||||
EXPECT_EQ(b1.cells[1].volt, 3310);
|
||||
EXPECT_EQ(b1.cells[14].volt, 3299);
|
||||
EXPECT_EQ(b1.cells[14].tempr, 1790);
|
||||
const PublishedCells &b3 = published_cells[1];
|
||||
EXPECT_EQ(b3.bat_num, 3);
|
||||
EXPECT_EQ(b3.read, 1u << 5);
|
||||
EXPECT_EQ(b3.cells[5].volt, 3250);
|
||||
EXPECT_EQ(b3.cells[5].tempr, 1600);
|
||||
|
||||
// The next pwr reply starts the cycle again
|
||||
this->uart_.push_rx({HEADER, BAT1, REPLY_END});
|
||||
this->hub_.loop();
|
||||
this->let_pause_pass_();
|
||||
EXPECT_EQ(this->uart_.tx, "bat 1\nbat 3\nbat 1\n");
|
||||
}
|
||||
|
||||
TEST_F(PylontechCellTest, CellPollingCanBeTurnedOffAtAnyReplyEnd) {
|
||||
this->hub_.set_cell_polling_enabled(false);
|
||||
this->uart_.push_rx({HEADER, BAT1, REPLY_END});
|
||||
this->drain_and_publish_();
|
||||
this->let_pause_pass_();
|
||||
EXPECT_TRUE(this->uart_.tx.empty());
|
||||
EXPECT_EQ(this->published_().size(), 1u);
|
||||
|
||||
this->hub_.set_cell_polling_enabled(true);
|
||||
this->uart_.push_rx({HEADER, BAT1, REPLY_END});
|
||||
this->hub_.loop();
|
||||
this->let_pause_pass_();
|
||||
EXPECT_EQ(this->uart_.tx, "bat 1\n");
|
||||
|
||||
// Turned off while bat 1 is answering: its cells are still published, bat 3 is not sent
|
||||
this->hub_.set_cell_polling_enabled(false);
|
||||
this->uart_.push_rx({CELL_HEADER, cell_line(0, 3308, 18300), REPLY_END});
|
||||
this->drain_and_publish_();
|
||||
this->let_pause_pass_();
|
||||
EXPECT_EQ(this->uart_.tx, "bat 1\n");
|
||||
ASSERT_EQ(published_cells.size(), 1u);
|
||||
EXPECT_EQ(published_cells[0].bat_num, 1);
|
||||
|
||||
// Turned off during the pause before bat 1: the queued command is dropped and the next pwr lines are
|
||||
// battery lines
|
||||
this->hub_.set_cell_polling_enabled(true);
|
||||
this->uart_.push_rx({HEADER, BAT1, REPLY_END});
|
||||
this->hub_.loop();
|
||||
this->hub_.set_cell_polling_enabled(false);
|
||||
this->let_pause_pass_();
|
||||
EXPECT_EQ(this->uart_.tx, "bat 1\n");
|
||||
this->uart_.push_rx({HEADER, BAT2, REPLY_END});
|
||||
this->drain_and_publish_();
|
||||
ASSERT_EQ(this->published_().size(), 4u);
|
||||
EXPECT_EQ(this->published_()[3].bat_num, 2);
|
||||
}
|
||||
|
||||
TEST_F(PylontechCellTest, UpdateAbandonsAnUnansweredBatReply) {
|
||||
this->uart_.push_rx({HEADER, BAT1, REPLY_END});
|
||||
this->hub_.loop();
|
||||
this->let_pause_pass_();
|
||||
EXPECT_EQ(this->uart_.tx, "bat 1\n");
|
||||
|
||||
// No reply ever comes; the next pwr lines are battery lines again
|
||||
this->hub_.update();
|
||||
this->uart_.push_rx({HEADER, BAT2, REPLY_END});
|
||||
this->drain_and_publish_();
|
||||
this->let_pause_pass_();
|
||||
EXPECT_EQ(this->uart_.tx, "bat 1\npwr\nbat 1\n");
|
||||
ASSERT_EQ(this->published_().size(), 2u);
|
||||
EXPECT_EQ(this->published_()[1].bat_num, 2);
|
||||
EXPECT_TRUE(published_cells.empty());
|
||||
}
|
||||
|
||||
TEST_F(PylontechCellTest, UpdateCancelsABatCommandThatWasNotSentYet) {
|
||||
this->uart_.push_rx({HEADER, BAT1, REPLY_END});
|
||||
this->hub_.loop();
|
||||
this->hub_.update();
|
||||
this->let_pause_pass_();
|
||||
EXPECT_EQ(this->uart_.tx, "pwr\n");
|
||||
}
|
||||
|
||||
TEST_F(PylontechCellTest, LateBatReplyAfterUpdateIsNotMistakenForThePwrReply) {
|
||||
this->uart_.push_rx({HEADER, BAT1, REPLY_END});
|
||||
this->hub_.loop();
|
||||
this->let_pause_pass_();
|
||||
EXPECT_EQ(this->uart_.tx, "bat 1\n");
|
||||
ASSERT_EQ(this->published_().size(), 1u);
|
||||
|
||||
// Battery 1's next record is still unpublished when update() abandons bat 1; the pack answers it only
|
||||
// after the next pwr went out, with cell rows that look like battery lines, then answers pwr
|
||||
this->uart_.push_rx({HEADER, bat_line(1, 49100), REPLY_END});
|
||||
this->hub_.loop();
|
||||
this->hub_.update();
|
||||
this->uart_.push_rx(
|
||||
{CELL_HEADER, cell_line(0, 3308, 18300), cell_line(1, 3310, 18400), cell_line(2, 3312, 18500), REPLY_END});
|
||||
this->uart_.push_rx({"pwr\r\n@\r\n", HEADER, BAT2, REPLY_END});
|
||||
this->drain_and_publish_();
|
||||
this->let_pause_pass_();
|
||||
|
||||
// The late rows are dropped without touching battery 1's record, its "$$" sends nothing, and the pwr
|
||||
// reply starts the cycle once
|
||||
EXPECT_EQ(this->uart_.tx, "bat 1\npwr\nbat 1\n");
|
||||
ASSERT_EQ(this->published_().size(), 3u);
|
||||
EXPECT_EQ(this->published_()[1].bat_num, 1);
|
||||
EXPECT_EQ(this->published_()[1].volt, 49100);
|
||||
EXPECT_EQ(this->published_()[2].bat_num, 2);
|
||||
EXPECT_EQ(this->published_()[2].volt, 46012);
|
||||
EXPECT_TRUE(published_cells.empty());
|
||||
}
|
||||
|
||||
TEST_F(PylontechCellTest, BadCellLinesAreIgnoredAndTheNextReplyStartsEmpty) {
|
||||
this->uart_.push_rx({HEADER, BAT1, REPLY_END});
|
||||
this->hub_.loop();
|
||||
this->let_pause_pass_();
|
||||
this->uart_.push_rx({CELL_HEADER, cell_line(0, 3308, 18300), "0 abc -914 18300 Dischg\r\n",
|
||||
cell_line(15, 3300, 18000), cell_line(14, 70000, 18000), cell_line(13, 3300, 400000),
|
||||
REPLY_END});
|
||||
this->hub_.loop();
|
||||
this->let_pause_pass_();
|
||||
this->uart_.push_rx({CELL_HEADER, cell_line(5, 3250, 16000), cell_line(5, 3251, 16100), REPLY_END});
|
||||
this->drain_and_publish_();
|
||||
|
||||
// The bad line for cell 0 leaves the good one; cell 15 does not exist; 70000 mV and 400 °C are out of
|
||||
// range; the second cell 5 line replaces the first
|
||||
ASSERT_EQ(published_cells.size(), 2u);
|
||||
EXPECT_EQ(published_cells[0].read, 1u << 0);
|
||||
EXPECT_EQ(published_cells[0].cells[0].volt, 3308);
|
||||
EXPECT_EQ(published_cells[1].bat_num, 3);
|
||||
EXPECT_EQ(published_cells[1].read, 1u << 5);
|
||||
EXPECT_EQ(published_cells[1].cells[5].volt, 3251);
|
||||
|
||||
// A reply that read nothing publishes an empty record, not the old cells
|
||||
this->uart_.push_rx({HEADER, BAT1, REPLY_END});
|
||||
this->hub_.loop();
|
||||
this->let_pause_pass_();
|
||||
this->uart_.push_rx({CELL_HEADER, REPLY_END});
|
||||
this->drain_and_publish_();
|
||||
ASSERT_EQ(published_cells.size(), 3u);
|
||||
EXPECT_EQ(published_cells[2].bat_num, 1);
|
||||
EXPECT_EQ(published_cells[2].read, 0u);
|
||||
}
|
||||
|
||||
TEST_F(PylontechCellTest, ANewReplyUnmarksTheUnpublishedRecordOfItsBattery) {
|
||||
// A whole cycle arrives with only the idle loops of the pauses: the battery record and battery 1's
|
||||
// cells are published, battery 3's cells stay pending
|
||||
this->uart_.push_rx({HEADER, BAT1, REPLY_END});
|
||||
this->hub_.loop();
|
||||
this->let_pause_pass_();
|
||||
this->uart_.push_rx({CELL_HEADER, cell_line(0, 3308, 18300), REPLY_END});
|
||||
this->hub_.loop();
|
||||
this->let_pause_pass_();
|
||||
this->uart_.push_rx({CELL_HEADER, cell_line(5, 3250, 16000), REPLY_END});
|
||||
this->hub_.loop();
|
||||
this->uart_.push_rx({HEADER, BAT1, REPLY_END});
|
||||
this->hub_.loop();
|
||||
this->let_pause_pass_();
|
||||
ASSERT_EQ(published_cells.size(), 1u);
|
||||
EXPECT_EQ(published_cells[0].bat_num, 1);
|
||||
|
||||
// The next bat 1 reply is cut short by a gap: battery 3's cells go out, battery 1's wait for "$$"
|
||||
this->uart_.push_rx({CELL_HEADER, cell_line(0, 3400, 19000)});
|
||||
this->hub_.loop();
|
||||
this->hub_.loop();
|
||||
this->hub_.loop();
|
||||
ASSERT_EQ(published_cells.size(), 2u);
|
||||
EXPECT_EQ(published_cells[1].bat_num, 3);
|
||||
this->uart_.push_rx({REPLY_END});
|
||||
this->drain_and_publish_();
|
||||
ASSERT_EQ(published_cells.size(), 3u);
|
||||
EXPECT_EQ(published_cells[2].bat_num, 1);
|
||||
EXPECT_EQ(published_cells[2].cells[0].volt, 3400);
|
||||
}
|
||||
|
||||
} // namespace esphome::pylontech::testing
|
||||
|
||||
@@ -79,7 +79,7 @@ async def test_uart_mock_pylontech(
|
||||
def line_callback(line: str) -> None:
|
||||
if "Line over 199 chars dropped" in line:
|
||||
overflow_lines.append(line)
|
||||
if "malformed battery lines" in line:
|
||||
if "malformed lines" in line:
|
||||
rejected_lines.append(line)
|
||||
|
||||
collector = SensorStateCollector(
|
||||
|
||||
Reference in New Issue
Block a user