[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:
functionpointer
2026-10-17 19:28:52 +00:00
committed by GitHub
co-authored by Michael ritschys J. Nick Koston J. Nick Koston
parent 5f8f075e76
commit f72857151a
11 changed files with 630 additions and 58 deletions
+29 -3
View File
@@ -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])
+179 -46
View File
@@ -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
+52 -3
View File
@@ -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
+2
View File
@@ -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
+3 -1
View File
@@ -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.
+21
View File
@@ -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
+259 -1
View File
@@ -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(