From 36f2654fa67cdfc76741b6329e76f789671ed775 Mon Sep 17 00:00:00 2001 From: functionpointer Date: Wed, 4 Feb 2026 17:06:59 +0100 Subject: [PATCH] [pylontech] Refactor parser to support new firmware version and SysError (#12300) --- esphome/components/pylontech/pylontech.cpp | 138 ++++++++++++++++++--- esphome/components/pylontech/pylontech.h | 7 +- 2 files changed, 122 insertions(+), 23 deletions(-) diff --git a/esphome/components/pylontech/pylontech.cpp b/esphome/components/pylontech/pylontech.cpp index 74b7caefb2..61b5356c90 100644 --- a/esphome/components/pylontech/pylontech.cpp +++ b/esphome/components/pylontech/pylontech.cpp @@ -2,6 +2,28 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +// Helper macros +#define PARSE_INT(field, field_name) \ + { \ + get_token(token_buf); \ + auto val = parse_number(token_buf); \ + if (val.has_value()) { \ + (field) = val.value(); \ + } else { \ + ESP_LOGD(TAG, "invalid " field_name " in line %s", buffer.substr(0, buffer.size() - 2).c_str()); \ + return; \ + } \ + } + +#define PARSE_STR(field, field_name) \ + { \ + get_token(field); \ + if (strlen(field) < 2) { \ + ESP_LOGD(TAG, "too short " field_name " in line %s", buffer.substr(0, buffer.size() - 2).c_str()); \ + return; \ + } \ + } + namespace esphome { namespace pylontech { @@ -64,33 +86,106 @@ void PylontechComponent::loop() { void PylontechComponent::process_line_(std::string &buffer) { ESP_LOGV(TAG, "Read from serial: %s", buffer.substr(0, buffer.size() - 2).c_str()); // clang-format off - // example line 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 - // 1 50548 8910 25000 24200 25000 3368 3371 Charge Normal Normal Normal 97% 2021-06-30 20:49:45 Normal Normal 22700 Normal + // 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 + // 1 50548 8910 25000 24200 25000 3368 3371 Charge Normal Normal Normal 97% 2021-06-30 20:49:45 Normal Normal 22700 Normal + // 1 46012 1255 9100 5300 5500 3047 3091 SysError Low Normal Normal 4% 2025-11-28 17:56:33 Low Normal 7800 Normal + // newer firmware example: + // 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 + // 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 // clang-format on PylontechListener::LineContents l{}; - char mostempr_s[6]; - const int parsed = sscanf( // NOLINT - buffer.c_str(), "%d %d %d %d %d %d %d %d %7s %7s %7s %7s %d%% %*d-%*d-%*d %*d:%*d:%*d %*s %*s %5s %*s", // NOLINT - &l.bat_num, &l.volt, &l.curr, &l.tempr, &l.tlow, &l.thigh, &l.vlow, &l.vhigh, l.base_st, l.volt_st, // NOLINT - l.curr_st, l.temp_st, &l.coulomb, mostempr_s); // NOLINT - if (l.bat_num <= 0) { - ESP_LOGD(TAG, "invalid bat_num in line %s", buffer.substr(0, buffer.size() - 2).c_str()); - return; + const char *cursor = buffer.c_str(); + 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 != '\r') { + 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); + auto val = parse_number(token_buf); + if (val.has_value() && val.value() > 0) { + l.bat_num = val.value(); + } else if (strcmp(token_buf, "Power") == 0) { + // header line i.e. "Power Volt Curr" and so on + this->has_tlow_id_ = buffer.find("Tlow.Id") != std::string::npos; + ESP_LOGD(TAG, "header line %s Tlow.Id: %s", this->has_tlow_id_ ? "with" : "without", + buffer.substr(0, buffer.size() - 2).c_str()); + return; + } else { + ESP_LOGD(TAG, "unknown line %s", buffer.substr(0, buffer.size() - 2).c_str()); + return; + } } - if (parsed != 14) { - ESP_LOGW(TAG, "invalid line: found only %d items in %s", parsed, buffer.substr(0, buffer.size() - 2).c_str()); - return; + PARSE_INT(l.volt, "Volt"); + PARSE_INT(l.curr, "Curr"); + PARSE_INT(l.tempr, "Tempr"); + PARSE_INT(l.tlow, "Tlow"); + if (this->has_tlow_id_) { + get_token(token_buf); // Skip Tlow.Id } - auto mostempr_parsed = parse_number(mostempr_s); - if (mostempr_parsed.has_value()) { - l.mostempr = mostempr_parsed.value(); - } else { - l.mostempr = -300; - ESP_LOGW(TAG, "bat_num %d: received no mostempr", l.bat_num); + PARSE_INT(l.thigh, "Thigh"); + if (this->has_tlow_id_) { + get_token(token_buf); // Skip Thigh.Id } + PARSE_INT(l.vlow, "Vlow"); + if (this->has_tlow_id_) { + get_token(token_buf); // Skip Vlow.Id + } + PARSE_INT(l.vhigh, "Vhigh"); + if (this->has_tlow_id_) { + get_token(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); + for (char &i : token_buf) { + if (i == '%') { + i = 0; + break; + } + } + auto coul_val = parse_number(token_buf); + if (coul_val.has_value()) { + l.coulomb = coul_val.value(); + } else { + ESP_LOGD(TAG, "invalid Coulomb in line %s", buffer.substr(0, buffer.size() - 2).c_str()); + return; + } + } + 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 + PARSE_INT(l.mostempr, "Mostempr"); + + ESP_LOGD(TAG, "successful line %s", buffer.substr(0, buffer.size() - 2).c_str()); for (PylontechListener *listener : this->listeners_) { listener->on_line_read(&l); @@ -101,3 +196,6 @@ float PylontechComponent::get_setup_priority() const { return setup_priority::DA } // namespace pylontech } // namespace esphome + +#undef PARSE_INT +#undef PARSE_STR diff --git a/esphome/components/pylontech/pylontech.h b/esphome/components/pylontech/pylontech.h index 3282cb4d9f..10c669ad9a 100644 --- a/esphome/components/pylontech/pylontech.h +++ b/esphome/components/pylontech/pylontech.h @@ -8,14 +8,14 @@ namespace esphome { namespace pylontech { static const uint8_t NUM_BUFFERS = 20; -static const uint8_t TEXT_SENSOR_MAX_LEN = 8; +static const uint8_t TEXT_SENSOR_MAX_LEN = 14; class PylontechListener { public: struct LineContents { int bat_num = 0, volt, curr, tempr, tlow, thigh, vlow, vhigh, coulomb, mostempr; - 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]; + char base_st[TEXT_SENSOR_MAX_LEN] = {0}, volt_st[TEXT_SENSOR_MAX_LEN] = {0}, curr_st[TEXT_SENSOR_MAX_LEN] = {0}, + temp_st[TEXT_SENSOR_MAX_LEN] = {0}; }; virtual void on_line_read(LineContents *line); @@ -45,6 +45,7 @@ class PylontechComponent : public PollingComponent, public uart::UARTDevice { std::string buffer_[NUM_BUFFERS]; int buffer_index_write_ = 0; int buffer_index_read_ = 0; + bool has_tlow_id_ = false; std::vector listeners_{}; };