powermeter refactor: add and use HttpGetter class
this new class uses the newly introduced HttpRequestConfig and performs HTTP requests using this config. it will be reused for other power meters (SML over HTTP(S)) and may be reused by other features in the future (battery provider, solar power provider, etc.).
This commit is contained in:
parent
4d99e87ef4
commit
20ecf2a66b
@ -202,7 +202,7 @@ struct CONFIG_T {
|
||||
bool UpdatesOnly;
|
||||
} Vedirect;
|
||||
|
||||
struct {
|
||||
struct PowerMeterConfig {
|
||||
bool Enabled;
|
||||
bool VerboseLogging;
|
||||
uint32_t Interval;
|
||||
|
||||
77
include/HttpGetter.h
Normal file
77
include/HttpGetter.h
Normal file
@ -0,0 +1,77 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include "Configuration.h"
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
#include <utility>
|
||||
#include <string>
|
||||
#include <HTTPClient.h>
|
||||
#include <WiFiClient.h>
|
||||
|
||||
using up_http_client_t = std::unique_ptr<HTTPClient>;
|
||||
using sp_wifi_client_t = std::shared_ptr<WiFiClient>;
|
||||
|
||||
class HttpRequestResult {
|
||||
public:
|
||||
HttpRequestResult(bool success,
|
||||
up_http_client_t upHttpClient = nullptr,
|
||||
sp_wifi_client_t spWiFiClient = nullptr)
|
||||
: _success(success)
|
||||
, _upHttpClient(std::move(upHttpClient))
|
||||
, _spWiFiClient(std::move(spWiFiClient)) { }
|
||||
|
||||
~HttpRequestResult() {
|
||||
// the wifi client *must* die *after* the http client, as the http
|
||||
// client uses the wifi client in its destructor.
|
||||
if (_upHttpClient) { _upHttpClient->end(); }
|
||||
_upHttpClient = nullptr;
|
||||
_spWiFiClient = nullptr;
|
||||
}
|
||||
|
||||
HttpRequestResult(HttpRequestResult const&) = delete;
|
||||
HttpRequestResult(HttpRequestResult&&) = delete;
|
||||
HttpRequestResult& operator=(HttpRequestResult const&) = delete;
|
||||
HttpRequestResult& operator=(HttpRequestResult&&) = delete;
|
||||
|
||||
operator bool() const { return _success; }
|
||||
|
||||
Stream* getStream() {
|
||||
if(!_upHttpClient) { return nullptr; }
|
||||
return _upHttpClient->getStreamPtr();
|
||||
}
|
||||
|
||||
private:
|
||||
bool _success;
|
||||
up_http_client_t _upHttpClient;
|
||||
sp_wifi_client_t _spWiFiClient;
|
||||
};
|
||||
|
||||
class HttpGetter {
|
||||
public:
|
||||
explicit HttpGetter(HttpRequestConfig const& cfg)
|
||||
: _config(cfg) { }
|
||||
|
||||
bool init();
|
||||
void addHeader(char const* key, char const* value);
|
||||
HttpRequestResult performGetRequest();
|
||||
|
||||
char const* getErrorText() const { return _errBuffer; }
|
||||
|
||||
private:
|
||||
String getAuthDigest(String const& authReq, unsigned int counter);
|
||||
HttpRequestConfig const& _config;
|
||||
|
||||
template<typename... Args>
|
||||
void logError(char const* format, Args... args);
|
||||
char _errBuffer[256];
|
||||
|
||||
bool _useHttps;
|
||||
String _host;
|
||||
String _uri;
|
||||
uint16_t _port;
|
||||
|
||||
sp_wifi_client_t _spWiFiClient; // reused for multiple HTTP requests
|
||||
|
||||
std::vector<std::pair<std::string, std::string>> _additionalHeaders;
|
||||
};
|
||||
@ -1,10 +1,11 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <variant>
|
||||
#include <memory>
|
||||
#include <stdint.h>
|
||||
#include <Arduino.h>
|
||||
#include <HTTPClient.h>
|
||||
#include "HttpGetter.h"
|
||||
#include "Configuration.h"
|
||||
#include "PowerMeterProvider.h"
|
||||
|
||||
@ -13,31 +14,19 @@ using Unit_t = PowerMeterHttpJsonConfig::Unit;
|
||||
|
||||
class PowerMeterHttpJson : public PowerMeterProvider {
|
||||
public:
|
||||
~PowerMeterHttpJson();
|
||||
|
||||
bool init() final { return true; }
|
||||
bool init() final;
|
||||
void loop() final;
|
||||
float getPowerTotal() const final;
|
||||
void doMqttPublish() const final;
|
||||
|
||||
bool queryValue(int phase, PowerMeterHttpJsonConfig const& config);
|
||||
char httpPowerMeterError[256];
|
||||
float getCached(size_t idx) { return _cache[idx]; }
|
||||
using power_values_t = std::array<float, POWERMETER_HTTP_JSON_MAX_VALUES>;
|
||||
using poll_result_t = std::variant<power_values_t, String>;
|
||||
poll_result_t poll();
|
||||
|
||||
private:
|
||||
uint32_t _lastPoll;
|
||||
std::array<float, POWERMETER_HTTP_JSON_MAX_VALUES> _cache;
|
||||
std::array<float, POWERMETER_HTTP_JSON_MAX_VALUES> _powerValues;
|
||||
std::unique_ptr<WiFiClient> wifiClient;
|
||||
std::unique_ptr<HTTPClient> httpClient;
|
||||
String httpResponse;
|
||||
|
||||
bool httpRequest(int phase, const String& host, uint16_t port, const String& uri, bool https, PowerMeterHttpJsonConfig const& config);
|
||||
bool extractUrlComponents(String url, String& _protocol, String& _hostname, String& _uri, uint16_t& uint16_t);
|
||||
String extractParam(String& authReq, const String& param, const char delimit);
|
||||
String getcNonce(const int len);
|
||||
String getDigestAuth(String& authReq, const String& username, const String& password, const String& method, const String& uri, unsigned int counter);
|
||||
bool tryGetFloatValueForPhase(int phase, String jsonPath, Unit_t unit, bool signInverted);
|
||||
void prepareRequest(uint32_t timeout, const char* httpHeader, const char* httpValue);
|
||||
String sha256(const String& data);
|
||||
power_values_t _powerValues;
|
||||
|
||||
std::array<std::unique_ptr<HttpGetter>, POWERMETER_HTTP_JSON_MAX_VALUES> _httpGetters;
|
||||
};
|
||||
|
||||
@ -16,5 +16,5 @@ public:
|
||||
|
||||
/* OpenDTU-OnBatter-specific utils go here: */
|
||||
template<typename T>
|
||||
static std::pair<T, String> getJsonValueFromStringByPath(String const& jsonText, String const& path);
|
||||
static std::pair<T, String> getJsonValueByPath(JsonDocument const& root, String const& path);
|
||||
};
|
||||
|
||||
231
src/HttpGetter.cpp
Normal file
231
src/HttpGetter.cpp
Normal file
@ -0,0 +1,231 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#include "HttpGetter.h"
|
||||
#include <WiFiClientSecure.h>
|
||||
#include "mbedtls/sha256.h"
|
||||
#include <base64.h>
|
||||
#include <ESPmDNS.h>
|
||||
|
||||
template<typename... Args>
|
||||
void HttpGetter::logError(char const* format, Args... args) {
|
||||
snprintf(_errBuffer, sizeof(_errBuffer), format, args...);
|
||||
}
|
||||
|
||||
bool HttpGetter::init()
|
||||
{
|
||||
String url(_config.Url);
|
||||
|
||||
int index = url.indexOf(':');
|
||||
if (index < 0) {
|
||||
logError("failed to parse URL protocol: no colon in URL");
|
||||
return false;
|
||||
}
|
||||
|
||||
String protocol = url.substring(0, index);
|
||||
if (protocol != "http" && protocol != "https") {
|
||||
logError("failed to parse URL protocol: '%s' is neither 'http' nor 'https'", protocol.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
_useHttps = (protocol == "https");
|
||||
|
||||
// initialize port to default values for http or https.
|
||||
// port will be overwritten below in case port is explicitly defined
|
||||
_port = _useHttps ? 443 : 80;
|
||||
|
||||
String slashes = url.substring(index + 1, index + 3);
|
||||
if (slashes != "//") {
|
||||
logError("expected two forward slashes after first colon in URL");
|
||||
return false;
|
||||
}
|
||||
|
||||
_uri = url.substring(index + 3); // without protocol identifier
|
||||
|
||||
index = _uri.indexOf('/');
|
||||
if (index == -1) {
|
||||
index = _uri.length();
|
||||
_uri += '/';
|
||||
}
|
||||
_host = _uri.substring(0, index);
|
||||
_uri.remove(0, index); // remove host part
|
||||
|
||||
index = _host.indexOf('@');
|
||||
if (index >= 0) {
|
||||
// basic authentication is only supported through setting username
|
||||
// and password using the respective inputs, not embedded into the URL.
|
||||
// to avoid regressions, we remove username and password from the host
|
||||
// part of the URL.
|
||||
_host.remove(0, index + 1); // remove auth part including @
|
||||
}
|
||||
|
||||
// get port
|
||||
index = _host.indexOf(':');
|
||||
if (index >= 0) {
|
||||
_host = _host.substring(0, index); // up until colon
|
||||
_port = _host.substring(index + 1).toInt(); // after colon
|
||||
}
|
||||
|
||||
if (_useHttps) {
|
||||
auto secureWifiClient = std::make_shared<WiFiClientSecure>();
|
||||
secureWifiClient->setInsecure();
|
||||
_spWiFiClient = std::move(secureWifiClient);
|
||||
} else {
|
||||
_spWiFiClient = std::make_shared<WiFiClient>();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
HttpRequestResult HttpGetter::performGetRequest()
|
||||
{
|
||||
// hostByName in WiFiGeneric fails to resolve local names. issue described at
|
||||
// https://github.com/espressif/arduino-esp32/issues/3822 and in analyzed in
|
||||
// depth at https://github.com/espressif/esp-idf/issues/2507#issuecomment-761836300
|
||||
// in conclusion: we cannot rely on _upHttpClient->begin(*wifiClient, url) to resolve
|
||||
// IP adresses. have to do it manually.
|
||||
IPAddress ipaddr((uint32_t)0);
|
||||
|
||||
if (!ipaddr.fromString(_host)) {
|
||||
// host is not an IP address, so try to resolve the name to an address.
|
||||
// first try locally via mDNS, then via DNS. WiFiGeneric::hostByName()
|
||||
// will spam the console if done the other way around.
|
||||
ipaddr = INADDR_NONE;
|
||||
|
||||
if (Configuration.get().Mdns.Enabled) {
|
||||
ipaddr = MDNS.queryHost(_host); // INADDR_NONE if failed
|
||||
}
|
||||
|
||||
if (ipaddr == INADDR_NONE && !WiFiGenericClass::hostByName(_host.c_str(), ipaddr)) {
|
||||
logError("failed to resolve host '%s' via DNS", _host.c_str());
|
||||
return { false };
|
||||
}
|
||||
}
|
||||
|
||||
auto upTmpHttpClient = std::make_unique<HTTPClient>();
|
||||
if (!upTmpHttpClient->begin(*_spWiFiClient, ipaddr.toString(), _port, _uri, _useHttps)) {
|
||||
logError("HTTP client begin() failed for %s://%s",
|
||||
(_useHttps ? "https" : "http"), _host.c_str());
|
||||
return { false };
|
||||
}
|
||||
|
||||
upTmpHttpClient->setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
|
||||
upTmpHttpClient->setUserAgent("OpenDTU-OnBattery");
|
||||
upTmpHttpClient->setConnectTimeout(_config.Timeout);
|
||||
upTmpHttpClient->setTimeout(_config.Timeout);
|
||||
for (auto const& h : _additionalHeaders) {
|
||||
upTmpHttpClient->addHeader(h.first.c_str(), h.second.c_str());
|
||||
}
|
||||
|
||||
if (strlen(_config.HeaderKey) > 0) {
|
||||
upTmpHttpClient->addHeader(_config.HeaderKey, _config.HeaderValue);
|
||||
}
|
||||
|
||||
using Auth_t = HttpRequestConfig::Auth;
|
||||
switch (_config.AuthType) {
|
||||
case Auth_t::None:
|
||||
break;
|
||||
case Auth_t::Basic: {
|
||||
String credentials = String(_config.Username) + ":" + _config.Password;
|
||||
String authorization = "Basic " + base64::encode(credentials);
|
||||
upTmpHttpClient->addHeader("Authorization", authorization);
|
||||
break;
|
||||
}
|
||||
case Auth_t::Digest: {
|
||||
const char *headers[1] = {"WWW-Authenticate"};
|
||||
upTmpHttpClient->collectHeaders(headers, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
int httpCode = upTmpHttpClient->GET();
|
||||
|
||||
if (httpCode == HTTP_CODE_UNAUTHORIZED && _config.AuthType == Auth_t::Digest) {
|
||||
if (!upTmpHttpClient->hasHeader("WWW-Authenticate")) {
|
||||
logError("Cannot perform digest authentication as server did "
|
||||
"not send a WWW-Authenticate header");
|
||||
return { false };
|
||||
}
|
||||
String authReq = upTmpHttpClient->header("WWW-Authenticate");
|
||||
String authorization = getAuthDigest(authReq, 1);
|
||||
upTmpHttpClient->addHeader("Authorization", authorization);
|
||||
httpCode = upTmpHttpClient->GET();
|
||||
}
|
||||
|
||||
if (httpCode <= 0) {
|
||||
logError("HTTP Error: %s", upTmpHttpClient->errorToString(httpCode).c_str());
|
||||
return { false };
|
||||
}
|
||||
|
||||
if (httpCode != HTTP_CODE_OK) {
|
||||
logError("Bad HTTP code: %d", httpCode);
|
||||
return { false };
|
||||
}
|
||||
|
||||
return { true, std::move(upTmpHttpClient), _spWiFiClient };
|
||||
}
|
||||
|
||||
static String sha256(const String& data) {
|
||||
uint8_t hash[32];
|
||||
|
||||
mbedtls_sha256_context ctx;
|
||||
mbedtls_sha256_init(&ctx);
|
||||
mbedtls_sha256_starts(&ctx, 0); // select SHA256
|
||||
mbedtls_sha256_update(&ctx, reinterpret_cast<const unsigned char*>(data.c_str()), data.length());
|
||||
mbedtls_sha256_finish(&ctx, hash);
|
||||
mbedtls_sha256_free(&ctx);
|
||||
|
||||
char res[sizeof(hash) * 2 + 1];
|
||||
for (int i = 0; i < sizeof(hash); i++) {
|
||||
snprintf(res + (i*2), sizeof(res) - (i*2), "%02x", hash[i]);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
static String extractParam(String const& authReq, String const& param, char delimiter) {
|
||||
auto begin = authReq.indexOf(param);
|
||||
if (begin == -1) { return ""; }
|
||||
auto end = authReq.indexOf(delimiter, begin + param.length());
|
||||
return authReq.substring(begin + param.length(), end);
|
||||
}
|
||||
|
||||
static String getcNonce(int len) {
|
||||
static const char alphanum[] = "0123456789"
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
"abcdefghijklmnopqrstuvwxyz";
|
||||
String s = "";
|
||||
|
||||
for (int i = 0; i < len; ++i) { s += alphanum[rand() % (sizeof(alphanum) - 1)]; }
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
String HttpGetter::getAuthDigest(String const& authReq, unsigned int counter) {
|
||||
// extracting required parameters for RFC 2617 Digest
|
||||
String realm = extractParam(authReq, "realm=\"", '"');
|
||||
String nonce = extractParam(authReq, "nonce=\"", '"');
|
||||
String cNonce = getcNonce(8);
|
||||
|
||||
char nc[9];
|
||||
snprintf(nc, sizeof(nc), "%08x", counter);
|
||||
|
||||
// sha256 of the user:realm:password
|
||||
String ha1 = sha256(String(_config.Username) + ":" + realm + ":" + _config.Password);
|
||||
|
||||
// sha256 of method:uri
|
||||
String ha2 = sha256("GET:" + _uri);
|
||||
|
||||
// sha256 of h1:nonce:nc:cNonce:auth:h2
|
||||
String response = sha256(ha1 + ":" + nonce + ":" + String(nc) +
|
||||
":" + cNonce + ":" + "auth" + ":" + ha2);
|
||||
|
||||
// Final authorization String
|
||||
return String("Digest username=\"") + _config.Username +
|
||||
"\", realm=\"" + realm + "\", nonce=\"" + nonce + "\", uri=\"" +
|
||||
_uri + "\", cnonce=\"" + cNonce + "\", nc=" + nc +
|
||||
", qop=auth, response=\"" + response + "\", algorithm=SHA-256";
|
||||
}
|
||||
|
||||
void HttpGetter::addHeader(char const* key, char const* value)
|
||||
{
|
||||
_additionalHeaders.push_back({ key, value });
|
||||
}
|
||||
@ -9,13 +9,33 @@
|
||||
#include <base64.h>
|
||||
#include <ESPmDNS.h>
|
||||
|
||||
PowerMeterHttpJson::~PowerMeterHttpJson()
|
||||
bool PowerMeterHttpJson::init()
|
||||
{
|
||||
// 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();
|
||||
auto const& config = Configuration.get();
|
||||
|
||||
for (uint8_t i = 0; i < POWERMETER_HTTP_JSON_MAX_VALUES; i++) {
|
||||
auto const& valueConfig = config.PowerMeter.HttpJson[i];
|
||||
|
||||
_httpGetters[i] = nullptr;
|
||||
|
||||
if (i == 0 || (config.PowerMeter.HttpIndividualRequests && valueConfig.Enabled)) {
|
||||
_httpGetters[i] = std::make_unique<HttpGetter>(valueConfig.HttpRequest);
|
||||
}
|
||||
|
||||
if (!_httpGetters[i]) { continue; }
|
||||
|
||||
if (_httpGetters[i]->init()) {
|
||||
_httpGetters[i]->addHeader("Content-Type", "application/json");
|
||||
_httpGetters[i]->addHeader("Accept", "application/json");
|
||||
continue;
|
||||
}
|
||||
|
||||
MessageOutput.printf("[PowerMeterHttpJson] Initializing HTTP getter for value %d failed:\r\n", i + 1);
|
||||
MessageOutput.printf("[PowerMeterHttpJson] %s\r\n", _httpGetters[i]->getErrorText());
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void PowerMeterHttpJson::loop()
|
||||
@ -27,33 +47,72 @@ void PowerMeterHttpJson::loop()
|
||||
|
||||
_lastPoll = millis();
|
||||
|
||||
for (uint8_t i = 0; i < POWERMETER_HTTP_JSON_MAX_VALUES; i++) {
|
||||
auto const& valueConfig = config.PowerMeter.HttpJson[i];
|
||||
|
||||
if (!valueConfig.Enabled) {
|
||||
_cache[i] = 0.0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i == 0 || config.PowerMeter.HttpIndividualRequests) {
|
||||
if (!queryValue(i, valueConfig)) {
|
||||
MessageOutput.printf("[PowerMeterHttpJson] Getting HTTP response for phase %d failed.\r\n", i + 1);
|
||||
MessageOutput.printf("%s\r\n", httpPowerMeterError);
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if(!tryGetFloatValueForPhase(i, valueConfig.JsonPath, valueConfig.PowerUnit, valueConfig.SignInverted)) {
|
||||
MessageOutput.printf("[PowerMeterHttpJson] Reading power of phase %d (from JSON fetched with Phase 1 config) failed.\r\n", i + 1);
|
||||
MessageOutput.printf("%s\r\n", httpPowerMeterError);
|
||||
return;
|
||||
}
|
||||
auto res = poll();
|
||||
if (std::holds_alternative<String>(res)) {
|
||||
MessageOutput.printf("[PowerMeterHttpJson] %s\r\n", std::get<String>(res).c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
_powerValues = std::get<power_values_t>(res);
|
||||
gotUpdate();
|
||||
}
|
||||
|
||||
_powerValues = _cache;
|
||||
PowerMeterHttpJson::poll_result_t PowerMeterHttpJson::poll()
|
||||
{
|
||||
power_values_t cache;
|
||||
JsonDocument jsonResponse;
|
||||
|
||||
for (uint8_t i = 0; i < POWERMETER_HTTP_JSON_MAX_VALUES; i++) {
|
||||
auto const& cfg = Configuration.get().PowerMeter.HttpJson[i];
|
||||
|
||||
if (!cfg.Enabled) {
|
||||
cache[i] = 0.0;
|
||||
continue;
|
||||
}
|
||||
|
||||
auto const& upGetter = _httpGetters[i];
|
||||
|
||||
if (upGetter) {
|
||||
auto res = upGetter->performGetRequest();
|
||||
if (!res) {
|
||||
return upGetter->getErrorText();
|
||||
}
|
||||
|
||||
auto pStream = res.getStream();
|
||||
if (!pStream) {
|
||||
return String("Programmer error: HTTP request yields no stream");
|
||||
}
|
||||
|
||||
const DeserializationError error = deserializeJson(jsonResponse, *pStream);
|
||||
if (error) {
|
||||
String msg("Unable to parse server response as JSON: ");
|
||||
return msg + error.c_str();
|
||||
}
|
||||
}
|
||||
|
||||
auto pathResolutionResult = Utils::getJsonValueByPath<float>(jsonResponse, cfg.JsonPath);
|
||||
if (!pathResolutionResult.second.isEmpty()) {
|
||||
return pathResolutionResult.second;
|
||||
}
|
||||
|
||||
// this value is supposed to be in Watts and positive if energy is consumed
|
||||
cache[i] = pathResolutionResult.first;
|
||||
|
||||
switch (cfg.PowerUnit) {
|
||||
case Unit_t::MilliWatts:
|
||||
cache[i] /= 1000;
|
||||
break;
|
||||
case Unit_t::KiloWatts:
|
||||
cache[i] *= 1000;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (cfg.SignInverted) { cache[i] *= -1; }
|
||||
}
|
||||
|
||||
return cache;
|
||||
}
|
||||
|
||||
float PowerMeterHttpJson::getPowerTotal() const
|
||||
@ -69,279 +128,3 @@ void PowerMeterHttpJson::doMqttPublish() const
|
||||
mqttPublish("power2", _powerValues[1]);
|
||||
mqttPublish("power3", _powerValues[2]);
|
||||
}
|
||||
|
||||
bool PowerMeterHttpJson::queryValue(int phase, PowerMeterHttpJsonConfig 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;
|
||||
uint16_t port;
|
||||
extractUrlComponents(config.HttpRequest.Url, protocol, host, uri, port);
|
||||
|
||||
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(httpPowerMeterError, sizeof(httpPowerMeterError), 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(httpPowerMeterError, sizeof(httpPowerMeterError), PSTR("Error resolving host %s via DNS"), host.c_str());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ipaddr = MDNS.queryHost(host);
|
||||
if (ipaddr == INADDR_NONE){
|
||||
snprintf_P(httpPowerMeterError, sizeof(httpPowerMeterError), 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(httpPowerMeterError, sizeof(httpPowerMeterError), PSTR("Error resolving host %s via DNS"), host.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool https = protocol == "https";
|
||||
if (https) {
|
||||
auto secureWifiClient = std::make_unique<WiFiClientSecure>();
|
||||
secureWifiClient->setInsecure();
|
||||
wifiClient = std::move(secureWifiClient);
|
||||
} else {
|
||||
wifiClient = std::make_unique<WiFiClient>();
|
||||
}
|
||||
|
||||
return httpRequest(phase, ipaddr.toString(), port, uri, https, config);
|
||||
}
|
||||
|
||||
bool PowerMeterHttpJson::httpRequest(int phase, const String& host, uint16_t port, const String& uri, bool https, PowerMeterHttpJsonConfig const& powerMeterConfig)
|
||||
{
|
||||
if (!httpClient) { httpClient = std::make_unique<HTTPClient>(); }
|
||||
|
||||
if(!httpClient->begin(*wifiClient, host, port, uri, https)){
|
||||
snprintf_P(httpPowerMeterError, sizeof(httpPowerMeterError), PSTR("httpClient->begin() failed for %s://%s"), (https ? "https" : "http"), host.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
auto const& config = powerMeterConfig.HttpRequest;
|
||||
prepareRequest(config.Timeout, config.HeaderKey, config.HeaderValue);
|
||||
if (config.AuthType == Auth_t::Digest) {
|
||||
const char *headers[1] = {"WWW-Authenticate"};
|
||||
httpClient->collectHeaders(headers, 1);
|
||||
} else if (config.AuthType == Auth_t::Basic) {
|
||||
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 == HTTP_CODE_UNAUTHORIZED && config.AuthType == Auth_t::Digest) {
|
||||
// Handle authentication challenge
|
||||
if (httpClient->hasHeader("WWW-Authenticate")) {
|
||||
String authReq = httpClient->header("WWW-Authenticate");
|
||||
String authorization = getDigestAuth(authReq, String(config.Username), String(config.Password), "GET", String(uri), 1);
|
||||
httpClient->end();
|
||||
if(!httpClient->begin(*wifiClient, host, port, uri, https)){
|
||||
snprintf_P(httpPowerMeterError, sizeof(httpPowerMeterError), PSTR("httpClient->begin() failed for %s://%s using digest auth"), (https ? "https" : "http"), host.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
prepareRequest(config.Timeout, config.HeaderKey, config.HeaderValue);
|
||||
httpClient->addHeader("Authorization", authorization);
|
||||
httpCode = httpClient->GET();
|
||||
}
|
||||
}
|
||||
|
||||
if (httpCode <= 0) {
|
||||
snprintf_P(httpPowerMeterError, sizeof(httpPowerMeterError), PSTR("HTTP Error %s"), httpClient->errorToString(httpCode).c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (httpCode != HTTP_CODE_OK) {
|
||||
snprintf_P(httpPowerMeterError, sizeof(httpPowerMeterError), PSTR("Bad HTTP code: %d"), httpCode);
|
||||
return false;
|
||||
}
|
||||
|
||||
httpResponse = httpClient->getString(); // very unfortunate that we cannot parse WifiClient stream directly
|
||||
httpClient->end();
|
||||
|
||||
// TODO(schlimmchen): postpone calling tryGetFloatValueForPhase, as it
|
||||
// will be called twice for each phase when doing separate requests.
|
||||
return tryGetFloatValueForPhase(phase, powerMeterConfig.JsonPath, powerMeterConfig.PowerUnit, powerMeterConfig.SignInverted);
|
||||
}
|
||||
|
||||
String PowerMeterHttpJson::extractParam(String& authReq, const String& param, const char delimit) {
|
||||
int _begin = authReq.indexOf(param);
|
||||
if (_begin == -1) { return ""; }
|
||||
return authReq.substring(_begin + param.length(), authReq.indexOf(delimit, _begin + param.length()));
|
||||
}
|
||||
|
||||
String PowerMeterHttpJson::getcNonce(const int len) {
|
||||
static const char alphanum[] = "0123456789"
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
"abcdefghijklmnopqrstuvwxyz";
|
||||
String s = "";
|
||||
|
||||
for (int i = 0; i < len; ++i) { s += alphanum[rand() % (sizeof(alphanum) - 1)]; }
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
String PowerMeterHttpJson::getDigestAuth(String& authReq, const String& username, const String& password, const String& method, const String& uri, unsigned int counter) {
|
||||
// extracting required parameters for RFC 2617 Digest
|
||||
String realm = extractParam(authReq, "realm=\"", '"');
|
||||
String nonce = extractParam(authReq, "nonce=\"", '"');
|
||||
String cNonce = getcNonce(8);
|
||||
|
||||
char nc[9];
|
||||
snprintf(nc, sizeof(nc), "%08x", counter);
|
||||
|
||||
//sha256 of the user:realm:password
|
||||
String ha1 = sha256(username + ":" + realm + ":" + password);
|
||||
|
||||
//sha256 of method:uri
|
||||
String ha2 = sha256(method + ":" + uri);
|
||||
|
||||
//sha256 of h1:nonce:nc:cNonce:auth:h2
|
||||
String response = sha256(ha1 + ":" + nonce + ":" + String(nc) + ":" + cNonce + ":" + "auth" + ":" + ha2);
|
||||
|
||||
//Final authorization String;
|
||||
String authorization = "Digest username=\"";
|
||||
authorization += username;
|
||||
authorization += "\", realm=\"";
|
||||
authorization += realm;
|
||||
authorization += "\", nonce=\"";
|
||||
authorization += nonce;
|
||||
authorization += "\", uri=\"";
|
||||
authorization += uri;
|
||||
authorization += "\", cnonce=\"";
|
||||
authorization += cNonce;
|
||||
authorization += "\", nc=";
|
||||
authorization += String(nc);
|
||||
authorization += ", qop=auth, response=\"";
|
||||
authorization += response;
|
||||
authorization += "\", algorithm=SHA-256";
|
||||
|
||||
return authorization;
|
||||
}
|
||||
|
||||
bool PowerMeterHttpJson::tryGetFloatValueForPhase(int phase, String jsonPath, Unit_t unit, bool signInverted)
|
||||
{
|
||||
auto pathResolutionResult = Utils::getJsonValueFromStringByPath<float>(httpResponse, jsonPath);
|
||||
if (!pathResolutionResult.second.isEmpty()) {
|
||||
snprintf_P(httpPowerMeterError, sizeof(httpPowerMeterError),
|
||||
PSTR("[PowerMeterHttpJson] %s"),
|
||||
pathResolutionResult.second.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// this value is supposed to be in Watts and positive if energy is consumed.
|
||||
_cache[phase] = pathResolutionResult.first;
|
||||
|
||||
switch (unit) {
|
||||
case Unit_t::MilliWatts:
|
||||
_cache[phase] /= 1000;
|
||||
break;
|
||||
case Unit_t::KiloWatts:
|
||||
_cache[phase] *= 1000;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (signInverted) { _cache[phase] *= -1; }
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
//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 PowerMeterHttpJson::extractUrlComponents(String url, String& _protocol, String& _host, String& _uri, uint16_t& _port)
|
||||
{
|
||||
// check for : (http: or https:
|
||||
int index = url.indexOf(':');
|
||||
if(index < 0) {
|
||||
snprintf_P(httpPowerMeterError, sizeof(httpPowerMeterError), PSTR("failed to parse protocol"));
|
||||
return false;
|
||||
}
|
||||
|
||||
_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 += '/';
|
||||
}
|
||||
String host = url.substring(0, index);
|
||||
url.remove(0, index); // remove host part
|
||||
|
||||
// get Authorization
|
||||
index = host.indexOf('@');
|
||||
if(index >= 0) {
|
||||
// basic authentication is only supported through setting username
|
||||
// and password using the respective inputs, not embedded into the URL
|
||||
host.remove(0, index + 1); // remove auth part including @
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
_host = the_host;
|
||||
_uri = url;
|
||||
return true;
|
||||
}
|
||||
|
||||
String PowerMeterHttpJson::sha256(const String& data) {
|
||||
uint8_t hash[32];
|
||||
|
||||
mbedtls_sha256_context ctx;
|
||||
mbedtls_sha256_init(&ctx);
|
||||
mbedtls_sha256_starts(&ctx, 0); // select SHA256
|
||||
mbedtls_sha256_update(&ctx, reinterpret_cast<const unsigned char*>(data.c_str()), data.length());
|
||||
mbedtls_sha256_finish(&ctx, hash);
|
||||
mbedtls_sha256_free(&ctx);
|
||||
|
||||
char res[sizeof(hash) * 2 + 1];
|
||||
for (int i = 0; i < sizeof(hash); i++) {
|
||||
snprintf(res + (i*2), sizeof(res) - (i*2), "%02x", hash[i]);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
void PowerMeterHttpJson::prepareRequest(uint32_t timeout, const char* httpHeader, const char* httpValue) {
|
||||
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");
|
||||
|
||||
if (strlen(httpHeader) > 0) {
|
||||
httpClient->addHeader(httpHeader, httpValue);
|
||||
}
|
||||
}
|
||||
|
||||
@ -95,20 +95,14 @@ void Utils::removeAllFiles()
|
||||
|
||||
/* OpenDTU-OnBatter-specific utils go here: */
|
||||
template<typename T>
|
||||
std::pair<T, String> Utils::getJsonValueFromStringByPath(String const& jsonText, String const& path)
|
||||
std::pair<T, String> Utils::getJsonValueByPath(JsonDocument const& root, String const& path)
|
||||
{
|
||||
JsonDocument root;
|
||||
const DeserializationError error = deserializeJson(root, jsonText);
|
||||
if (error) {
|
||||
return { T(), "Unable to parse server response as JSON" };
|
||||
}
|
||||
|
||||
size_t constexpr kErrBufferSize = 256;
|
||||
char errBuffer[kErrBufferSize];
|
||||
constexpr char delimiter = '/';
|
||||
int start = 0;
|
||||
int end = path.indexOf(delimiter);
|
||||
auto value = root.as<JsonVariant>();
|
||||
auto value = root.as<JsonVariantConst>();
|
||||
|
||||
// NOTE: "Because ArduinoJson implements the Null Object Pattern, it is
|
||||
// always safe to read the object: if the key doesn't exist, it returns an
|
||||
@ -171,4 +165,4 @@ std::pair<T, String> Utils::getJsonValueFromStringByPath(String const& jsonText,
|
||||
return { value.as<T>(), "" };
|
||||
}
|
||||
|
||||
template std::pair<float, String> Utils::getJsonValueFromStringByPath(String const& jsonText, String const& path);
|
||||
template std::pair<float, String> Utils::getJsonValueByPath(JsonDocument const& root, String const& path);
|
||||
|
||||
@ -210,14 +210,21 @@ void WebApiPowerMeterClass::onTestHttpJsonRequest(AsyncWebServerRequest* request
|
||||
|
||||
char response[256];
|
||||
|
||||
PowerMeterHttpJsonConfig httpJsonConfig;
|
||||
Configuration.deserializePowerMeterHttpJsonConfig(root.as<JsonObject>(), httpJsonConfig);
|
||||
auto powerMeterConfig = std::make_unique<CONFIG_T::PowerMeterConfig>();
|
||||
Configuration.deserializePowerMeterHttpJsonConfig(root.as<JsonObject>(), powerMeterConfig->HttpJson[0]);
|
||||
auto backup = std::make_unique<CONFIG_T::PowerMeterConfig>(Configuration.get().PowerMeter);
|
||||
Configuration.get().PowerMeter = *powerMeterConfig;
|
||||
auto upMeter = std::make_unique<PowerMeterHttpJson>();
|
||||
if (upMeter->queryValue(0/*value index*/, httpJsonConfig)) {
|
||||
upMeter->init();
|
||||
auto res = upMeter->poll();
|
||||
Configuration.get().PowerMeter = *backup;
|
||||
using values_t = PowerMeterHttpJson::power_values_t;
|
||||
if (std::holds_alternative<values_t>(res)) {
|
||||
retMsg["type"] = "success";
|
||||
snprintf_P(response, sizeof(response), "Success! Power: %5.2fW", upMeter->getCached(0));
|
||||
auto vals = std::get<values_t>(res);
|
||||
snprintf_P(response, sizeof(response), "Result: %5.2fW", vals[0]);
|
||||
} else {
|
||||
snprintf_P(response, sizeof(response), "%s", upMeter->httpPowerMeterError);
|
||||
snprintf_P(response, sizeof(response), "%s", std::get<String>(res).c_str());
|
||||
}
|
||||
|
||||
retMsg["message"] = response;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user