From f72857151af118e0a05cdf1bd7c99bb09fbedcb2 Mon Sep 17 00:00:00 2001 From: functionpointer Date: Sat, 17 Oct 2026 21:28:52 +0200 Subject: [PATCH] [pylontech] Add individual cell voltage and temperature sensors (#16852) Co-authored-by: Michael Co-authored-by: ritschys <117527311+ritschys@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/pylontech/__init__.py | 32 ++- esphome/components/pylontech/pylontech.cpp | 225 +++++++++++---- esphome/components/pylontech/pylontech.h | 55 +++- .../components/pylontech/sensor/__init__.py | 48 +++- .../pylontech/sensor/pylontech_sensor.cpp | 23 ++ .../pylontech/sensor/pylontech_sensor.h | 16 +- esphome/core/defines.h | 2 + tests/components/pylontech/__init__.py | 4 +- tests/components/pylontech/common.yaml | 21 ++ tests/components/pylontech/pylontech_test.cpp | 260 +++++++++++++++++- tests/integration/test_uart_mock_pylontech.py | 2 +- 11 files changed, 630 insertions(+), 58 deletions(-) diff --git a/esphome/components/pylontech/__init__.py b/esphome/components/pylontech/__init__.py index 8bd57f252c1..e15f8f04c82 100644 --- a/esphome/components/pylontech/__init__.py +++ b/esphome/components/pylontech/__init__.py @@ -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]) diff --git a/esphome/components/pylontech/pylontech.cpp b/esphome/components/pylontech/pylontech.cpp index 5e48e8b726c..90cb4a8b855 100644 --- a/esphome/components/pylontech/pylontech.cpp +++ b/esphome/components/pylontech/pylontech.cpp @@ -1,7 +1,9 @@ #include "pylontech.h" +#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include +#include #include #include @@ -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 static bool in_range(int value) { if constexpr (sizeof(T) >= sizeof(int)) { @@ -47,6 +54,37 @@ template 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(cursor - start), static_cast(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(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(cursor - start), static_cast(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(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(*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(~((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(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(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 diff --git a/esphome/components/pylontech/pylontech.h b/esphome/components/pylontech/pylontech.h index aa652056dca..77403478291 100644 --- a/esphome/components/pylontech/pylontech.h +++ b/esphome/components/pylontech/pylontech.h @@ -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; 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 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 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 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 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 }; diff --git a/esphome/components/pylontech/sensor/__init__.py b/esphome/components/pylontech/sensor/__init__.py index 51aceace3e7..490331df70f 100644 --- a/esphome/components/pylontech/sensor/__init__.py +++ b/esphome/components/pylontech/sensor/__init__.py @@ -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)) diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.cpp b/esphome/components/pylontech/sensor/pylontech_sensor.cpp index bead0387bef..c7612f46609 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.cpp +++ b/esphome/components/pylontech/sensor/pylontech_sensor.cpp @@ -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 diff --git a/esphome/components/pylontech/sensor/pylontech_sensor.h b/esphome/components/pylontech/sensor/pylontech_sensor.h index 6b466b74664..75bc9ba01df 100644 --- a/esphome/components/pylontech/sensor/pylontech_sensor.h +++ b/esphome/components/pylontech/sensor/pylontech_sensor.h @@ -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 cells_; +#endif }; } // namespace esphome::pylontech diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 331a637b6b3..a2480108a72 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -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 diff --git a/tests/components/pylontech/__init__.py b/tests/components/pylontech/__init__.py index c16210128f6..15b9ef0e2a6 100644 --- a/tests/components/pylontech/__init__.py +++ b/tests/components/pylontech/__init__.py @@ -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. diff --git a/tests/components/pylontech/common.yaml b/tests/components/pylontech/common.yaml index 537450a3b68..37a1c4514b8 100644 --- a/tests/components/pylontech/common.yaml +++ b/tests/components/pylontech/common.yaml @@ -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 diff --git a/tests/components/pylontech/pylontech_test.cpp b/tests/components/pylontech/pylontech_test.cpp index 479530571c0..6041bd572b1 100644 --- a/tests/components/pylontech/pylontech_test.cpp +++ b/tests/components/pylontech/pylontech_test.cpp @@ -10,6 +10,7 @@ #include #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 rx; + std::string tx; // everything the hub wrote, in order void push_rx(std::initializer_list 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(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 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 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 diff --git a/tests/integration/test_uart_mock_pylontech.py b/tests/integration/test_uart_mock_pylontech.py index fffaa902072..a434ed5bdee 100644 --- a/tests/integration/test_uart_mock_pylontech.py +++ b/tests/integration/test_uart_mock_pylontech.py @@ -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(