diff --git a/include/BatteryStats.h b/include/BatteryStats.h index 5d7067208..8ff129f41 100644 --- a/include/BatteryStats.h +++ b/include/BatteryStats.h @@ -128,3 +128,16 @@ class VictronSmartShuntStats : public BatteryStats { bool _alarmLowTemperature; bool _alarmHighTemperature; }; + +class MqttBatteryStats : public BatteryStats { + public: + // since the source of information was MQTT in the first place, + // we do NOT publish the same data under a different topic. + void mqttPublish() const final { } + + // the SoC is the only interesting value in this case, which is already + // displayed at the top of the live view. do not generate a card. + void getLiveViewData(JsonVariant& root) const final { } + + void setSoC(uint8_t SoC) { _SoC = SoC; _lastUpdateSoC = _lastUpdate = millis(); } +}; diff --git a/include/Configuration.h b/include/Configuration.h index 38b915bd1..66049fd08 100644 --- a/include/Configuration.h +++ b/include/Configuration.h @@ -226,6 +226,7 @@ struct CONFIG_T { uint8_t Provider; uint8_t JkBmsInterface; uint8_t JkBmsPollingInterval; + char MqttTopic[MQTT_MAX_TOPIC_STRLEN + 1]; } Battery; struct { diff --git a/include/MqttBattery.h b/include/MqttBattery.h new file mode 100644 index 000000000..83ff412d3 --- /dev/null +++ b/include/MqttBattery.h @@ -0,0 +1,22 @@ +#pragma once + +#include "Battery.h" +#include + +class MqttBattery : public BatteryProvider { + public: + MqttBattery() = default; + + bool init(bool verboseLogging) final; + void deinit() final; + void loop() final { return; } // this class is event-driven + std::shared_ptr getStats() const final { return _stats; } + + private: + bool _verboseLogging = false; + String _socTopic; + std::shared_ptr _stats = std::make_shared(); + + void onMqttMessage(espMqttClientTypes::MessageProperties const& properties, + char const* topic, uint8_t const* payload, size_t len, size_t index, size_t total); +}; diff --git a/src/Battery.cpp b/src/Battery.cpp index 720d056f0..9fdc6e273 100644 --- a/src/Battery.cpp +++ b/src/Battery.cpp @@ -5,6 +5,7 @@ #include "PylontechCanReceiver.h" #include "JkBmsController.h" #include "VictronSmartShunt.h" +#include "MqttBattery.h" BatteryClass Battery; @@ -53,6 +54,10 @@ void BatteryClass::updateSettings() _upProvider = std::make_unique(); if (!_upProvider->init(verboseLogging)) { _upProvider = nullptr; } break; + case 2: + _upProvider = std::make_unique(); + if (!_upProvider->init(verboseLogging)) { _upProvider = nullptr; } + break; case 3: _upProvider = std::make_unique(); if (!_upProvider->init(verboseLogging)) { _upProvider = nullptr; } @@ -63,7 +68,6 @@ void BatteryClass::updateSettings() } } - void BatteryClass::loop() { std::lock_guard lock(_mutex); diff --git a/src/Configuration.cpp b/src/Configuration.cpp index 485d03519..9c166ab00 100644 --- a/src/Configuration.cpp +++ b/src/Configuration.cpp @@ -199,6 +199,7 @@ bool ConfigurationClass::write() battery["provider"] = config.Battery.Provider; battery["jkbms_interface"] = config.Battery.JkBmsInterface; battery["jkbms_polling_interval"] = config.Battery.JkBmsPollingInterval; + battery["mqtt_topic"] = config.Battery.MqttTopic; JsonObject huawei = doc.createNestedObject("huawei"); huawei["enabled"] = config.Huawei.Enabled; @@ -435,6 +436,7 @@ bool ConfigurationClass::read() config.Battery.Provider = battery["provider"] | BATTERY_PROVIDER; config.Battery.JkBmsInterface = battery["jkbms_interface"] | BATTERY_JKBMS_INTERFACE; config.Battery.JkBmsPollingInterval = battery["jkbms_polling_interval"] | BATTERY_JKBMS_POLLING_INTERVAL; + strlcpy(config.Battery.MqttTopic, battery["mqtt_topic"] | "", sizeof(config.Battery.MqttTopic)); JsonObject huawei = doc["huawei"]; config.Huawei.Enabled = huawei["enabled"] | HUAWEI_ENABLED; diff --git a/src/MqttBattery.cpp b/src/MqttBattery.cpp new file mode 100644 index 000000000..9e1992429 --- /dev/null +++ b/src/MqttBattery.cpp @@ -0,0 +1,65 @@ +#include + +#include "Configuration.h" +#include "MqttBattery.h" +#include "MqttSettings.h" +#include "MessageOutput.h" + +bool MqttBattery::init(bool verboseLogging) +{ + _verboseLogging = verboseLogging; + + auto const& config = Configuration.get(); + _socTopic = config.Battery.MqttTopic; + + if (_socTopic.isEmpty()) { return false; } + + MqttSettings.subscribe(_socTopic, 0/*QoS*/, + std::bind(&MqttBattery::onMqttMessage, + this, std::placeholders::_1, std::placeholders::_2, + std::placeholders::_3, std::placeholders::_4, + std::placeholders::_5, std::placeholders::_6) + ); + + if (_verboseLogging) { + MessageOutput.printf("MqttBattery: Subscribed to '%s'\r\n", + _socTopic.c_str()); + } + + return true; +} + +void MqttBattery::deinit() +{ + if (_socTopic.isEmpty()) { return; } + MqttSettings.unsubscribe(_socTopic); +} + +void MqttBattery::onMqttMessage(espMqttClientTypes::MessageProperties const& properties, + char const* topic, uint8_t const* payload, size_t len, size_t index, size_t total) +{ + float soc = 0; + std::string value(reinterpret_cast(payload), len); + + try { + soc = std::stof(value); + } + catch(std::invalid_argument const& e) { + MessageOutput.printf("MqttBattery: Cannot parse payload '%s' in topic '%s' as float\r\n", + value.c_str(), topic); + return; + } + + if (soc < 0 || soc > 100) { + MessageOutput.printf("MqttBattery: Implausible SoC '%.2f' in topic '%s'\r\n", + soc, topic); + return; + } + + _stats->setSoC(static_cast(soc)); + + if (_verboseLogging) { + MessageOutput.printf("MqttBattery: Updated SoC to %d from '%s'\r\n", + static_cast(soc), topic); + } +} diff --git a/src/WebApi_battery.cpp b/src/WebApi_battery.cpp index 05897840a..1718dd6b6 100644 --- a/src/WebApi_battery.cpp +++ b/src/WebApi_battery.cpp @@ -43,6 +43,7 @@ void WebApiBatteryClass::onStatus(AsyncWebServerRequest* request) root[F("provider")] = config.Battery.Provider; root[F("jkbms_interface")] = config.Battery.JkBmsInterface; root[F("jkbms_polling_interval")] = config.Battery.JkBmsPollingInterval; + root[F("mqtt_topic")] = config.Battery.MqttTopic; response->setLength(); request->send(response); @@ -106,6 +107,7 @@ void WebApiBatteryClass::onAdminPost(AsyncWebServerRequest* request) config.Battery.Provider = root[F("provider")].as(); config.Battery.JkBmsInterface = root[F("jkbms_interface")].as(); config.Battery.JkBmsPollingInterval = root[F("jkbms_polling_interval")].as(); + strlcpy(config.Battery.MqttTopic, root[F("mqtt_topic")].as().c_str(), sizeof(config.Battery.MqttTopic)); Configuration.write(); retMsg[F("type")] = F("success"); diff --git a/webapp/src/components/BatteryView.vue b/webapp/src/components/BatteryView.vue index b1fbcfe24..fe539d9bf 100644 --- a/webapp/src/components/BatteryView.vue +++ b/webapp/src/components/BatteryView.vue @@ -5,7 +5,7 @@ -
+
diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index 0ca28ec2b..ac0714002 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -605,7 +605,10 @@ "Provider": "Datenanbieter", "ProviderPylontechCan": "Pylontech per CAN-Bus", "ProviderJkBmsSerial": "Jikong (JK) BMS per serieller Verbindung", + "ProviderMqtt": "State of Charge (SoC) Wert aus MQTT Broker", "ProviderVictron": "Victron SmartShunt per VE.Direct Schnittstelle", + "MqttConfiguration": "MQTT Einstellungen", + "MqttTopic": "SoC-Wert Topic", "JkBmsConfiguration": "JK BMS Einstellungen", "JkBmsInterface": "Schnittstellentyp", "JkBmsInterfaceUart": "TTL-UART an der MCU", diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 7f3fd59e1..c819fea44 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -614,7 +614,10 @@ "Provider": "Data Provider", "ProviderPylontechCan": "Pylontech using CAN bus", "ProviderJkBmsSerial": "Jikong (JK) BMS using serial connection", + "ProviderMqtt": "State of Charge (SoC) value from MQTT broker", "ProviderVictron": "Victron SmartShunt using VE.Direct interface", + "MqttConfiguration": "MQTT Settings", + "MqttTopic": "SoC value topic", "JkBmsConfiguration": "JK BMS Settings", "JkBmsInterface": "Interface Type", "JkBmsInterfaceUart": "TTL-UART on MCU", diff --git a/webapp/src/locales/fr.json b/webapp/src/locales/fr.json index e6d236833..8f1e88efa 100644 --- a/webapp/src/locales/fr.json +++ b/webapp/src/locales/fr.json @@ -530,7 +530,10 @@ "Provider": "Data Provider", "ProviderPylontechCan": "Pylontech using CAN bus", "ProviderJkBmsSerial": "Jikong (JK) BMS using serial connection", + "ProviderMqtt": "State of Charge (SoC) value from MQTT broker", "ProviderVictron": "Victron SmartShunt using VE.Direct interface", + "MqttConfiguration": "MQTT Settings", + "MqttTopic": "SoC value topic", "JkBmsConfiguration": "JK BMS Settings", "JkBmsInterface": "Interface Type", "JkBmsInterfaceUart": "TTL-UART on MCU", diff --git a/webapp/src/types/BatteryConfig.ts b/webapp/src/types/BatteryConfig.ts index 8bd05000a..fc83e84d9 100644 --- a/webapp/src/types/BatteryConfig.ts +++ b/webapp/src/types/BatteryConfig.ts @@ -4,4 +4,5 @@ export interface BatteryConfig { provider: number; jkbms_interface: number; jkbms_polling_interval: number; + mqtt_topic: string; } diff --git a/webapp/src/views/BatteryAdminView.vue b/webapp/src/views/BatteryAdminView.vue index e599425b3..88b67df4b 100644 --- a/webapp/src/views/BatteryAdminView.vue +++ b/webapp/src/views/BatteryAdminView.vue @@ -49,6 +49,20 @@ type="number" min="2" max="90" step="1" :postfix="$t('batteryadmin.Seconds')"/> + +
+ +
+
+ +
+
+
+
+ @@ -82,6 +96,7 @@ export default defineComponent({ providerTypeList: [ { key: 0, value: 'PylontechCan' }, { key: 1, value: 'JkBmsSerial' }, + { key: 2, value: 'Mqtt' }, { key: 3, value: 'Victron' }, ], jkBmsInterfaceTypeList: [