[renogy_inverter_ble] Add Renogy inverter BLE sensor component (#16754)

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Celsius Aray
2026-10-12 12:56:50 -10:00
committed by GitHub
co-authored by J. Nick Koston J. Nick Koston
parent 1e13b7f973
commit 150e32c4be
7 changed files with 481 additions and 0 deletions
+1
View File
@@ -472,6 +472,7 @@ esphome/components/rc522/* @glmnet
esphome/components/rc522_i2c/* @glmnet
esphome/components/rc522_spi/* @glmnet
esphome/components/rd03d/* @jasstrong
esphome/components/renogy_inverter_ble/* @emilioaray-dev
esphome/components/resampler/speaker/* @kahrendt
esphome/components/restart/* @esphome/core
esphome/components/rf_bridge/* @jesserockz
@@ -0,0 +1,40 @@
import esphome.codegen as cg
from esphome.components import ble_client
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
AUTO_LOAD = ["sensor"]
CODEOWNERS = ["@emilioaray-dev"]
DEPENDENCIES = ["ble_client"]
DOMAIN = "renogy_inverter_ble"
MULTI_CONF = True
CONF_RENOGY_INVERTER_BLE_ID = "renogy_inverter_ble_id"
renogy_inverter_ble_ns = cg.esphome_ns.namespace(DOMAIN)
RenogyInverterBle = renogy_inverter_ble_ns.class_(
"RenogyInverterBle", ble_client.BLEClientNode, cg.PollingComponent
)
RENOGY_INVERTER_BLE_COMPONENT_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_RENOGY_INVERTER_BLE_ID): cv.use_id(RenogyInverterBle),
}
)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(RenogyInverterBle),
}
)
.extend(ble_client.BLE_CLIENT_SCHEMA)
.extend(cv.polling_component_schema("30s"))
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await ble_client.register_ble_node(var, config)
@@ -0,0 +1,248 @@
#include "renogy_inverter_ble.h"
#ifdef USE_ESP32
#include <cstring>
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::renogy_inverter_ble {
ESPHOME_LOG_TAG(TAG, "renogy_inverter_ble");
static constexpr uint16_t SERVICE_NOTIFY_UUID = 0xFFF0;
static constexpr uint16_t CHAR_NOTIFY_UUID = 0xFFF1;
static constexpr uint16_t SERVICE_WRITE_UUID = 0xFFD0;
static constexpr uint16_t CHAR_WRITE_UUID = 0xFFD1;
static constexpr uint16_t CHAR_INIT_UUID = 0xFFD4;
static constexpr uint8_t MODBUS_DEVICE_ID = 0x20;
static constexpr uint8_t MODBUS_READ_HOLDING = 0x03;
static constexpr uint16_t REG_MAIN = 4000;
static constexpr uint16_t REG_MAIN_WORDS = 32;
static constexpr uint16_t REG_LOAD = 4408;
static constexpr uint16_t REG_LOAD_WORDS = 6;
// Watchdog: returns the cycle to IDLE when the inverter stops answering
static constexpr uint32_t CYCLE_TIMEOUT_MS = 3000;
// Gap the inverter needs between two consecutive register reads
static constexpr uint32_t LOAD_READ_DELAY_MS = 300;
static constexpr uint32_t CYCLE_TIMEOUT_ID = 0;
static constexpr uint32_t LOAD_READ_ID = 1;
static void publish(sensor::Sensor *s, float value) {
if (s != nullptr) {
s->publish_state(value);
}
}
void RenogyInverterBle::dump_config() {
ESP_LOGCONFIG(TAG, "Renogy Inverter BLE:");
LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "AC input voltage", this->ac_input_voltage_sensor_);
LOG_SENSOR(" ", "AC output voltage", this->ac_output_voltage_sensor_);
LOG_SENSOR(" ", "AC output current", this->ac_output_current_sensor_);
LOG_SENSOR(" ", "AC output frequency", this->ac_output_frequency_sensor_);
LOG_SENSOR(" ", "Input frequency", this->input_frequency_sensor_);
LOG_SENSOR(" ", "Battery voltage", this->battery_voltage_sensor_);
LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
LOG_SENSOR(" ", "Load current", this->load_current_sensor_);
LOG_SENSOR(" ", "Load active power", this->load_active_power_sensor_);
LOG_SENSOR(" ", "Load apparent power", this->load_apparent_power_sensor_);
}
void RenogyInverterBle::abort_cycle_() {
this->cancel_timeout(CYCLE_TIMEOUT_ID);
this->cancel_timeout(LOAD_READ_ID);
this->frame_len_ = 0;
this->state_ = State::IDLE;
}
void RenogyInverterBle::read_register_(uint16_t start_register, uint16_t word_count) {
std::array<uint8_t, 8> req{MODBUS_DEVICE_ID,
MODBUS_READ_HOLDING,
static_cast<uint8_t>(start_register >> 8),
static_cast<uint8_t>(start_register),
static_cast<uint8_t>(word_count >> 8),
static_cast<uint8_t>(word_count)};
const uint16_t crc = crc16(req.data(), 6);
req[6] = crc & 0xFF; // Modbus sends the CRC low byte first
req[7] = crc >> 8;
this->frame_len_ = 0;
const esp_err_t status =
esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->write_handle_,
req.size(), req.data(), ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE);
if (status != ESP_OK) {
ESP_LOGW(TAG, "write_char (reg %u) failed, status=%d", start_register, status);
this->abort_cycle_();
}
}
void RenogyInverterBle::start_cycle_() {
this->set_timeout(CYCLE_TIMEOUT_ID, CYCLE_TIMEOUT_MS, [this]() {
ESP_LOGW(TAG, "Read cycle timed out (state=%u); resetting", static_cast<uint8_t>(this->state_));
this->abort_cycle_();
});
if (this->init_handle_ == 0) {
// No init characteristic discovered; try the main read directly
this->state_ = State::MAIN;
this->read_register_(REG_MAIN, REG_MAIN_WORDS);
return;
}
this->state_ = State::INIT;
const esp_err_t status = esp_ble_gattc_read_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(),
this->init_handle_, ESP_GATT_AUTH_REQ_NONE);
if (status != ESP_OK) {
ESP_LOGW(TAG, "init read_char failed, status=%d", status);
this->abort_cycle_();
}
}
void RenogyInverterBle::update() {
if (this->node_state != espbt::ClientState::ESTABLISHED) {
ESP_LOGD(TAG, "Not connected yet; skipping poll");
return;
}
if (this->state_ != State::IDLE) {
ESP_LOGD(TAG, "Previous read cycle still in progress; skipping");
return;
}
this->start_cycle_();
}
void RenogyInverterBle::on_frame_complete_(uint16_t len) {
// A Modbus frame with its CRC appended checks to zero
if (len < 5 || crc16(this->frame_.data(), len) != 0) {
ESP_LOGW(TAG, "CRC mismatch (state=%u, len=%u)", static_cast<uint8_t>(this->state_), len);
this->abort_cycle_();
return;
}
// Exception response: function code with the high bit set, third byte is the exception code
if ((this->frame_[1] & 0x80) != 0) {
ESP_LOGW(TAG, "Modbus exception 0x%02X (state=%u)", this->frame_[2], static_cast<uint8_t>(this->state_));
this->abort_cycle_();
return;
}
// Only the reply to the outstanding read is accepted: same device, same function, the requested word count
const bool awaiting_reply = this->state_ == State::MAIN || this->state_ == State::LOAD;
const uint16_t expected_words = this->state_ == State::MAIN ? REG_MAIN_WORDS : REG_LOAD_WORDS;
if (!awaiting_reply || this->frame_[0] != MODBUS_DEVICE_ID || this->frame_[1] != MODBUS_READ_HOLDING ||
this->frame_[2] != 2 * expected_words) {
ESP_LOGW(TAG, "Unexpected response %02X %02X %02X (state=%u)", this->frame_[0], this->frame_[1], this->frame_[2],
static_cast<uint8_t>(this->state_));
this->abort_cycle_();
return;
}
auto reg16 = [this](uint8_t i) { return encode_uint16(this->frame_[3 + 2 * i], this->frame_[4 + 2 * i]); };
if (this->state_ == State::MAIN) {
publish(this->ac_input_voltage_sensor_, reg16(0) * 0.1f);
publish(this->ac_output_voltage_sensor_, reg16(2) * 0.1f);
publish(this->ac_output_current_sensor_, reg16(3) * 0.01f);
publish(this->ac_output_frequency_sensor_, reg16(4) * 0.01f);
publish(this->battery_voltage_sensor_, reg16(5) * 0.1f);
publish(this->temperature_sensor_, reg16(6) * 0.1f);
publish(this->input_frequency_sensor_, reg16(9) * 0.01f);
this->frame_len_ = 0;
this->set_timeout(LOAD_READ_ID, LOAD_READ_DELAY_MS, [this]() {
this->state_ = State::LOAD;
this->read_register_(REG_LOAD, REG_LOAD_WORDS);
});
return;
}
publish(this->load_current_sensor_, reg16(0) * 0.01f);
publish(this->load_active_power_sensor_, static_cast<float>(reg16(1)));
publish(this->load_apparent_power_sensor_, static_cast<float>(reg16(2)));
this->abort_cycle_();
}
void RenogyInverterBle::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) {
switch (event) {
case ESP_GATTC_DISCONNECT_EVT: {
this->abort_cycle_();
this->node_state = espbt::ClientState::IDLE;
break;
}
case ESP_GATTC_SEARCH_CMPL_EVT: {
auto *notify_chr = this->parent_->get_characteristic(espbt::ESPBTUUID::from_uint16(SERVICE_NOTIFY_UUID),
espbt::ESPBTUUID::from_uint16(CHAR_NOTIFY_UUID));
auto *write_chr = this->parent_->get_characteristic(espbt::ESPBTUUID::from_uint16(SERVICE_WRITE_UUID),
espbt::ESPBTUUID::from_uint16(CHAR_WRITE_UUID));
auto *init_chr = this->parent_->get_characteristic(espbt::ESPBTUUID::from_uint16(SERVICE_WRITE_UUID),
espbt::ESPBTUUID::from_uint16(CHAR_INIT_UUID));
if (notify_chr == nullptr || write_chr == nullptr) {
ESP_LOGE(TAG, "Required characteristics not found; not a Renogy inverter?");
break;
}
this->notify_handle_ = notify_chr->handle;
this->write_handle_ = write_chr->handle;
this->init_handle_ = (init_chr != nullptr) ? init_chr->handle : 0;
ESP_LOGI(TAG, "handles: notify=%u write=%u init=%u", this->notify_handle_, this->write_handle_,
this->init_handle_);
const esp_err_t status = this->parent_->register_for_notify(this->notify_handle_);
if (status != ESP_OK) {
ESP_LOGW(TAG, "register_for_notify failed, status=%d", status);
}
break;
}
case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
// Other nodes on the same client see this event too
if (param->reg_for_notify.handle != this->notify_handle_) {
break;
}
if (param->reg_for_notify.status != ESP_GATT_OK) {
ESP_LOGW(TAG, "Notification registration failed, status=%d", param->reg_for_notify.status);
break;
}
this->node_state = espbt::ClientState::ESTABLISHED;
ESP_LOGI(TAG, "Connected; will poll the inverter every update interval");
break;
}
case ESP_GATTC_READ_CHAR_EVT: {
if (this->state_ != State::INIT || param->read.handle != this->init_handle_) {
break;
}
if (param->read.status != ESP_GATT_OK) {
ESP_LOGW(TAG, "init read failed, status=%d", param->read.status);
this->abort_cycle_();
break;
}
// Init handshake done; the inverter now answers Modbus reads
this->state_ = State::MAIN;
this->read_register_(REG_MAIN, REG_MAIN_WORDS);
break;
}
case ESP_GATTC_NOTIFY_EVT: {
if (param->notify.handle != this->notify_handle_) {
break;
}
const uint16_t value_len = param->notify.value_len;
if (this->frame_len_ + value_len > MAX_FRAME) {
ESP_LOGW(TAG, "Frame overflow; resetting");
this->abort_cycle_();
break;
}
memcpy(&this->frame_[this->frame_len_], param->notify.value, value_len);
this->frame_len_ += value_len;
if (this->frame_len_ < 3) {
break;
}
// An exception frame ([id][func|0x80][exc][crc][crc]) is 5 bytes; a normal response is
// [id][func][byte_count][...data][crc][crc]
const uint16_t expected_len = ((this->frame_[1] & 0x80) != 0) ? 5 : 3 + this->frame_[2] + 2;
if (this->frame_len_ >= expected_len) {
this->on_frame_complete_(expected_len);
}
break;
}
default:
break;
}
}
} // namespace esphome::renogy_inverter_ble
#endif // USE_ESP32
@@ -0,0 +1,59 @@
#pragma once
#ifdef USE_ESP32
#include <esp_gattc_api.h>
#include <array>
#include "esphome/components/ble_client/ble_client.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
namespace esphome::renogy_inverter_ble {
namespace espbt = esphome::esp32_ble_tracker;
// Largest Modbus response: 3 header + 2 * 32 data + 2 CRC bytes
static constexpr size_t MAX_FRAME = 69;
// Renogy inverter BLE GATT (RIV1230PU). Notify char 0xFFF1 (svc 0xFFF0), write char 0xFFD1
// (svc 0xFFD0), init char 0xFFD4 (svc 0xFFD0). Modbus device id 0x20, function 0x03.
class RenogyInverterBle : public ble_client::BLEClientNode, public PollingComponent {
SUB_SENSOR(ac_input_voltage)
SUB_SENSOR(ac_output_voltage)
SUB_SENSOR(ac_output_current)
SUB_SENSOR(ac_output_frequency)
SUB_SENSOR(input_frequency)
SUB_SENSOR(battery_voltage)
SUB_SENSOR(temperature)
SUB_SENSOR(load_current)
SUB_SENSOR(load_active_power)
SUB_SENSOR(load_apparent_power)
public:
void dump_config() override;
void update() override;
void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
esp_ble_gattc_cb_param_t *param) override;
protected:
// Read cycle: IDLE -> (update) INIT read(0xFFD4) -> MAIN read(4000) -> LOAD read(4408) -> IDLE
enum class State : uint8_t { IDLE, INIT, MAIN, LOAD };
void start_cycle_();
void read_register_(uint16_t start_register, uint16_t word_count);
void on_frame_complete_(uint16_t len);
void abort_cycle_();
State state_{State::IDLE};
uint8_t frame_len_{0};
uint16_t notify_handle_{0};
uint16_t write_handle_{0};
uint16_t init_handle_{0};
// Reassembles the Modbus response, which arrives in MTU-sized notification chunks
std::array<uint8_t, MAX_FRAME> frame_;
};
} // namespace esphome::renogy_inverter_ble
#endif // USE_ESP32
@@ -0,0 +1,99 @@
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BATTERY_VOLTAGE,
CONF_TEMPERATURE,
DEVICE_CLASS_APPARENT_POWER,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_FREQUENCY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_VOLTAGE,
STATE_CLASS_MEASUREMENT,
UNIT_AMPERE,
UNIT_CELSIUS,
UNIT_HERTZ,
UNIT_VOLT,
UNIT_VOLT_AMPS,
UNIT_WATT,
)
from esphome.types import ConfigType
from . import CONF_RENOGY_INVERTER_BLE_ID, RENOGY_INVERTER_BLE_COMPONENT_SCHEMA
CODEOWNERS = ["@emilioaray-dev"]
DEPENDENCIES = ["renogy_inverter_ble"]
CONF_AC_INPUT_VOLTAGE = "ac_input_voltage"
CONF_AC_OUTPUT_VOLTAGE = "ac_output_voltage"
CONF_AC_OUTPUT_CURRENT = "ac_output_current"
CONF_AC_OUTPUT_FREQUENCY = "ac_output_frequency"
CONF_INPUT_FREQUENCY = "input_frequency"
CONF_LOAD_CURRENT = "load_current"
CONF_LOAD_ACTIVE_POWER = "load_active_power"
CONF_LOAD_APPARENT_POWER = "load_apparent_power"
VOLTAGE_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=1,
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
)
CURRENT_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_AMPERE,
accuracy_decimals=2,
device_class=DEVICE_CLASS_CURRENT,
state_class=STATE_CLASS_MEASUREMENT,
)
FREQUENCY_SCHEMA = sensor.sensor_schema(
unit_of_measurement=UNIT_HERTZ,
accuracy_decimals=2,
device_class=DEVICE_CLASS_FREQUENCY,
state_class=STATE_CLASS_MEASUREMENT,
)
CONFIG_SCHEMA = RENOGY_INVERTER_BLE_COMPONENT_SCHEMA.extend(
{
cv.Optional(CONF_AC_INPUT_VOLTAGE): VOLTAGE_SCHEMA,
cv.Optional(CONF_AC_OUTPUT_VOLTAGE): VOLTAGE_SCHEMA,
cv.Optional(CONF_AC_OUTPUT_CURRENT): CURRENT_SCHEMA,
cv.Optional(CONF_AC_OUTPUT_FREQUENCY): FREQUENCY_SCHEMA,
cv.Optional(CONF_INPUT_FREQUENCY): FREQUENCY_SCHEMA,
cv.Optional(CONF_BATTERY_VOLTAGE): VOLTAGE_SCHEMA,
cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema(
unit_of_measurement=UNIT_CELSIUS,
accuracy_decimals=1,
device_class=DEVICE_CLASS_TEMPERATURE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_LOAD_CURRENT): CURRENT_SCHEMA,
cv.Optional(CONF_LOAD_ACTIVE_POWER): sensor.sensor_schema(
unit_of_measurement=UNIT_WATT,
accuracy_decimals=0,
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_LOAD_APPARENT_POWER): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT_AMPS,
accuracy_decimals=0,
device_class=DEVICE_CLASS_APPARENT_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
}
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_RENOGY_INVERTER_BLE_ID])
sensors = sensor.sub_sensors(config)
await sensors(CONF_AC_INPUT_VOLTAGE, hub.set_ac_input_voltage_sensor)
await sensors(CONF_AC_OUTPUT_VOLTAGE, hub.set_ac_output_voltage_sensor)
await sensors(CONF_AC_OUTPUT_CURRENT, hub.set_ac_output_current_sensor)
await sensors(CONF_AC_OUTPUT_FREQUENCY, hub.set_ac_output_frequency_sensor)
await sensors(CONF_INPUT_FREQUENCY, hub.set_input_frequency_sensor)
await sensors(CONF_BATTERY_VOLTAGE, hub.set_battery_voltage_sensor)
await sensors(CONF_TEMPERATURE, hub.set_temperature_sensor)
await sensors(CONF_LOAD_CURRENT, hub.set_load_current_sensor)
await sensors(CONF_LOAD_ACTIVE_POWER, hub.set_load_active_power_sensor)
await sensors(CONF_LOAD_APPARENT_POWER, hub.set_load_apparent_power_sensor)
@@ -0,0 +1,31 @@
ble_client:
- mac_address: 14:9C:EF:01:02:03
id: inverter_client
renogy_inverter_ble:
- id: inv0
ble_client_id: inverter_client
sensor:
- platform: renogy_inverter_ble
renogy_inverter_ble_id: inv0
ac_input_voltage:
name: AC input voltage
ac_output_voltage:
name: AC output voltage
ac_output_current:
name: AC output current
ac_output_frequency:
name: AC output frequency
input_frequency:
name: Input frequency
battery_voltage:
name: Battery voltage
temperature:
name: Temperature
load_current:
name: Load current
load_active_power:
name: Load active power
load_apparent_power:
name: Load apparent power
@@ -0,0 +1,3 @@
packages:
ble: !include ../../test_build_components/common/ble/esp32-idf.yaml
renogy_inverter_ble: !include common.yaml