Pylontech integration (solar battery bank) (#4688)

Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
This commit is contained in:
functionpointer 2023-11-27 23:43:03 +01:00 committed by GitHub
parent 4e6d3729e1
commit 4b6fbd5db0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 528 additions and 0 deletions

View file

@ -240,6 +240,7 @@ esphome/components/preferences/* @esphome/core
esphome/components/psram/* @esphome/core
esphome/components/pulse_meter/* @TrentHouliston @cstaahl @stevebaxter
esphome/components/pvvx_mithermometer/* @pasiz
esphome/components/pylontech/* @functionpointer
esphome/components/qmp6988/* @andrewpc
esphome/components/qr_code/* @wjtje
esphome/components/qwiic_pir/* @kahrendt

View file

@ -0,0 +1,46 @@
import logging
import esphome.codegen as cg
from esphome.components import uart
import esphome.config_validation as cv
from esphome.const import CONF_ID
_LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@functionpointer"]
DEPENDENCIES = ["uart"]
MULTI_CONF = True
CONF_PYLONTECH_ID = "pylontech_id"
CONF_BATTERY = "battery"
pylontech_ns = cg.esphome_ns.namespace("pylontech")
PylontechComponent = pylontech_ns.class_(
"PylontechComponent", cg.PollingComponent, uart.UARTDevice
)
PylontechBattery = pylontech_ns.class_("PylontechBattery")
CV_NUM_BATTERIES = cv.int_range(1, 6)
PYLONTECH_COMPONENT_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_PYLONTECH_ID): cv.use_id(PylontechComponent),
cv.Required(CONF_BATTERY): CV_NUM_BATTERIES,
}
)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(PylontechComponent),
}
)
.extend(cv.polling_component_schema("60s"))
.extend(uart.UART_DEVICE_SCHEMA)
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)

View file

@ -0,0 +1,91 @@
#include "pylontech.h"
#include "esphome/core/log.h"
namespace esphome {
namespace pylontech {
static const char *const TAG = "pylontech";
static const int MAX_DATA_LENGTH_BYTES = 256;
static const uint8_t ASCII_LF = 0x0A;
PylontechComponent::PylontechComponent() {}
void PylontechComponent::dump_config() {
this->check_uart_settings(115200, 1, esphome::uart::UART_CONFIG_PARITY_NONE, 8);
ESP_LOGCONFIG(TAG, "pylontech:");
if (this->is_failed()) {
ESP_LOGE(TAG, "Connection with pylontech failed!");
}
for (PylontechListener *listener : this->listeners_) {
listener->dump_config();
}
LOG_UPDATE_INTERVAL(this);
}
void PylontechComponent::setup() {
ESP_LOGCONFIG(TAG, "Setting up pylontech...");
while (this->available() != 0) {
this->read();
}
}
void PylontechComponent::update() { this->write_str("pwr\n"); }
void PylontechComponent::loop() {
uint8_t data;
// pylontech sends a lot of data very suddenly
// we need to quickly put it all into our own buffer, otherwise the uart's buffer will overflow
while (this->available() > 0) {
if (this->read_byte(&data)) {
buffer_[buffer_index_write_] += (char) data;
if (buffer_[buffer_index_write_].back() == static_cast<char>(ASCII_LF) ||
buffer_[buffer_index_write_].length() >= MAX_DATA_LENGTH_BYTES) {
// complete line received
buffer_index_write_ = (buffer_index_write_ + 1) % NUM_BUFFERS;
}
}
}
// only process one line per call of loop() to not block esphome for too long
if (buffer_index_read_ != buffer_index_write_) {
this->process_line_(buffer_[buffer_index_read_]);
buffer_[buffer_index_read_].clear();
buffer_index_read_ = (buffer_index_read_ + 1) % NUM_BUFFERS;
}
}
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
// clang-format on
PylontechListener::LineContents l{};
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 %d %*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, &l.mostempr); // NOLINT
if (l.bat_num <= 0) {
ESP_LOGD(TAG, "invalid bat_num in 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;
}
for (PylontechListener *listener : this->listeners_) {
listener->on_line_read(&l);
}
}
float PylontechComponent::get_setup_priority() const { return setup_priority::DATA; }
} // namespace pylontech
} // namespace esphome

View file

@ -0,0 +1,53 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/components/uart/uart.h"
namespace esphome {
namespace pylontech {
static const uint8_t NUM_BUFFERS = 20;
static const uint8_t TEXT_SENSOR_MAX_LEN = 8;
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];
};
virtual void on_line_read(LineContents *line);
virtual void dump_config();
};
class PylontechComponent : public PollingComponent, public uart::UARTDevice {
public:
PylontechComponent();
/// Schedule data readings.
void update() override;
/// Read data once available
void loop() override;
/// Setup the sensor and test for a connection.
void setup() override;
void dump_config() override;
float get_setup_priority() const override;
void register_listener(PylontechListener *listener) { this->listeners_.push_back(listener); }
protected:
void process_line_(std::string &buffer);
// ring buffer
std::string buffer_[NUM_BUFFERS];
int buffer_index_write_ = 0;
int buffer_index_read_ = 0;
std::vector<PylontechListener *> listeners_{};
};
} // namespace pylontech
} // namespace esphome

View file

@ -0,0 +1,97 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import sensor
from esphome.const import (
CONF_VOLTAGE,
CONF_CURRENT,
CONF_TEMPERATURE,
UNIT_VOLT,
UNIT_AMPERE,
DEVICE_CLASS_VOLTAGE,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_BATTERY,
UNIT_CELSIUS,
UNIT_PERCENT,
CONF_ID,
)
from .. import (
CONF_PYLONTECH_ID,
PYLONTECH_COMPONENT_SCHEMA,
CONF_BATTERY,
pylontech_ns,
)
PylontechSensor = pylontech_ns.class_("PylontechSensor", cg.Component)
CONF_COULOMB = "coulomb"
CONF_TEMPERATURE_LOW = "temperature_low"
CONF_TEMPERATURE_HIGH = "temperature_high"
CONF_VOLTAGE_LOW = "voltage_low"
CONF_VOLTAGE_HIGH = "voltage_high"
CONF_MOS_TEMPERATURE = "mos_temperature"
TYPES: dict[str, cv.Schema] = {
CONF_VOLTAGE: sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_VOLTAGE,
),
CONF_CURRENT: sensor.sensor_schema(
unit_of_measurement=UNIT_AMPERE,
accuracy_decimals=3,
device_class=DEVICE_CLASS_CURRENT,
),
CONF_TEMPERATURE: sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
),
CONF_TEMPERATURE_LOW: sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
),
CONF_TEMPERATURE_HIGH: sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
),
CONF_VOLTAGE_LOW: sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
),
CONF_VOLTAGE_HIGH: sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
),
CONF_COULOMB: sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=0,
device_class=DEVICE_CLASS_BATTERY,
),
CONF_MOS_TEMPERATURE: sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
),
}
CONFIG_SCHEMA = PYLONTECH_COMPONENT_SCHEMA.extend(
{cv.GenerateID(): cv.declare_id(PylontechSensor)}
).extend({cv.Optional(marker): schema for marker, schema in TYPES.items()})
async def to_code(config):
paren = await cg.get_variable(config[CONF_PYLONTECH_ID])
bat = cg.new_Pvariable(config[CONF_ID], config[CONF_BATTERY])
for marker in TYPES:
if marker_config := config.get(marker):
sens = await sensor.new_sensor(marker_config)
cg.add(getattr(bat, f"set_{marker}_sensor")(sens))
cg.add(paren.register_listener(bat))

View file

@ -0,0 +1,60 @@
#include "pylontech_sensor.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome {
namespace pylontech {
static const char *const TAG = "pylontech.sensor";
PylontechSensor::PylontechSensor(int8_t bat_num) { this->bat_num_ = bat_num; }
void PylontechSensor::dump_config() {
ESP_LOGCONFIG(TAG, "Pylontech Sensor:");
ESP_LOGCONFIG(TAG, " Battery %d", this->bat_num_);
LOG_SENSOR(" ", "Voltage", this->voltage_sensor_);
LOG_SENSOR(" ", "Current", this->current_sensor_);
LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
LOG_SENSOR(" ", "Temperature low", this->temperature_low_sensor_);
LOG_SENSOR(" ", "Temperature high", this->temperature_high_sensor_);
LOG_SENSOR(" ", "Voltage low", this->voltage_low_sensor_);
LOG_SENSOR(" ", "Voltage high", this->voltage_high_sensor_);
LOG_SENSOR(" ", "Coulomb", this->coulomb_sensor_);
LOG_SENSOR(" ", "MOS Temperature", this->mos_temperature_sensor_);
}
void PylontechSensor::on_line_read(PylontechListener::LineContents *line) {
if (this->bat_num_ != line->bat_num) {
return;
}
if (this->voltage_sensor_ != nullptr) {
this->voltage_sensor_->publish_state(((float) line->volt) / 1000.0f);
}
if (this->current_sensor_ != nullptr) {
this->current_sensor_->publish_state(((float) line->curr) / 1000.0f);
}
if (this->temperature_sensor_ != nullptr) {
this->temperature_sensor_->publish_state(((float) line->tempr) / 1000.0f);
}
if (this->temperature_low_sensor_ != nullptr) {
this->temperature_low_sensor_->publish_state(((float) line->tlow) / 1000.0f);
}
if (this->temperature_high_sensor_ != nullptr) {
this->temperature_high_sensor_->publish_state(((float) line->thigh) / 1000.0f);
}
if (this->voltage_low_sensor_ != nullptr) {
this->voltage_low_sensor_->publish_state(((float) line->vlow) / 1000.0f);
}
if (this->voltage_high_sensor_ != nullptr) {
this->voltage_high_sensor_->publish_state(((float) line->vhigh) / 1000.0f);
}
if (this->coulomb_sensor_ != nullptr) {
this->coulomb_sensor_->publish_state(line->coulomb);
}
if (this->mos_temperature_sensor_ != nullptr) {
this->mos_temperature_sensor_->publish_state(((float) line->mostempr) / 1000.0f);
}
}
} // namespace pylontech
} // namespace esphome

View file

@ -0,0 +1,32 @@
#pragma once
#include "../pylontech.h"
#include "esphome/components/sensor/sensor.h"
namespace esphome {
namespace pylontech {
class PylontechSensor : public PylontechListener, public Component {
public:
PylontechSensor(int8_t bat_num);
void dump_config() override;
SUB_SENSOR(voltage)
SUB_SENSOR(current)
SUB_SENSOR(temperature)
SUB_SENSOR(temperature_low)
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;
protected:
int8_t bat_num_;
};
} // namespace pylontech
} // namespace esphome

View file

@ -0,0 +1,41 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import text_sensor
from esphome.const import CONF_ID
from .. import (
CONF_PYLONTECH_ID,
PYLONTECH_COMPONENT_SCHEMA,
CONF_BATTERY,
pylontech_ns,
)
PylontechTextSensor = pylontech_ns.class_("PylontechTextSensor", cg.Component)
CONF_BASE_STATE = "base_state"
CONF_VOLTAGE_STATE = "voltage_state"
CONF_CURRENT_STATE = "current_state"
CONF_TEMPERATURE_STATE = "temperature_state"
MARKERS: list[str] = [
CONF_BASE_STATE,
CONF_VOLTAGE_STATE,
CONF_CURRENT_STATE,
CONF_TEMPERATURE_STATE,
]
CONFIG_SCHEMA = PYLONTECH_COMPONENT_SCHEMA.extend(
{cv.GenerateID(): cv.declare_id(PylontechTextSensor)}
).extend({cv.Optional(marker): text_sensor.text_sensor_schema() for marker in MARKERS})
async def to_code(config):
paren = await cg.get_variable(config[CONF_PYLONTECH_ID])
bat = cg.new_Pvariable(config[CONF_ID], config[CONF_BATTERY])
for marker in MARKERS:
if marker_config := config.get(marker):
var = await text_sensor.new_text_sensor(marker_config)
cg.add(getattr(bat, f"set_{marker}_text_sensor")(var))
cg.add(paren.register_listener(bat))

View file

@ -0,0 +1,40 @@
#include "pylontech_text_sensor.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome {
namespace pylontech {
static const char *const TAG = "pylontech.textsensor";
PylontechTextSensor::PylontechTextSensor(int8_t bat_num) { this->bat_num_ = bat_num; }
void PylontechTextSensor::dump_config() {
ESP_LOGCONFIG(TAG, "Pylontech Text Sensor:");
ESP_LOGCONFIG(TAG, " Battery %d", this->bat_num_);
LOG_TEXT_SENSOR(" ", "Base state", this->base_state_text_sensor_);
LOG_TEXT_SENSOR(" ", "Voltage state", this->voltage_state_text_sensor_);
LOG_TEXT_SENSOR(" ", "Current state", this->current_state_text_sensor_);
LOG_TEXT_SENSOR(" ", "Temperature state", this->temperature_state_text_sensor_);
}
void PylontechTextSensor::on_line_read(PylontechListener::LineContents *line) {
if (this->bat_num_ != line->bat_num) {
return;
}
if (this->base_state_text_sensor_ != nullptr) {
this->base_state_text_sensor_->publish_state(std::string(line->base_st));
}
if (this->voltage_state_text_sensor_ != nullptr) {
this->voltage_state_text_sensor_->publish_state(std::string(line->volt_st));
}
if (this->current_state_text_sensor_ != nullptr) {
this->current_state_text_sensor_->publish_state(std::string(line->curr_st));
}
if (this->temperature_state_text_sensor_ != nullptr) {
this->temperature_state_text_sensor_->publish_state(std::string(line->temp_st));
}
}
} // namespace pylontech
} // namespace esphome

View file

@ -0,0 +1,26 @@
#pragma once
#include "../pylontech.h"
#include "esphome/components/text_sensor/text_sensor.h"
namespace esphome {
namespace pylontech {
class PylontechTextSensor : public PylontechListener, public Component {
public:
PylontechTextSensor(int8_t bat_num);
void dump_config() override;
SUB_TEXT_SENSOR(base_state)
SUB_TEXT_SENSOR(voltage_state)
SUB_TEXT_SENSOR(current_state)
SUB_TEXT_SENSOR(temperature_state)
void on_line_read(LineContents *line) override;
protected:
int8_t bat_num_;
};
} // namespace pylontech
} // namespace esphome

View file

@ -99,6 +99,12 @@ pipsolar:
id: inverter0
uart_id: uart115200
pylontech:
- id: pylontech0
uart_id: uart115200
- id: pylontech1
uart_id: uart115200
sx1509:
- id: sx1509_hub
address: 0x3E
@ -113,6 +119,30 @@ dac7678:
internal_reference: true
sensor:
- platform: pylontech
pylontech_id: pylontech0
battery: 1
voltage:
id: pyl01_voltage
current:
id: pyl01_current
coulomb:
id: pyl01_soc
mos_temperature:
id: pyl01_mos_temperature
- platform: pylontech
pylontech_id: pylontech1
battery: 1
voltage:
id: pyl13_voltage
temperature_low:
id: pyl13_temperature_low
temperature_high:
id: pyl13_temperature_high
voltage_low:
id: pyl13_voltage_low
voltage_high:
id: pyl13_voltage_high
- platform: homeassistant
entity_id: sensor.hello_world
id: ha_hello_world
@ -589,6 +619,17 @@ number:
name: Tuya Number Copy
text_sensor:
- platform: pylontech
pylontech_id: pylontech0
battery: 1
base_state:
id: pyl0_base_state
voltage_state:
id: pyl0_voltage_state
current_state:
id: pyl0_current_state
temperature_state:
id: pyl0_temperature_state
- platform: pipsolar
pipsolar_id: inverter0
device_mode: