Skip to content

Commit

Permalink
Feature: JK BMS Home Assistent integration
Browse files Browse the repository at this point in the history
* pylontech HA integration: remove unused method/variable

* make MqttHandlePylontechHassClass::publishConfig() private.
  there are no outside users of that method.

* rename to MqttHandleBatteryHass

* battery HA integration: merge methods and bring back forceUpdate().
  even though the forceUpdate() method was not in use before, it makes
  sense to implement it and use it when the battery config changes.
  rather than controlling a separate flag, it now changes the _doPublish
  flag of the class, which also triggers publishing the device config to
  Home Assistant when an MQTT connection problem was detected. since
  both situations are now handled similarly, we can merge the loop() and
  publishConfig() methods.

* battery: provider specific sensors for HA

* move Battery MQTT loop to BatteryStats
  the BatteryStats class should handle the MQTT publishing, including the
  interval. for the calculation of a reasonable Home Assistent expiration
  value this class now also knows the maximum publish interval.

* JK BMS: fix publishing values for Home Assistent
  Home Assistent values expire, because we set them to expire after three
  MQTT publish durations. for that reason, we need to re-publish all
  values after our self-inflicted full publish interval.

* define JK BMS sensors for Home Assistent
  • Loading branch information
schlimmchen committed Feb 5, 2024
1 parent c2b4993 commit 2f15af4
Show file tree
Hide file tree
Showing 9 changed files with 288 additions and 236 deletions.
1 change: 0 additions & 1 deletion include/Battery.h
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ class BatteryClass {

Task _loopTask;

uint32_t _lastMqttPublish = 0;
mutable std::mutex _mutex;
std::unique_ptr<BatteryProvider> _upProvider = nullptr;
};
Expand Down
13 changes: 12 additions & 1 deletion include/BatteryStats.h
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,24 @@ class BatteryStats {
// convert stats to JSON for web application live view
virtual void getLiveViewData(JsonVariant& root) const;

virtual void mqttPublish() const;
void mqttLoop();

// the interval at which all battery datums will be re-published, even
// if they did not change. used to calculate Home Assistent expiration.
virtual uint32_t getMqttFullPublishIntervalMs() const;

bool isValid() const { return _lastUpdateSoC > 0 && _lastUpdate > 0; }

protected:
virtual void mqttPublish() const;

String _manufacturer = "unknown";
uint8_t _SoC = 0;
uint32_t _lastUpdateSoC = 0;
uint32_t _lastUpdate = 0;

private:
uint32_t _lastMqttPublish = 0;
};

class PylontechBatteryStats : public BatteryStats {
Expand Down Expand Up @@ -89,6 +98,8 @@ class JkBmsBatteryStats : public BatteryStats {

void mqttPublish() const final;

uint32_t getMqttFullPublishIntervalMs() const final { return 60 * 1000; }

void updateFrom(JkBms::DataPointContainer const& dp);

private:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,10 @@
#include <ArduinoJson.h>
#include <TaskSchedulerDeclarations.h>

class MqttHandlePylontechHassClass {
class MqttHandleBatteryHassClass {
public:
void init(Scheduler& scheduler);
void publishConfig();
void forceUpdate();
void forceUpdate() { _doPublish = true; }

private:
void loop();
Expand All @@ -19,9 +18,8 @@ class MqttHandlePylontechHassClass {

Task _loopTask;

bool _wasConnected = false;
bool _updateForced = false;
bool _doPublish = true;
String serial = "0001"; // pseudo-serial, can be replaced in future with real serialnumber
};

extern MqttHandlePylontechHassClass MqttHandlePylontechHass;
extern MqttHandleBatteryHassClass MqttHandleBatteryHass;
12 changes: 1 addition & 11 deletions src/Battery.cpp
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include "Battery.h"
#include "MessageOutput.h"
#include "MqttSettings.h"
#include "PylontechCanReceiver.h"
#include "JkBmsController.h"
#include "VictronSmartShunt.h"
Expand Down Expand Up @@ -76,14 +75,5 @@ void BatteryClass::loop()

_upProvider->loop();

CONFIG_T& config = Configuration.get();

if (!MqttSettings.getConnected()
|| (millis() - _lastMqttPublish) < (config.Mqtt.PublishInterval * 1000)) {
return;
}

_upProvider->getStats()->mqttPublish();

_lastMqttPublish = millis();
_upProvider->getStats()->mqttLoop();
}
35 changes: 30 additions & 5 deletions src/BatteryStats.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include "Configuration.h"
#include "MqttSettings.h"
#include "JkBmsDataPoints.h"
#include "MqttSettings.h"

template<typename T>
static void addLiveViewInSection(JsonVariant& root,
Expand Down Expand Up @@ -187,6 +188,31 @@ void JkBmsBatteryStats::getJsonData(JsonVariant& root, bool verbose) const
}
}

void BatteryStats::mqttLoop()
{
auto& config = Configuration.get();

if (!MqttSettings.getConnected()
|| (millis() - _lastMqttPublish) < (config.Mqtt.PublishInterval * 1000)) {
return;
}

mqttPublish();

_lastMqttPublish = millis();
}

uint32_t BatteryStats::getMqttFullPublishIntervalMs() const
{
auto& config = Configuration.get();

// this is the default interval, see mqttLoop(). mqttPublish()
// implementations in derived classes may choose to publish some values
// with a lower frequency and hence implement this method with a different
// return value.
return config.Mqtt.PublishInterval * 1000;
}

void BatteryStats::mqttPublish() const
{
MqttSettings.publish(F("battery/manufacturer"), _manufacturer);
Expand Down Expand Up @@ -236,11 +262,10 @@ void JkBmsBatteryStats::mqttPublish() const
Label::BatterySoCPercent // already published by base class
};

CONFIG_T& config = Configuration.get();

// publish all topics every minute, unless the retain flag is enabled
bool fullPublish = _lastFullMqttPublish + 60 * 1000 < millis();
fullPublish &= !config.Mqtt.Retain;
// regularly publish all topics regardless of whether or not their value changed
bool neverFullyPublished = _lastFullMqttPublish == 0;
bool intervalElapsed = _lastFullMqttPublish + getMqttFullPublishIntervalMs() < millis();
bool fullPublish = neverFullyPublished || intervalElapsed;

for (auto iter = _dataPoints.cbegin(); iter != _dataPoints.cend(); ++iter) {
// skip data points that did not change since last published
Expand Down
237 changes: 237 additions & 0 deletions src/MqttHandleBatteryHass.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
// SPDX-License-Identifier: GPL-2.0-or-later

#include "PylontechCanReceiver.h"
#include "Battery.h"
#include "MqttHandleBatteryHass.h"
#include "Configuration.h"
#include "MqttSettings.h"
#include "Utils.h"

MqttHandleBatteryHassClass MqttHandleBatteryHass;

void MqttHandleBatteryHassClass::init(Scheduler& scheduler)
{
scheduler.addTask(_loopTask);
_loopTask.setCallback(std::bind(&MqttHandleBatteryHassClass::loop, this));
_loopTask.setIterations(TASK_FOREVER);
_loopTask.enable();
}

void MqttHandleBatteryHassClass::loop()
{
CONFIG_T& config = Configuration.get();

if (!config.Battery.Enabled) { return; }

if (!config.Mqtt.Hass.Enabled) { return; }

// TODO(schlimmchen): this cannot make sure that transient
// connection problems are actually always noticed.
if (!MqttSettings.getConnected()) {
_doPublish = true;
return;
}

// only publish HA config once when (re-)connecting
// to the MQTT broker or on config changes.
if (!_doPublish) { return; }

// the MQTT battery provider does not re-publish the SoC under a different
// known topic. we don't know the manufacture either. HASS auto-discovery
// for that provider makes no sense.
if (config.Battery.Provider != 2) {
publishSensor("Manufacturer", "mdi:factory", "manufacturer");
publishSensor("Data Age", "mdi:timer-sand", "dataAge", "duration", "measurement", "s");
publishSensor("State of Charge (SoC)", "mdi:battery-medium", "stateOfCharge", "battery", "measurement", "%");
}

switch (config.Battery.Provider) {
case 0: // Pylontech Battery
publishSensor("Battery voltage", NULL, "voltage", "voltage", "measurement", "V");
publishSensor("Battery current", NULL, "current", "current", "measurement", "A");
publishSensor("Temperature", NULL, "temperature", "temperature", "measurement", "°C");
publishSensor("State of Health (SOH)", "mdi:heart-plus", "stateOfHealth", NULL, "measurement", "%");
publishSensor("Charge voltage (BMS)", NULL, "settings/chargeVoltage", "voltage", "measurement", "V");
publishSensor("Charge current limit", NULL, "settings/chargeCurrentLimitation", "current", "measurement", "A");
publishSensor("Discharge current limit", NULL, "settings/dischargeCurrentLimitation", "current", "measurement", "A");

publishBinarySensor("Alarm Discharge current", "mdi:alert", "alarm/overCurrentDischarge", "1", "0");
publishBinarySensor("Warning Discharge current", "mdi:alert-outline", "warning/highCurrentDischarge", "1", "0");

publishBinarySensor("Alarm Temperature low", "mdi:thermometer-low", "alarm/underTemperature", "1", "0");
publishBinarySensor("Warning Temperature low", "mdi:thermometer-low", "warning/lowTemperature", "1", "0");

publishBinarySensor("Alarm Temperature high", "mdi:thermometer-high", "alarm/overTemperature", "1", "0");
publishBinarySensor("Warning Temperature high", "mdi:thermometer-high", "warning/highTemperature", "1", "0");

publishBinarySensor("Alarm Voltage low", "mdi:alert", "alarm/underVoltage", "1", "0");
publishBinarySensor("Warning Voltage low", "mdi:alert-outline", "warning/lowVoltage", "1", "0");

publishBinarySensor("Alarm Voltage high", "mdi:alert", "alarm/overVoltage", "1", "0");
publishBinarySensor("Warning Voltage high", "mdi:alert-outline", "warning/highVoltage", "1", "0");

publishBinarySensor("Alarm BMS internal", "mdi:alert", "alarm/bmsInternal", "1", "0");
publishBinarySensor("Warning BMS internal", "mdi:alert-outline", "warning/bmsInternal", "1", "0");

publishBinarySensor("Alarm High charge current", "mdi:alert", "alarm/overCurrentCharge", "1", "0");
publishBinarySensor("Warning High charge current", "mdi:alert-outline", "warning/highCurrentCharge", "1", "0");

publishBinarySensor("Charge enabled", "mdi:battery-arrow-up", "charging/chargeEnabled", "1", "0");
publishBinarySensor("Discharge enabled", "mdi:battery-arrow-down", "charging/dischargeEnabled", "1", "0");
publishBinarySensor("Charge immediately", "mdi:alert", "charging/chargeImmediately", "1", "0");
break;
case 1: // JK BMS
// caption icon topic dev. class state class unit
publishSensor("Voltage", "mdi:battery-charging", "BatteryVoltageMilliVolt", "voltage", "measurement", "mV");
publishSensor("Current", "mdi:current-dc", "BatteryCurrentMilliAmps", "current", "measurement", "mA");
publishSensor("BMS Temperature", "mdi:thermometer", "BmsTempCelsius", "temperature", "measurement", "°C");
publishSensor("Cell Voltage Diff", "mdi:battery-alert", "CellDiffMilliVolt", "voltage", "measurement", "mV");
publishSensor("Charge Cycles", "mdi:counter", "BatteryCycles");
publishSensor("Cycle Capacity", "mdi:battery-sync", "BatteryCycleCapacity");

publishBinarySensor("Charging Possible", "mdi:battery-arrow-up", "status/ChargingActive", "1", "0");
publishBinarySensor("Discharging Possible", "mdi:battery-arrow-down", "status/DischargingActive", "1", "0");
publishBinarySensor("Balancing Active", "mdi:scale-balance", "status/BalancingActive", "1", "0");

#define PBS(a, b, c) publishBinarySensor("Alarm: " a, "mdi:" b, "alarms/" c, "1", "0")
PBS("Low Capacity", "battery-alert-variant-outline", "LowCapacity");
PBS("BMS Overtemperature", "thermometer-alert", "BmsOvertemperature");
PBS("Charging Overvoltage", "fuse-alert", "ChargingOvervoltage");
PBS("Discharge Undervoltage", "fuse-alert", "DischargeUndervoltage");
PBS("Battery Overtemperature", "thermometer-alert", "BatteryOvertemperature");
PBS("Charging Overcurrent", "fuse-alert", "ChargingOvercurrent");
PBS("Discharging Overcurrent", "fuse-alert", "DischargeOvercurrent");
PBS("Cell Voltage Difference", "battery-alert", "CellVoltageDifference");
PBS("Battery Box Overtemperature", "thermometer-alert", "BatteryBoxOvertemperature");
PBS("Battery Undertemperature", "thermometer-alert", "BatteryUndertemperature");
PBS("Cell Overvoltage", "battery-alert", "CellOvervoltage");
PBS("Cell Undervoltage", "battery-alert", "CellUndervoltage");
#undef PBS
break;
case 2: // SoC from MQTT
break;
case 3: // Victron SmartShunt
break;
}

_doPublish = false;
}

void MqttHandleBatteryHassClass::publishSensor(const char* caption, const char* icon, const char* subTopic, const char* deviceClass, const char* stateClass, const char* unitOfMeasurement )
{
String sensorId = caption;
sensorId.replace(" ", "_");
sensorId.replace(".", "");
sensorId.replace("(", "");
sensorId.replace(")", "");
sensorId.toLowerCase();

String configTopic = "sensor/dtu_battery_" + serial
+ "/" + sensorId
+ "/config";

String statTopic = MqttSettings.getPrefix() + "battery/";
// omit serial to avoid a breaking change
// statTopic.concat(serial);
// statTopic.concat("/");
statTopic.concat(subTopic);

DynamicJsonDocument root(1024);
if (!Utils::checkJsonAlloc(root, __FUNCTION__, __LINE__)) {
return;
}
root["name"] = caption;
root["stat_t"] = statTopic;
root["uniq_id"] = serial + "_" + sensorId;

if (icon != NULL) {
root["icon"] = icon;
}

if (unitOfMeasurement != NULL) {
root["unit_of_meas"] = unitOfMeasurement;
}

JsonObject deviceObj = root.createNestedObject("dev");
createDeviceInfo(deviceObj);

if (Configuration.get().Mqtt.Hass.Expire) {
root["exp_aft"] = Battery.getStats()->getMqttFullPublishIntervalMs() * 3;
}
if (deviceClass != NULL) {
root["dev_cla"] = deviceClass;
}
if (stateClass != NULL) {
root["stat_cla"] = stateClass;
}

char buffer[512];
serializeJson(root, buffer);
publish(configTopic, buffer);

}

void MqttHandleBatteryHassClass::publishBinarySensor(const char* caption, const char* icon, const char* subTopic, const char* payload_on, const char* payload_off)
{
String sensorId = caption;
sensorId.replace(" ", "_");
sensorId.replace(".", "");
sensorId.replace("(", "");
sensorId.replace(")", "");
sensorId.replace(":", "");
sensorId.toLowerCase();

String configTopic = "binary_sensor/dtu_battery_" + serial
+ "/" + sensorId
+ "/config";

String statTopic = MqttSettings.getPrefix() + "battery/";
// omit serial to avoid a breaking change
// statTopic.concat(serial);
// statTopic.concat("/");
statTopic.concat(subTopic);

DynamicJsonDocument root(1024);
if (!Utils::checkJsonAlloc(root, __FUNCTION__, __LINE__)) {
return;
}
root["name"] = caption;
root["uniq_id"] = serial + "_" + sensorId;
root["stat_t"] = statTopic;
root["pl_on"] = payload_on;
root["pl_off"] = payload_off;

if (icon != NULL) {
root["icon"] = icon;
}

JsonObject deviceObj = root.createNestedObject("dev");
createDeviceInfo(deviceObj);

char buffer[512];
serializeJson(root, buffer);
publish(configTopic, buffer);
}

void MqttHandleBatteryHassClass::createDeviceInfo(JsonObject& object)
{
object["name"] = "Battery(" + serial + ")";

auto& config = Configuration.get();
if (config.Battery.Provider == 1) {
object["name"] = "JK BMS (" + Battery.getStats()->getManufacturer() + ")";
}

object["ids"] = serial;
object["cu"] = String("http://") + NetworkSettings.localIP().toString();
object["mf"] = "OpenDTU";
object["mdl"] = Battery.getStats()->getManufacturer();
object["sw"] = AUTO_GIT_HASH;
}

void MqttHandleBatteryHassClass::publish(const String& subtopic, const String& payload)
{
String topic = Configuration.get().Mqtt.Hass.Topic;
topic += subtopic;
MqttSettings.publishGeneric(topic.c_str(), payload.c_str(), Configuration.get().Mqtt.Hass.Retain);
}
Loading

0 comments on commit 2f15af4

Please sign in to comment.