Skip to content

Commit

Permalink
Feature: support JSON payload in MQTT power meter
Browse files Browse the repository at this point in the history
the MQTT power meter can now process the messages published at the
respective topics as JSON and extract a power value using a JSON path
(same as in HTTP+JSON power meter). additionally, selecting a unit for
the power value as well as an option to invert the value's sign was
added as well, similar to the HTTPS+JSON power meter.
  • Loading branch information
schlimmchen committed Jun 27, 2024
1 parent 347dd67 commit 15b6a32
Show file tree
Hide file tree
Showing 8 changed files with 154 additions and 59 deletions.
6 changes: 6 additions & 0 deletions include/Configuration.h
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ using HttpRequestConfig = struct HTTP_REQUEST_CONFIG_T;

struct POWERMETER_MQTT_VALUE_T {
char Topic[MQTT_MAX_TOPIC_STRLEN + 1];
char JsonPath[POWERMETER_HTTP_JSON_MAX_PATH_STRLEN + 1];

enum Unit { Watts = 0, MilliWatts = 1, KiloWatts = 2 };
Unit PowerUnit;

bool SignInverted;
};
using PowerMeterMqttValue = struct POWERMETER_MQTT_VALUE_T;

Expand Down
8 changes: 4 additions & 4 deletions include/PowerMeterMqtt.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#include <espMqttClient.h>
#include <vector>
#include <mutex>
#include <array>

class PowerMeterMqtt : public PowerMeterProvider {
public:
Expand All @@ -23,13 +24,12 @@ class PowerMeterMqtt : public PowerMeterProvider {
using MsgProperties = espMqttClientTypes::MessageProperties;
void onMessage(MsgProperties const& properties, char const* topic,
uint8_t const* payload, size_t len, size_t index,
size_t total, float* targetVariable);
size_t total, float* targetVariable, PowerMeterMqttValue const* cfg);

PowerMeterMqttConfig const _cfg;

float _powerValueOne = 0;
float _powerValueTwo = 0;
float _powerValueThree = 0;
using power_values_t = std::array<float, POWERMETER_MQTT_MAX_VALUES>;
power_values_t _powerValues;

std::vector<String> _mqttSubscriptions;

Expand Down
11 changes: 9 additions & 2 deletions src/Configuration.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ void ConfigurationClass::serializePowerMeterMqttConfig(PowerMeterMqttConfig cons
PowerMeterMqttValue const& s = source.Values[i];

t["topic"] = s.Topic;
t["json_path"] = s.JsonPath;
t["unit"] = s.PowerUnit;
t["sign_inverted"] = s.SignInverted;
}
}

Expand Down Expand Up @@ -301,10 +304,14 @@ void ConfigurationClass::deserializeHttpRequestConfig(JsonObject const& source,

void ConfigurationClass::deserializePowerMeterMqttConfig(JsonObject const& source, PowerMeterMqttConfig& target)
{
JsonArray s = source["values"].as<JsonArray>();
for (size_t i = 0; i < POWERMETER_MQTT_MAX_VALUES; ++i) {
PowerMeterMqttValue& t = target.Values[i];
strlcpy(t.Topic, s[i]["topic"] | "", sizeof(t.Topic));
JsonObject s = source["values"][i];

strlcpy(t.Topic, s["topic"] | "", sizeof(t.Topic));
strlcpy(t.JsonPath, s["json_path"] | "", sizeof(t.JsonPath));
t.PowerUnit = s["unit"] | PowerMeterMqttValue::Unit::Watts;
t.SignInverted = s["sign_inverted"] | false;
}
}

Expand Down
86 changes: 66 additions & 20 deletions src/PowerMeterMqtt.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,27 @@
#include "PowerMeterMqtt.h"
#include "MqttSettings.h"
#include "MessageOutput.h"
#include "ArduinoJson.h"
#include "Utils.h"

bool PowerMeterMqtt::init()
{
auto subscribe = [this](char const* topic, float* targetVariable) {
auto subscribe = [this](PowerMeterMqttValue const& val, float* targetVariable) {
char const* topic = val.Topic;
if (strlen(topic) == 0) { return; }
MqttSettings.subscribe(topic, 0,
std::bind(&PowerMeterMqtt::onMessage,
this, std::placeholders::_1, std::placeholders::_2,
std::placeholders::_3, std::placeholders::_4,
std::placeholders::_5, std::placeholders::_6,
targetVariable)
targetVariable, &val)
);
_mqttSubscriptions.push_back(topic);
};

subscribe(_cfg.Values[0].Topic, &_powerValueOne);
subscribe(_cfg.Values[1].Topic, &_powerValueTwo);
subscribe(_cfg.Values[2].Topic, &_powerValueThree);
for (size_t i = 0; i < _powerValues.size(); ++i) {
subscribe(_cfg.Values[i], &_powerValues[i]);
}

return _mqttSubscriptions.size() > 0;
}
Expand All @@ -32,37 +35,80 @@ PowerMeterMqtt::~PowerMeterMqtt()

void PowerMeterMqtt::onMessage(PowerMeterMqtt::MsgProperties const& properties,
char const* topic, uint8_t const* payload, size_t len, size_t index,
size_t total, float* targetVariable)
size_t total, float* targetVariable, PowerMeterMqttValue const* cfg)
{
std::string value(reinterpret_cast<char const*>(payload), len);
try {
std::lock_guard<std::mutex> l(_mutex);
*targetVariable = std::stof(value);
std::string logValue = value.substr(0, 32);
if (value.length() > logValue.length()) { logValue += "..."; }

auto log= [topic](char const* format, auto&&... args) -> void {
MessageOutput.printf("[PowerMeterMqtt] Topic '%s': ", topic);
MessageOutput.printf(format, args...);
MessageOutput.println();
};

if (strlen(cfg->JsonPath) == 0) {
try {
std::lock_guard<std::mutex> l(_mutex);
*targetVariable = std::stof(value);
}
catch (std::invalid_argument const& e) {
return log("cannot parse payload '%s' as float", logValue.c_str());
}
}
else {
JsonDocument json;

const DeserializationError error = deserializeJson(json, value);
if (error) {
return log("cannot parse payload '%s' as JSON", logValue.c_str());
}

if (json.overflowed()) {
return log("payload too large to process as JSON");
}

auto pathResolutionResult = Utils::getJsonValueByPath<float>(json, cfg->JsonPath);
if (!pathResolutionResult.second.isEmpty()) {
return log("%s", pathResolutionResult.second.c_str());
}

*targetVariable = pathResolutionResult.first;
}
catch (std::invalid_argument const& e) {
MessageOutput.printf("[PowerMeterMqtt] cannot parse payload of topic "
"'%s' as float: %s\r\n", topic, value.c_str());
return;

using Unit_t = PowerMeterMqttValue::Unit;
switch (cfg->PowerUnit) {
case Unit_t::MilliWatts:
*targetVariable /= 1000;
break;
case Unit_t::KiloWatts:
*targetVariable *= 1000;
break;
default:
break;
}

if (cfg->SignInverted) { *targetVariable *= -1; }

if (_verboseLogging) {
MessageOutput.printf("[PowerMeterMqtt] Updated from '%s', TotalPower: %5.2f\r\n",
topic, getPowerTotal());
log("new value: %5.2f, total: %5.2f", *targetVariable, getPowerTotal());
}

gotUpdate();
}

float PowerMeterMqtt::getPowerTotal() const
{
std::lock_guard<std::mutex> l(_mutex);
return _powerValueOne + _powerValueTwo + _powerValueThree;
float sum = 0.0;
std::unique_lock<std::mutex> lock(_mutex);
for (auto v: _powerValues) { sum += v; }
return sum;
}

void PowerMeterMqtt::doMqttPublish() const
{
std::lock_guard<std::mutex> l(_mutex);
mqttPublish("power1", _powerValueOne);
mqttPublish("power2", _powerValueTwo);
mqttPublish("power3", _powerValueThree);
mqttPublish("power1", _powerValues[0]);
mqttPublish("power2", _powerValues[1]);
mqttPublish("power3", _powerValues[2]);
}
11 changes: 6 additions & 5 deletions webapp/src/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,7 @@
"typeHTTP_SML": "HTTP(S) + SML (z.B. Tibber Pulse via Tibber Bridge)",
"MqttValue": "Konfiguration Wert {valueNumber}",
"MqttTopic": "MQTT Topic",
"mqttJsonPath": "Optional: JSON-Pfad",
"SDM": "SDM-Stromzähler Konfiguration",
"sdmaddress": "Modbus Adresse",
"HTTP_JSON": "HTTP(S) + JSON - Allgemeine Konfiguration",
Expand All @@ -575,11 +576,11 @@
"jsonPathExamplesExplanation": "Die folgenden Pfade finden jeweils den Wert '123.4' im jeweiligen Beispiel-JSON.",
"httpValue": "Konfiguration Wert {valueNumber}",
"httpEnabled": "Wert aktiviert",
"httpJsonPath": "JSON-Pfad",
"httpJsonPathDescription": "Anwendungsspezifischer JSON-Pfad um den Leistungswert in the HTTP(S) Antwort zu finden, z.B. 'power/total/watts' oder nur 'total'.",
"httpUnit": "Einheit",
"httpSignInverted": "Vorzeichen umkehren",
"httpSignInvertedHint": "Positive Werte werden als Leistungsabnahme aus dem Netz interpretiert. Diese Option muss aktiviert werden, wenn das Vorzeichen des Wertes die gegenteilige Bedeutung hat.",
"valueJsonPath": "JSON-Pfad",
"valueJsonPathDescription": "Anwendungsspezifischer JSON-Pfad um den Leistungswert in den JSON Nutzdatzen zu finden, z.B. 'power/total/watts' oder nur 'total'.",
"valueUnit": "Einheit",
"valueSignInverted": "Vorzeichen umkehren",
"valueSignInvertedHint": "Positive Werte werden als Leistungsabnahme aus dem Netz interpretiert. Diese Option muss aktiviert werden, wenn das Vorzeichen des Wertes die gegenteilige Bedeutung hat.",
"testHttpJsonHeader": "Konfiguration testen",
"testHttpJsonRequest": "HTTP(S)-Anfrage(n) senden und Antwort(en) verarbeiten",
"testHttpSmlHeader": "Konfiguration testen",
Expand Down
11 changes: 6 additions & 5 deletions webapp/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,7 @@
"typeSMAHM2": "SMA Homemanager 2.0",
"typeHTTP_SML": "HTTP(S) + SML (e.g. Tibber Pulse via Tibber Bridge)",
"MqttValue": "Value {valueNumber} Configuration",
"mqttJsonPath": "Optional: JSON Path",
"MqttTopic": "MQTT Topic",
"SDM": "SDM-Power Meter Parameter",
"sdmaddress": "Modbus Address",
Expand All @@ -577,11 +578,11 @@
"jsonPathExamplesExplanation": "The following paths each find the value '123.4' in the respective example JSON.",
"httpValue": "Value {valueNumber} Configuration",
"httpEnabled": "Value Enabled",
"httpJsonPath": "JSON Path",
"httpJsonPathDescription": "Application specific JSON path to find the power value in the HTTP(S) response, e.g., 'power/total/watts' or simply 'total'.",
"httpUnit": "Unit",
"httpSignInverted": "Change Sign",
"httpSignInvertedHint": "Is is expected that positive values denote power usage from the grid. Check this option if the sign of this value has the opposite meaning.",
"valueJsonPath": "JSON Path",
"valueJsonPathDescription": "Application specific JSON path to find the power value in the JSON payload, e.g., 'power/total/watts' or simply 'total'.",
"valueUnit": "Unit",
"valueSignInverted": "Change Sign",
"valueSignInvertedHint": "Is is expected that positive values denote power usage from the grid. Check this option if the sign of this value has the opposite meaning.",
"testHttpJsonHeader": "Test Configuration",
"testHttpJsonRequest": "Send HTTP(S) request(s) and process response(s)",
"testHttpSmlHeader": "Test Configuration",
Expand Down
3 changes: 3 additions & 0 deletions webapp/src/types/PowerMeterConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import type { HttpRequestConfig } from '@/types/HttpRequestConfig';

export interface PowerMeterMqttValue {
topic: string;
json_path: string;
unit: number;
sign_inverted: boolean;
}

export interface PowerMeterMqttConfig {
Expand Down
77 changes: 54 additions & 23 deletions webapp/src/views/PowerMeterAdminView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@
</CardElement>

<div v-if="powerMeterConfigList.enabled">
<div v-if="powerMeterConfigList.source === 0 || powerMeterConfigList.source === 3">
<div class="alert alert-secondary mt-5" role="alert">
<h2>{{ $t('powermeteradmin.jsonPathExamplesHeading') }}:</h2>
{{ $t('powermeteradmin.jsonPathExamplesExplanation') }}
<ul>
<li><code>power/total/watts</code> &mdash; <code>{ "power": { "phase1": { "factor": 0.98, "watts": 42 }, "total": { "watts": 123.4 } } }</code></li>
<li><code>data/[1]/power</code> &mdash; <code>{ "data": [ { "factor": 0.98, "power": 42 }, { "factor": 1.0, "power": 123.4 } ] } }</code></li>
<li><code>total</code> &mdash; <code>{ "othervalue": 66, "total": 123.4 }</code></li>
</ul>
</div>
</div>

<CardElement v-if="powerMeterConfigList.source === 0"
v-for="(mqtt, index) in powerMeterConfigList.mqtt.values"
:text="$t('powermeteradmin.MqttValue', { valueNumber: index + 1})"
Expand All @@ -41,6 +53,33 @@
type="text"
maxlength="256"
wide />

<InputElement :label="$t('powermeteradmin.mqttJsonPath')"
v-model="mqtt.json_path"
type="text"
maxlength="256"
:tooltip="$t('powermeteradmin.valueJsonPathDescription')"
wide />

<div class="row mb-3">
<label for="mqtt_power_unit" class="col-sm-4 col-form-label">
{{ $t('powermeteradmin.valueUnit') }}
</label>
<div class="col-sm-8">
<select id="mqtt_power_unit" class="form-select" v-model="mqtt.unit">
<option v-for="u in unitTypeList" :key="u.key" :value="u.key">
{{ u.value }}
</option>
</select>
</div>
</div>

<InputElement
:label="$t('powermeteradmin.valueSignInverted')"
v-model="mqtt.sign_inverted"
:tooltip="$t('powermeteradmin.valueSignInvertedHint')"
type="checkbox"
wide />
</CardElement>

<CardElement v-if="(powerMeterConfigList.source === 1 || powerMeterConfigList.source === 2)"
Expand All @@ -63,6 +102,16 @@
</CardElement>

<div v-if="powerMeterConfigList.source === 3">
<div class="alert alert-secondary mt-5" role="alert">
<h2>{{ $t('powermeteradmin.urlExamplesHeading') }}:</h2>
<ul>
<li>http://shelly3em.home/status</li>
<li>https://shelly3em.home/status</li>
<li>http://tasmota-123.home/cm?cmnd=status%208</li>
<li>http://12.34.56.78:8080/emeter/0</li>
</ul>
</div>

<CardElement :text="$t('powermeteradmin.HTTP')"
textVariant="text-bg-primary"
add-space>
Expand All @@ -80,24 +129,6 @@
wide />
</CardElement>

<div class="alert alert-secondary mt-5" role="alert">
<h2>{{ $t('powermeteradmin.urlExamplesHeading') }}:</h2>
<ul>
<li>http://shelly3em.home/status</li>
<li>https://shelly3em.home/status</li>
<li>http://tasmota-123.home/cm?cmnd=status%208</li>
<li>http://12.34.56.78:8080/emeter/0</li>
</ul>

<h2>{{ $t('powermeteradmin.jsonPathExamplesHeading') }}:</h2>
{{ $t('powermeteradmin.jsonPathExamplesExplanation') }}
<ul>
<li><code>power/total/watts</code> &mdash; <code>{ "power": { "phase1": { "factor": 0.98, "watts": 42 }, "total": { "watts": 123.4 } } }</code></li>
<li><code>data/[1]/power</code> &mdash; <code>{ "data": [ { "factor": 0.98, "power": 42 }, { "factor": 1.0, "power": 123.4 } ] } }</code></li>
<li><code>total</code> &mdash; <code>{ "othervalue": 66, "total": 123.4 }</code></li>
</ul>
</div>

<CardElement
v-for="(httpJson, index) in powerMeterConfigList.http_json.values"
:key="index"
Expand All @@ -114,17 +145,17 @@

<HttpRequestSettings :cfg="httpJson.http_request" v-if="index == 0 || powerMeterConfigList.http_json.individual_requests"/>

<InputElement :label="$t('powermeteradmin.httpJsonPath')"
<InputElement :label="$t('powermeteradmin.valueJsonPath')"
v-model="httpJson.json_path"
type="text"
maxlength="256"
placeholder="total_power"
:tooltip="$t('powermeteradmin.httpJsonPathDescription')"
:tooltip="$t('powermeteradmin.valueJsonPathDescription')"
wide />

<div class="row mb-3">
<label for="power_unit" class="col-sm-4 col-form-label">
{{ $t('powermeteradmin.httpUnit') }}
{{ $t('powermeteradmin.valueUnit') }}
</label>
<div class="col-sm-8">
<select id="power_unit" class="form-select" v-model="httpJson.unit">
Expand All @@ -136,9 +167,9 @@
</div>

<InputElement
:label="$t('powermeteradmin.httpSignInverted')"
:label="$t('powermeteradmin.valueSignInverted')"
v-model="httpJson.sign_inverted"
:tooltip="$t('powermeteradmin.httpSignInvertedHint')"
:tooltip="$t('powermeteradmin.valueSignInvertedHint')"
type="checkbox"
wide />
</div>
Expand Down

0 comments on commit 15b6a32

Please sign in to comment.