Skip to content

Commit

Permalink
powermeter refactor: use HttpGetter in HTTP SML implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
schlimmchen committed Jun 27, 2024
1 parent a08ef4c commit e1778eb
Show file tree
Hide file tree
Showing 6 changed files with 70 additions and 189 deletions.
21 changes: 7 additions & 14 deletions include/PowerMeterHttpSml.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,20 @@
#include <memory>
#include <stdint.h>
#include <Arduino.h>
#include <HTTPClient.h>
#include "Configuration.h"
#include "HttpGetter.h"
#include "PowerMeterSml.h"

class PowerMeterHttpSml : public PowerMeterSml {
public:
~PowerMeterHttpSml();

bool init() final { return true; }
bool init() final;
void loop() final;
bool updateValues();
char tibberPowerMeterError[256];
bool query(HttpRequestConfig const& config);

// returns an empty string on success,
// returns an error message otherwise.
String poll();

private:
uint32_t _lastPoll = 0;

std::unique_ptr<WiFiClient> wifiClient;
std::unique_ptr<HTTPClient> httpClient;
String httpResponse;
bool httpRequest(const String& host, uint16_t port, const String& uri, bool https, HttpRequestConfig const& config);
bool extractUrlComponents(String url, String& _protocol, String& _hostname, String& _uri, uint16_t& uint16_t, String& _base64Authorization);
void prepareRequest(uint32_t timeout);
std::unique_ptr<HttpGetter> _upHttpGetter;
};
177 changes: 29 additions & 148 deletions src/PowerMeterHttpSml.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,178 +6,59 @@
#include <base64.h>
#include <ESPmDNS.h>

PowerMeterHttpSml::~PowerMeterHttpSml()
{
// the wifiClient instance must live longer than the httpClient instance,
// as the httpClient holds a pointer to the wifiClient and uses it in its
// destructor.
httpClient.reset();
wifiClient.reset();
}

void PowerMeterHttpSml::loop()
bool PowerMeterHttpSml::init()
{
auto const& config = Configuration.get();
if ((millis() - _lastPoll) < (config.PowerMeter.Interval * 1000)) {
return;
}

_lastPoll = millis();

if (!query(config.PowerMeter.HttpSml.HttpRequest)) {
MessageOutput.printf("[PowerMeterHttpSml] Getting the power value failed.\r\n");
MessageOutput.printf("%s\r\n", tibberPowerMeterError);
}
}
_upHttpGetter = std::make_unique<HttpGetter>(config.PowerMeter.HttpSml.HttpRequest);

bool PowerMeterHttpSml::query(HttpRequestConfig const& config)
{
//hostByName in WiFiGeneric fails to resolve local names. issue described in
//https://github.com/espressif/arduino-esp32/issues/3822
//and in depth analyzed in https://github.com/espressif/esp-idf/issues/2507#issuecomment-761836300
//in conclusion: we cannot rely on httpClient->begin(*wifiClient, url) to resolve IP adresses.
//have to do it manually here. Feels Hacky...
String protocol;
String host;
String uri;
String base64Authorization;
uint16_t port;
extractUrlComponents(config.Url, protocol, host, uri, port, base64Authorization);
if (_upHttpGetter->init()) { return true; }

IPAddress ipaddr((uint32_t)0);
//first check if "host" is already an IP adress
if (!ipaddr.fromString(host))
{
//"host"" is not an IP address so try to resolve the IP adress
//first try locally via mDNS, then via DNS. WiFiGeneric::hostByName() will spam the console if done the otherway around.
const bool mdnsEnabled = Configuration.get().Mdns.Enabled;
if (!mdnsEnabled) {
snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("Error resolving host %s via DNS, try to enable mDNS in Network Settings"), host.c_str());
//ensure we try resolving via DNS even if mDNS is disabled
if(!WiFiGenericClass::hostByName(host.c_str(), ipaddr)){
snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("Error resolving host %s via DNS"), host.c_str());
}
}
else
{
ipaddr = MDNS.queryHost(host);
if (ipaddr == INADDR_NONE){
snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("Error resolving host %s via mDNS"), host.c_str());
//when we cannot find local server via mDNS, try resolving via DNS
if(!WiFiGenericClass::hostByName(host.c_str(), ipaddr)){
snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("Error resolving host %s via DNS"), host.c_str());
}
}
}
}
MessageOutput.printf("[PowerMeterHttpSml] Initializing HTTP getter failed:\r\n");
MessageOutput.printf("[PowerMeterHttpSml] %s\r\n", _upHttpGetter->getErrorText());

bool https = protocol == "https";
if (https) {
auto secureWifiClient = std::make_unique<WiFiClientSecure>();
secureWifiClient->setInsecure();
wifiClient = std::move(secureWifiClient);
} else {
wifiClient = std::make_unique<WiFiClient>();
}
_upHttpGetter = nullptr;

return httpRequest(ipaddr.toString(), port, uri, https, config);
return false;
}

bool PowerMeterHttpSml::httpRequest(const String& host, uint16_t port, const String& uri, bool https, HttpRequestConfig const& config)
void PowerMeterHttpSml::loop()
{
if (!httpClient) { httpClient = std::make_unique<HTTPClient>(); }

if(!httpClient->begin(*wifiClient, host, port, uri, https)){
snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("httpClient->begin() failed for %s://%s"), (https ? "https" : "http"), host.c_str());
return false;
}

prepareRequest(config.Timeout);

String authString = config.Username;
authString += ":";
authString += config.Password;
String auth = "Basic ";
auth.concat(base64::encode(authString));
httpClient->addHeader("Authorization", auth);

int httpCode = httpClient->GET();

if (httpCode <= 0) {
snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("HTTP Error %s"), httpClient->errorToString(httpCode).c_str());
return false;
auto const& config = Configuration.get();
if ((millis() - _lastPoll) < (config.PowerMeter.Interval * 1000)) {
return;
}

if (httpCode != HTTP_CODE_OK) {
snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("Bad HTTP code: %d"), httpCode);
return false;
}
_lastPoll = millis();

auto& stream = httpClient->getStream();
while (stream.available()) {
processSmlByte(stream.read());
auto res = poll();
if (!res.isEmpty()) {
MessageOutput.printf("[PowerMeterHttpJson] %s\r\n", res.c_str());
return;
}
httpClient->end();

return true;
gotUpdate();
}

//extract url component as done by httpClient::begin(String url, const char* expectedProtocol) https://github.com/espressif/arduino-esp32/blob/da6325dd7e8e152094b19fe63190907f38ef1ff0/libraries/HTTPClient/src/HTTPClient.cpp#L250
bool PowerMeterHttpSml::extractUrlComponents(String url, String& _protocol, String& _host, String& _uri, uint16_t& _port, String& _base64Authorization)
String PowerMeterHttpSml::poll()
{
// check for : (http: or https:
int index = url.indexOf(':');
if(index < 0) {
snprintf_P(tibberPowerMeterError, sizeof(tibberPowerMeterError), PSTR("failed to parse protocol"));
return false;
if (!_upHttpGetter) {
return "Initialization of HTTP request failed";
}

_protocol = url.substring(0, index);

//initialize port to default values for http or https.
//port will be overwritten below in case port is explicitly defined
_port = (_protocol == "https" ? 443 : 80);

url.remove(0, (index + 3)); // remove http:// or https://

index = url.indexOf('/');
if (index == -1) {
index = url.length();
url += '/';
auto res = _upHttpGetter->performGetRequest();
if (!res) {
return _upHttpGetter->getErrorText();
}
String host = url.substring(0, index);
url.remove(0, index); // remove host part

// get Authorization
index = host.indexOf('@');
if(index >= 0) {
// auth info
String auth = host.substring(0, index);
host.remove(0, index + 1); // remove auth part including @
_base64Authorization = base64::encode(auth);
auto pStream = res.getStream();
if (!pStream) {
return "Programmer error: HTTP request yields no stream";
}

// get port
index = host.indexOf(':');
String the_host;
if(index >= 0) {
the_host = host.substring(0, index); // hostname
host.remove(0, (index + 1)); // remove hostname + :
_port = host.toInt(); // get port
} else {
the_host = host;
while (pStream->available()) {
processSmlByte(pStream->read());
}

_host = the_host;
_uri = url;
return true;
}

void PowerMeterHttpSml::prepareRequest(uint32_t timeout) {
httpClient->setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
httpClient->setUserAgent("OpenDTU-OnBattery");
httpClient->setConnectTimeout(timeout);
httpClient->setTimeout(timeout);
httpClient->addHeader("Content-Type", "application/json");
httpClient->addHeader("Accept", "application/json");
return "";
}
27 changes: 13 additions & 14 deletions src/WebApi_powermeter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ void WebApiPowerMeterClass::onTestHttpJsonRequest(AsyncWebServerRequest* request

auto powerMeterConfig = std::make_unique<CONFIG_T::PowerMeterConfig>();
powerMeterConfig->HttpIndividualRequests = root["http_individual_requests"].as<bool>();
powerMeterConfig->VerboseLogging = true;
JsonArray httpJson = root["http_json"];
for (uint8_t i = 0; i < httpJson.size(); i++) {
Configuration.deserializePowerMeterHttpJsonConfig(httpJson[i].as<JsonObject>(),
Expand Down Expand Up @@ -240,25 +241,23 @@ void WebApiPowerMeterClass::onTestHttpSmlRequest(AsyncWebServerRequest* request)

auto& retMsg = asyncJsonResponse->getRoot();

if (!root.containsKey("url") || !root.containsKey("username") || !root.containsKey("password")
|| !root.containsKey("timeout")) {
retMsg["message"] = "Missing fields!";
asyncJsonResponse->setLength();
request->send(asyncJsonResponse);
return;
}


char response[256];

PowerMeterHttpSmlConfig httpSmlConfig;
Configuration.deserializePowerMeterHttpSmlConfig(root.as<JsonObject>(), httpSmlConfig);
auto powerMeterConfig = std::make_unique<CONFIG_T::PowerMeterConfig>();
Configuration.deserializePowerMeterHttpSmlConfig(root["http_sml"].as<JsonObject>(),
powerMeterConfig->HttpSml);
powerMeterConfig->VerboseLogging = true;
auto backup = std::make_unique<CONFIG_T::PowerMeterConfig>(Configuration.get().PowerMeter);
Configuration.get().PowerMeter = *powerMeterConfig;
auto upMeter = std::make_unique<PowerMeterHttpSml>();
if (upMeter->query(httpSmlConfig.HttpRequest)) {
upMeter->init();
auto res = upMeter->poll();
Configuration.get().PowerMeter = *backup;
if (res.isEmpty()) {
retMsg["type"] = "success";
snprintf_P(response, sizeof(response), "Success! Power: %5.2fW", upMeter->getPowerTotal());
snprintf(response, sizeof(response), "Result: %5.2fW", upMeter->getPowerTotal());
} else {
snprintf_P(response, sizeof(response), "%s", upMeter->tibberPowerMeterError);
snprintf(response, sizeof(response), "%s", res.c_str());
}

retMsg["message"] = response;
Expand Down
3 changes: 2 additions & 1 deletion webapp/src/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -583,7 +583,8 @@
"httpSignInvertedHint": "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",
"testHttpSmlRequest": "Konfiguration testen (HTTP(S)-Anfrage senden)",
"testHttpSmlHeader": "Konfiguration testen",
"testHttpSmlRequest": "HTTP(S)-Anfrage senden und Antwort verarbeiten",
"HTTP_SML": "HTTP(S) + SML - Konfiguration"
},
"httprequestsettings": {
Expand Down
3 changes: 2 additions & 1 deletion webapp/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,8 @@
"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.",
"testHttpJsonHeader": "Test Configuration",
"testHttpJsonRequest": "Send HTTP(S) request(s) and process response(s)",
"testHttpSmlRequest": "Test configuration (send HTTP(S) request)",
"testHttpSmlHeader": "Test Configuration",
"testHttpSmlRequest": "Send HTTP(S) request and process response",
"HTTP_SML": "Configuration"
},
"httprequestsettings": {
Expand Down
28 changes: 17 additions & 11 deletions webapp/src/views/PowerMeterAdminView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -180,16 +180,22 @@
add-space>

<HttpRequestSettings :cfg="powerMeterConfigList.http_sml.http_request" />
</CardElement>

<div class="text-center mb-3">
<button type="button" class="btn btn-danger" @click="testHttpSmlRequest()">
{{ $t('powermeteradmin.testHttpSmlRequest') }}
</button>
</div>
<CardElement
:text="$t('powermeteradmin.testHttpSmlHeader')"
textVariant="text-bg-primary"
add-space>

<BootstrapAlert v-model="testHttpSmlRequestAlert.show" dismissible :variant="testHttpSmlRequestAlert.type">
{{ testHttpSmlRequestAlert.message }}
</BootstrapAlert>
<div class="text-center mt-3 mb-3">
<button type="button" class="btn btn-primary" @click="testHttpSmlRequest()">
{{ $t('powermeteradmin.testHttpSmlRequest') }}
</button>
</div>

<BootstrapAlert v-model="testHttpSmlRequestAlert.show" dismissible :variant="testHttpSmlRequestAlert.type">
{{ testHttpSmlRequestAlert.message }}
</BootstrapAlert>
</CardElement>
</div>
</div>
Expand Down Expand Up @@ -281,7 +287,7 @@ export default defineComponent({
},
testHttpJsonRequest() {
this.testHttpJsonRequestAlert = {
message: "Sending HTTP request...",
message: "Triggering HTTP request...",
type: "info",
show: true,
};
Expand All @@ -307,13 +313,13 @@ export default defineComponent({
},
testHttpSmlRequest() {
this.testHttpSmlRequestAlert = {
message: "Sending HTTP SML request...",
message: "Triggering HTTP request...",
type: "info",
show: true,
};
const formData = new FormData();
formData.append("data", JSON.stringify(this.powerMeterConfigList.http_sml));
formData.append("data", JSON.stringify(this.powerMeterConfigList));
fetch("/api/powermeter/testhttpsmlrequest", {
method: "POST",
Expand Down

0 comments on commit e1778eb

Please sign in to comment.