ESPHome 2026.11.0-dev
Loading...
Searching...
No Matches
pylontech.cpp
Go to the documentation of this file.
1#include "pylontech.h"
4#include "esphome/core/log.h"
5#include <algorithm>
6#include <cstdio>
7#include <cstring>
8#include <limits>
9
10// Helper macros
11#define PARSE_TOKEN_INT(field, field_name) \
12 { \
13 auto val = parse_number<int>(token_buf); \
14 if (val.has_value() && in_range<decltype(field)>(*val)) { \
15 (field) = *val; \
16 } else { \
17 ESP_LOGV(TAG, "invalid " field_name " in line %s", buffer); \
18 this->reject_line_(); \
19 return; \
20 } \
21 }
22
23#define PARSE_INT(field, field_name) \
24 { \
25 get_token(cursor, token_buf); \
26 PARSE_TOKEN_INT(field, field_name) \
27 }
28
29#define PARSE_STR(field, field_name) \
30 { \
31 get_token(cursor, field); \
32 if (strlen(field) < 2) { \
33 ESP_LOGV(TAG, "too short " field_name " in line %s", buffer); \
34 this->reject_line_(); \
35 return; \
36 } \
37 }
38
40
41ESPHOME_LOG_TAG(TAG, "pylontech");
42
43#ifdef PYLONTECH_CELL_BATTERY_COUNT
44// The pack drops a command that follows its reply without a pause
45static constexpr uint32_t BAT_COMMAND_DELAY_MS = 20;
46#endif
47
48// Rejects values that would wrap in a narrowed field
49template<typename T> static bool in_range(int value) {
50 if constexpr (sizeof(T) >= sizeof(int)) {
51 return true;
52 } else {
53 return value >= std::numeric_limits<T>::min() && value <= std::numeric_limits<T>::max();
54 }
55}
56
57#ifdef PYLONTECH_LISTENER_COUNT
58// Copies the next whitespace separated token, at most TEXT_SENSOR_MAX_LEN - 1 characters, and moves cursor past it
59static void get_token(const char *&cursor, char *token_buf) {
60 // Skip leading whitespace
61 while (*cursor == ' ' || *cursor == '\t') {
62 cursor++;
63 }
64
65 if (*cursor == '\0') {
66 token_buf[0] = 0;
67 return;
68 }
69
70 const char *start = cursor;
71
72 // Find end of field
73 while (*cursor != '\0' && *cursor != ' ' && *cursor != '\t') {
74 cursor++;
75 }
76
77 size_t token_len = std::min(static_cast<size_t>(cursor - start), static_cast<size_t>(TEXT_SENSOR_MAX_LEN - 1));
78 memcpy(token_buf, start, token_len);
79 token_buf[token_len] = 0;
80}
81
82// Slots are kept in battery order, so a battery's slot index is the number of lower batteries in the mask
83static uint8_t slot_index(battery_mask_t batteries, uint8_t bat_index) {
84 return __builtin_popcount(batteries & ((1u << bat_index) - 1u));
85}
86#endif
87
89
91 ESP_LOGCONFIG(TAG, "pylontech:");
92
93#ifdef PYLONTECH_LISTENER_COUNT
94 for (PylontechListener *listener : this->listeners_) {
95 listener->dump_config();
96 }
97#endif
98
99 LOG_UPDATE_INTERVAL(this);
100}
101
103
105#ifdef PYLONTECH_CELL_BATTERY_COUNT
106 // A "bat N" that was not sent yet, or whose reply never finished, must not get in the way of "pwr"
107 this->bat_command_due_ = false;
108 this->late_reply_ = this->bat_reply_ != 0;
109 this->bat_reply_ = 0;
110#endif
111 this->write_str("pwr\n");
112}
113
115 // Drain the UART quickly; pylontech sends large bursts. Logging and publishing wait until it is idle.
116 size_t avail = this->available();
117#ifdef PYLONTECH_LISTENER_COUNT
118 if (avail == 0) {
119#ifdef PYLONTECH_CELL_BATTERY_COUNT
120 if (this->bat_command_due_ &&
121 static_cast<int32_t>(App.get_loop_component_start_time() - this->bat_command_at_) >= 0) {
122 this->bat_command_due_ = false;
123 if (this->cell_polling_enabled_) {
124 char cmd[9]; // "bat 16\n"
125 snprintf(cmd, sizeof(cmd), "bat %u\n", this->bat_reply_);
126 this->write_str(cmd);
127 } else {
128 this->bat_reply_ = 0; // turned off during the pause: nothing is coming
129 }
130 }
131#endif
132 this->dispatch_one_();
133 return;
134 }
135#endif
136 uint8_t buf[64];
137 while (avail > 0) {
138 size_t to_read = std::min(avail, sizeof(buf));
139 if (!this->read_array(buf, to_read)) {
140 break;
141 }
142 avail -= to_read;
143#ifdef PYLONTECH_LISTENER_COUNT
144 for (size_t i = 0; i < to_read; i++) {
145 const char *line = this->line_reader_.feed(buf[i]);
146 if (line != nullptr) {
147 this->process_line_(line);
148 } else if (this->line_reader_.overflowed()) {
150 // Tlow.Id sits near the start, so a too long header still sets the column layout
151 const char *partial = this->line_reader_.partial();
152 if (strncmp(partial, "Power", 5) == 0) {
153 this->parse_header_(partial);
154 }
155 }
156 }
157#endif
158 }
159}
160
161#ifdef PYLONTECH_LISTENER_COUNT
163 for (uint8_t i = 0; this->pending_ != 0 && i < PYLONTECH_BATTERY_COUNT; i++) {
164 const battery_mask_t bit = 1u << i;
165 if ((this->pending_ & bit) == 0) {
166 continue;
167 }
168 this->pending_ &= ~bit;
170 ESP_LOGV(TAG, "Publishing battery %u", l->bat_num);
171 for (PylontechListener *listener : this->listeners_) {
172 if (listener->get_battery() == l->bat_num) {
173 listener->on_line_read(l);
174 }
175 }
176 return;
177 }
178#ifdef PYLONTECH_CELL_BATTERY_COUNT
179 if (this->cells_pending_ != 0) {
180 const uint8_t bat_index = __builtin_ctz(this->cells_pending_);
181 this->cells_pending_ &= ~(1u << bat_index);
182 const uint8_t slot = slot_index(this->cell_batteries_, bat_index);
183 ESP_LOGV(TAG, "Publishing cells of battery %u", bat_index + 1);
184 for (PylontechListener *listener : this->listeners_) {
185 if (listener->get_battery() == bat_index + 1) {
186 listener->on_cells_read(this->cell_slots_[slot], this->cells_read_[slot]);
187 }
188 }
189 return;
190 }
191#endif
192 if (this->rejected_ != 0) {
193 ESP_LOGW(TAG, "Rejected %u malformed lines", this->rejected_);
194 this->rejected_ = 0;
195 }
196}
197
199 this->has_tlow_id_ = strstr(buffer, "Tlow.Id") != nullptr;
200#ifdef PYLONTECH_CELL_BATTERY_COUNT
201 // The header opens a "pwr" reply, so what follows are battery lines even after a late "bat N" reply
202 this->bat_reply_ = 0;
203 this->late_reply_ = false;
204 this->pwr_reply_ = true;
205#endif
206 ESP_LOGV(TAG, "header line %s Tlow.Id: %s", this->has_tlow_id_ ? LOG_STR_LITERAL("with") : LOG_STR_LITERAL("without"),
207 buffer);
208}
209
210// Runs while the UART drains: no logging above VERBOSE and no publishing here
212#ifdef PYLONTECH_CELL_BATTERY_COUNT
213 if (strcmp(buffer, "$$") == 0) {
214 // Every reply ends with "$$"; after the "pwr" reply one "bat N" follows per battery with cell sensors.
215 // A "$$" that ends neither of the two, such as a late reply to an abandoned "bat N", sends nothing.
216 if (this->bat_reply_ != 0) {
217 this->cells_pending_ |= 1u << (this->bat_reply_ - 1);
218 this->send_next_bat_();
219 } else if (this->pwr_reply_) {
220 this->pwr_reply_ = false;
221 this->send_next_bat_();
222 }
223 return;
224 }
225 // The "pwr" header always starts a "pwr" reply, whatever was expected
226 if (this->bat_reply_ != 0 && strncmp(buffer, "Power", 5) != 0) {
227 this->parse_cell_line_(buffer);
228 return;
229 }
230#endif
231 // clang-format off
232 // example lines to parse:
233 // 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
234 // 1 50548 8910 25000 24200 25000 3368 3371 Charge Normal Normal Normal 97% 2021-06-30 20:49:45 Normal Normal 22700 Normal
235 // 1 46012 1255 9100 5300 5500 3047 3091 SysError Low Normal Normal 4% 2025-11-28 17:56:33 Low Normal 7800 Normal
236 // newer firmware example:
237 // 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
238 // 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
239 // clang-format on
240
241 const char *cursor = buffer;
242 char token_buf[TEXT_SENSOR_MAX_LEN] = {0};
243
244 get_token(cursor, token_buf);
245 auto bat = parse_number<int>(token_buf);
246 if (!bat.has_value()) {
247 if (strcmp(token_buf, "Power") == 0) {
248 // header line i.e. "Power Volt Curr" and so on
249 this->parse_header_(buffer);
250 } else {
251 ESP_LOGV(TAG, "unknown line %s", buffer);
252 }
253 return;
254 }
255#ifdef PYLONTECH_CELL_BATTERY_COUNT
256 if (this->late_reply_) {
257 ESP_LOGV(TAG, "dropped late line %s", buffer); // cell rows of the abandoned "bat N" look like battery lines
258 return;
259 }
260#endif
261 const unsigned bat_index = static_cast<unsigned>(*bat) - 1u;
262 if (bat_index >= MAX_BATTERIES || (this->batteries_ & (1u << bat_index)) == 0) {
263 return; // no listener for this battery
264 }
265 const battery_mask_t slot_bit = 1u << slot_index(this->batteries_, bat_index);
266 // Cleared until the whole line parses, so a bad line never publishes a half written slot
267 this->pending_ &= ~slot_bit;
268 PylontechListener::LineContents &l = this->slots_[__builtin_ctz(slot_bit)];
269 l.bat_num = *bat;
270
271 PARSE_INT(l.volt, "Volt");
272 PARSE_INT(l.curr, "Curr");
273 PARSE_INT(l.tempr, "Tempr");
274 PARSE_INT(l.tlow, "Tlow");
275 if (this->has_tlow_id_) {
276 get_token(cursor, token_buf); // Skip Tlow.Id
277 }
278 PARSE_INT(l.thigh, "Thigh");
279 if (this->has_tlow_id_) {
280 get_token(cursor, token_buf); // Skip Thigh.Id
281 }
282 PARSE_INT(l.vlow, "Vlow");
283 if (this->has_tlow_id_) {
284 get_token(cursor, token_buf); // Skip Vlow.Id
285 }
286 PARSE_INT(l.vhigh, "Vhigh");
287 if (this->has_tlow_id_) {
288 get_token(cursor, token_buf); // Skip Vhigh.Id
289 }
290 PARSE_STR(l.base_st, "Base.St");
291 PARSE_STR(l.volt_st, "Volt.St");
292 PARSE_STR(l.curr_st, "Curr.St");
293 PARSE_STR(l.temp_st, "Temp.St");
294 get_token(cursor, token_buf);
295 if (char *pct = strchr(token_buf, '%')) {
296 *pct = '\0';
297 }
298 PARSE_TOKEN_INT(l.coulomb, "Coulomb");
299 get_token(cursor, token_buf); // Skip Date
300 get_token(cursor, token_buf); // Skip Time
301 get_token(cursor, token_buf); // Skip B.V.St
302 get_token(cursor, token_buf); // Skip B.T.St
303 PARSE_INT(l.mostempr, "Mostempr");
304
305 this->pending_ |= slot_bit;
306}
307
308#ifdef PYLONTECH_CELL_BATTERY_COUNT
309// Picks the next cell battery above the one just read and schedules "bat N" for it; the cycle ends when none
310// is left or cell polling was turned off
312 // Battery n is bit n - 1, so clearing the low bat_reply_ bits leaves the batteries above it
313 const battery_mask_t remaining =
314 this->cell_batteries_ & static_cast<battery_mask_t>(~((1u << this->bat_reply_) - 1u));
315 if (remaining == 0 || !this->cell_polling_enabled_) {
316 this->bat_reply_ = 0;
317 return;
318 }
319 const uint8_t bat_index = __builtin_ctz(remaining);
320 this->bat_reply_ = bat_index + 1;
321 // The slot is about to be rewritten, so an unpublished record must not go out half filled
322 this->cells_pending_ &= ~(1u << bat_index);
323 this->cells_read_[slot_index(this->cell_batteries_, bat_index)] = 0;
324 // loop() writes the command once the pause has passed and the line is idle
325 this->bat_command_due_ = true;
326 this->bat_command_at_ = App.get_loop_component_start_time() + BAT_COMMAND_DELAY_MS;
327}
328
329// Runs while the UART drains, like process_line_
331 // clang-format off
332 // example lines to parse, the reply to "bat N":
333 // Battery Volt Curr Tempr Base State Volt. State Curr. State Temp. State SOC Coulomb BAL
334 // 0 3308 -914 18300 Dischg Normal Normal Normal 70% 34015 mAH N
335 // clang-format on
336
337 const char *cursor = buffer;
338 char token_buf[TEXT_SENSOR_MAX_LEN] = {0};
339
340 get_token(cursor, token_buf);
341 auto cell = parse_number<int>(token_buf);
342 if (!cell.has_value() || *cell < 0 || *cell >= MAX_CELLS) {
343 // the header, the echoed command or "Command completed successfully"
344 ESP_LOGV(TAG, "unknown line %s", buffer);
345 return;
346 }
348 PARSE_INT(c.volt, "Volt");
349 get_token(cursor, token_buf); // Skip Curr
350 int32_t tempr; // m°C
351 PARSE_INT(tempr, "Tempr");
352 tempr /= 10;
353 if (!in_range<int16_t>(tempr)) {
354 ESP_LOGV(TAG, "invalid Tempr in line %s", buffer);
355 this->reject_line_();
356 return;
357 }
358 c.tempr = tempr;
359
360 const uint8_t slot = slot_index(this->cell_batteries_, this->bat_reply_ - 1);
361 this->cell_slots_[slot][*cell] = c;
362 this->cells_read_[slot] |= 1u << *cell;
363}
364#endif
365#endif
366
367} // namespace esphome::pylontech
368
369#undef PARSE_TOKEN_INT
370#undef PARSE_INT
371#undef PARSE_STR
uint8_t l
Definition bl0906.h:0
uint32_t IRAM_ATTR HOT get_loop_component_start_time() const
Get the cached time in milliseconds from when the current component started its loop execution.
const char * partial()
The partial line, NUL terminated, for prompts with no line end; the first N - 1 characters on the byt...
Definition line_reader.h:60
bool overflowed() const
True only right after the feed() that found the line too long; the rest of that line is then dropped.
Definition line_reader.h:66
char * feed(uint8_t c)
Feed one byte; returns the completed line (empty lines included), valid until the next call,...
Definition line_reader.h:23
void log_if_overflowed(const char *tag) const
Log the shared "line dropped" warning when the last feed() overflowed.
Definition line_reader.h:69
StaticVector< PylontechListener *, PYLONTECH_LISTENER_COUNT > listeners_
Definition pylontech.h:101
void process_line_(const char *buffer)
void loop() override
Read data once available.
void parse_header_(const char *buffer)
std::array< PylontechListener::CellArray, PYLONTECH_CELL_BATTERY_COUNT > cell_slots_
Definition pylontech.h:109
void update() override
Schedule data readings.
void setup() override
Setup the sensor and test for a connection.
std::array< cell_mask_t, PYLONTECH_CELL_BATTERY_COUNT > cells_read_
Definition pylontech.h:115
std::array< PylontechListener::LineContents, PYLONTECH_BATTERY_COUNT > slots_
Definition pylontech.h:105
void parse_cell_line_(const char *buffer)
optional< std::array< uint8_t, N > > read_array()
Definition uart.h:43
void discard_available()
Read and drop every byte waiting in the receive buffer.
Definition uart.cpp:36
void write_str(const char *str)
Definition uart.h:37
uint32_t buffer
Definition gif.cpp:50
uint16_t battery_mask_t
Definition pylontech.h:17
ESPHOME_LOG_TAG(TAG, "pylontech")
optional< T > parse_number(const char *str)
Parse an unsigned decimal number from a null-terminated string.
Definition helpers.h:1352
Application App
Global storage of Application pointer - only one Application can exist.
static void uint32_t