Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JK BMS Home Assistent Integration #640

Merged
merged 2 commits into from
Feb 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading