Add extracted tools: CitrineOS, OpenOCPP, ShapeShifter
- CitrineOS core extracted (CSMS OCPP 2.0.1) - OpenOCPP extracted (firmware OCPP 1.6J/2.0.1) - ShapeShifter library installed (pip install -e) - ShapeShifter specification extracted - EVerest extracted TODO updated with progress
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
#
|
||||
# AUTO GENERATED - MARKED REGIONS WILL BE KEPT
|
||||
# template version 3
|
||||
#
|
||||
|
||||
# module setup:
|
||||
# - ${MODULE_NAME}: module name
|
||||
ev_setup_cpp_module()
|
||||
|
||||
# ev@bcc62523-e22b-41d7-ba2f-825b493a3c97:v1
|
||||
# insert your custom targets and additional config variables here
|
||||
target_link_libraries(${MODULE_NAME} PRIVATE
|
||||
CURL::libcurl
|
||||
nlohmann_json::nlohmann_json
|
||||
)
|
||||
|
||||
target_sources(${MODULE_NAME}
|
||||
PRIVATE
|
||||
main/isabellenhuetteIemDcrController.cpp
|
||||
main/httpClient.cpp
|
||||
)
|
||||
# ev@bcc62523-e22b-41d7-ba2f-825b493a3c97:v1
|
||||
|
||||
target_sources(${MODULE_NAME}
|
||||
PRIVATE
|
||||
"main/powermeterImpl.cpp"
|
||||
)
|
||||
|
||||
# ev@c55432ab-152c-45a9-9d2e-7281d50c69c3:v1
|
||||
# insert other things like install cmds etc here
|
||||
if(EVEREST_CORE_BUILD_TESTING)
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
# ev@c55432ab-152c-45a9-9d2e-7281d50c69c3:v1
|
||||
@@ -0,0 +1,15 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
#include "IsabellenhuetteIemDcr.hpp"
|
||||
|
||||
namespace module {
|
||||
|
||||
void IsabellenhuetteIemDcr::init() {
|
||||
invoke_init(*p_main);
|
||||
}
|
||||
|
||||
void IsabellenhuetteIemDcr::ready() {
|
||||
invoke_ready(*p_main);
|
||||
}
|
||||
|
||||
} // namespace module
|
||||
@@ -0,0 +1,71 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
#ifndef ISABELLENHUETTE_IEM_DCR_HPP
|
||||
#define ISABELLENHUETTE_IEM_DCR_HPP
|
||||
|
||||
//
|
||||
// AUTO GENERATED - MARKED REGIONS WILL BE KEPT
|
||||
// template version 2
|
||||
//
|
||||
|
||||
#include "ld-ev.hpp"
|
||||
|
||||
// headers for provided interface implementations
|
||||
#include <generated/interfaces/powermeter/Implementation.hpp>
|
||||
|
||||
// ev@4bf81b14-a215-475c-a1d3-0a484ae48918:v1
|
||||
// insert your custom include headers here
|
||||
// ev@4bf81b14-a215-475c-a1d3-0a484ae48918:v1
|
||||
|
||||
namespace module {
|
||||
|
||||
struct Conf {
|
||||
std::string ip_address;
|
||||
int port_http;
|
||||
std::string timezone;
|
||||
bool timezone_handle_DST;
|
||||
int datetime_resync_interval;
|
||||
int resilience_initial_connection_retry_delay;
|
||||
int resilience_transaction_request_retries;
|
||||
int resilience_transaction_request_retry_delay;
|
||||
std::string CT;
|
||||
std::string CI;
|
||||
std::string TT_initial;
|
||||
bool US;
|
||||
};
|
||||
|
||||
class IsabellenhuetteIemDcr : public Everest::ModuleBase {
|
||||
public:
|
||||
IsabellenhuetteIemDcr() = delete;
|
||||
IsabellenhuetteIemDcr(const ModuleInfo& info, std::unique_ptr<powermeterImplBase> p_main, Conf& config) :
|
||||
ModuleBase(info), p_main(std::move(p_main)), config(config){};
|
||||
|
||||
const std::unique_ptr<powermeterImplBase> p_main;
|
||||
const Conf& config;
|
||||
|
||||
// ev@1fce4c5e-0ab8-41bb-90f7-14277703d2ac:v1
|
||||
// insert your public definitions here
|
||||
// ev@1fce4c5e-0ab8-41bb-90f7-14277703d2ac:v1
|
||||
|
||||
protected:
|
||||
// ev@4714b2ab-a24f-4b95-ab81-36439e1478de:v1
|
||||
// insert your protected definitions here
|
||||
// ev@4714b2ab-a24f-4b95-ab81-36439e1478de:v1
|
||||
|
||||
private:
|
||||
friend class LdEverest;
|
||||
void init();
|
||||
void ready();
|
||||
|
||||
// ev@211cfdbe-f69a-4cd6-a4ec-f8aaa3d1b6c8:v1
|
||||
// insert your private definitions here
|
||||
// ev@211cfdbe-f69a-4cd6-a4ec-f8aaa3d1b6c8:v1
|
||||
};
|
||||
|
||||
// ev@087e516b-124c-48df-94fb-109508c7cda9:v1
|
||||
// insert other definitions here
|
||||
// ev@087e516b-124c-48df-94fb-109508c7cda9:v1
|
||||
|
||||
} // namespace module
|
||||
|
||||
#endif // ISABELLENHUETTE_IEM_DCR_HPP
|
||||
@@ -0,0 +1,55 @@
|
||||
.. _everest_modules_handwritten_IsabellenhuetteIemDcr:
|
||||
|
||||
.. *********************
|
||||
.. IsabellenhuetteIemDcr
|
||||
.. *********************
|
||||
|
||||
Module implements Isabellenhuette IEM-DCR power meter driver, connecting via HTTP/REST.
|
||||
|
||||
Implementation details
|
||||
======================
|
||||
|
||||
This section offers some additional information on driver implementation. The underlying HTTP communication functionality
|
||||
is mainly duplicated from other open source powermeter modules of EVerest to support a standarization of this interface
|
||||
later on.
|
||||
|
||||
Initialization
|
||||
--------------
|
||||
It begins with checking some plausibility measures on the handed configuration. Its default values are given in manifest.yaml.
|
||||
Please make sure to explicitly specify values that deviate from default configuration before starting the driver. If there is no
|
||||
conspicuousness in configuration, HTTP communication is verified with GET requests on /gw node. In case of no success, several
|
||||
retries are performed (as specified in config). On success POST /gw is issued for transfering CI, CT and datetime to IEM-DCR.
|
||||
Please note, that issuing POST /gw is only possible once after IEM-DCR power-up. So CI and CT are frozen until next power-cycle
|
||||
and datetime will be automatically updated using another node (POST /datetime) in configurable intervals. Therefore a warning
|
||||
will appear on EVerest console if CI and CT are already written and could not be updated. After this procedure the initial tariff
|
||||
text is transferred as configured. This will show up on display before a charging transaction.
|
||||
|
||||
Live values
|
||||
-----------
|
||||
Each second the MQTT variable Powermeter is updated to current values of /metervalue node. Also the public key is made available
|
||||
via MQTT.
|
||||
|
||||
Start transaction
|
||||
-----------------
|
||||
Starting a transaction will terminate any other running transaction (if there is one). The status type TransactionRequestStatus::
|
||||
NOT_SUPPORTED is returned, if given evse_id does not match CI (which was already transfered in initialization phase) and if IEM-DCR
|
||||
is in error state. Please refer to retrurned TransactionStartResponse.error for distinguishing between them. Starting a charging
|
||||
transaction will engage POST /user and POST /receipt. Please note that IEM-DCR automatically handles signed data tuple pagination. So
|
||||
the only place for transaction id defined by the charging station is the OCMF ID attribute. It will be filled from this driver with
|
||||
TransactionReq.identification_data. If this optional attribute is not given or empty, TransactionReq.transaction_id will be used
|
||||
instead. Please note that a transaction cannot be started while the sensor unit detects a current above activation treshold.
|
||||
Please refer to operation manual for details.
|
||||
|
||||
Stop transaction
|
||||
----------------
|
||||
If a transaction is in progress, it will be stopped and its signed data tuple returned. If no transaction is running, the last signed
|
||||
data tuple will be returned. Therefore input parameter transaction_id of this routine has no impact on its operation. Please note that
|
||||
TransactionRequestStatus::UNEXPECTED_ERROR may be returned, if no transaction is in progress and there has also been no transaction
|
||||
before. Please also note that a transaction cannot be stopped while the sensor unit detects a current above activation treshold.
|
||||
Please refer to operation manual for details.
|
||||
|
||||
References
|
||||
==========
|
||||
`IEM-DCR-125 <https://www.isabellenhuette.com/de/loesungen/produkte/iem-dcr-125>`_
|
||||
`IEM-DCR-1000 <https://www.isabellenhuette.com/de/loesungen/produkte/iem-dcr-1000>`_
|
||||
`IEM-DCR-1500 <https://www.isabellenhuette.com/de/loesungen/produkte/iem-dcr-1500>`_
|
||||
@@ -0,0 +1,154 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
#include "httpClient.hpp"
|
||||
#include <fmt/core.h>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace module::main {
|
||||
const char* CONTENT_TYPE_HEADER = "Content-Type: application/json";
|
||||
|
||||
struct PayloadInTransit {
|
||||
const std::string& data;
|
||||
size_t position;
|
||||
};
|
||||
|
||||
// Callback for receiving data, saves it into a string
|
||||
static size_t receive_data(char* ptr, size_t size, size_t nmemb, std::string* received_data) {
|
||||
received_data->append(ptr, size * nmemb);
|
||||
return size * nmemb;
|
||||
}
|
||||
|
||||
// Callback for sending data, fetches it from a string
|
||||
static size_t send_data(char* buffer, size_t size, size_t nitems, struct PayloadInTransit* payload) {
|
||||
if (payload->position >= payload->data.length()) {
|
||||
// Returning 0 signals to libcurl that we have no more data to send
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Send up to size*nitems bytes of data
|
||||
size_t payload_remaining_bytes = payload->data.length() - payload->position;
|
||||
size_t num_bytes_to_send = std::min(size * nitems, payload_remaining_bytes);
|
||||
std::memcpy(buffer, payload->data.c_str() + payload->position, num_bytes_to_send);
|
||||
payload->position += num_bytes_to_send;
|
||||
return num_bytes_to_send;
|
||||
}
|
||||
|
||||
static HttpClientError client_error(const std::string& host, unsigned int port, const char* method,
|
||||
const std::string& path, const std::string& message) {
|
||||
return HttpClientError(fmt::format("HTTP client error on {} {}:{}{} : {} ", method, host, port, path, message));
|
||||
}
|
||||
|
||||
static void setup_connection(CURL* connection, struct PayloadInTransit& request_payload, std::string& response_body,
|
||||
curl_slist*& headers) {
|
||||
// Override the Content-Type header
|
||||
headers = curl_slist_append(nullptr, CONTENT_TYPE_HEADER);
|
||||
if (curl_easy_setopt(connection, CURLOPT_HTTPHEADER, headers) != CURLE_OK) {
|
||||
throw std::runtime_error(
|
||||
"libcurl signals that HTTP is unsupported. Your build or linkage might be misconfigured.");
|
||||
}
|
||||
|
||||
// Set up callbacks for reading and writing
|
||||
curl_easy_setopt(connection, CURLOPT_WRITEFUNCTION, receive_data);
|
||||
curl_easy_setopt(connection, CURLOPT_WRITEDATA, &response_body);
|
||||
curl_easy_setopt(connection, CURLOPT_READFUNCTION, send_data);
|
||||
curl_easy_setopt(connection, CURLOPT_READDATA, &request_payload);
|
||||
|
||||
// Misc. settings come here
|
||||
curl_easy_setopt(connection, CURLOPT_FORBID_REUSE, 1);
|
||||
curl_easy_setopt(connection, CURLOPT_CONNECTTIMEOUT, 2);
|
||||
if (curl_easy_setopt(connection, CURLOPT_FOLLOWLOCATION, 0) != CURLE_OK) {
|
||||
throw std::runtime_error(
|
||||
"libcurl signals that HTTP is unsupported. Your build or linkage might be misconfigured.");
|
||||
}
|
||||
}
|
||||
|
||||
// Note: method_name and path are only there for the error message
|
||||
HttpResponse HttpClient::perform_request(CURL* connection, const std::string& request_body, const char* method_name,
|
||||
const std::string& path) const {
|
||||
// give curl a buffer to write its error messages to
|
||||
char curl_error_message[CURL_ERROR_SIZE] = {};
|
||||
curl_easy_setopt(connection, CURLOPT_ERRORBUFFER, curl_error_message);
|
||||
|
||||
// set up the connection options
|
||||
std::string response_body;
|
||||
struct PayloadInTransit request_payload {
|
||||
request_body, 0
|
||||
};
|
||||
struct curl_slist* headers;
|
||||
setup_connection(connection, request_payload, response_body, headers);
|
||||
|
||||
// perform the request
|
||||
CURLcode res = curl_easy_perform(connection);
|
||||
|
||||
// remember to free the headers list...
|
||||
curl_slist_free_all(headers);
|
||||
// check the result of the request and return
|
||||
if (res == CURLE_OK) {
|
||||
long response_code;
|
||||
curl_easy_getinfo(connection, CURLINFO_RESPONSE_CODE, &response_code);
|
||||
return HttpResponse{(unsigned int)response_code, std::move(response_body)};
|
||||
} else {
|
||||
throw client_error(this->host, this->port, method_name, path, std::string(curl_error_message));
|
||||
}
|
||||
}
|
||||
|
||||
CURL* HttpClient::create_curl_handle_and_setup_url(const std::string& path) const {
|
||||
CURL* connection = curl_easy_init();
|
||||
if (!connection) {
|
||||
throw std::runtime_error("Could not create a CURL handle: curl_easy_init() returned null");
|
||||
}
|
||||
const char* protocol = "http";
|
||||
if (curl_easy_setopt(connection, CURLOPT_URL,
|
||||
fmt::format("{}://{}:{}{}", protocol, this->host, this->port, path).c_str()) != CURLE_OK) {
|
||||
throw std::runtime_error("Could not set CURLOPT_URL, likely ran out of memory");
|
||||
}
|
||||
if (curl_easy_setopt(connection, CURLOPT_PROTOCOLS_STR, protocol) != CURLE_OK) {
|
||||
throw std::runtime_error(std::string("Could not set supported protocol to ") + protocol +
|
||||
", is it enabled in libcurl?");
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
HttpResponse HttpClient::get(const std::string& path) const {
|
||||
CURL* connection = this->create_curl_handle_and_setup_url(path);
|
||||
|
||||
if (curl_easy_setopt(connection, CURLOPT_HTTPGET, 1) != CURLE_OK) {
|
||||
curl_easy_cleanup(connection);
|
||||
throw std::runtime_error(
|
||||
"libcurl signals that HTTP is unsupported. Your build or linkage might be misconfigured.");
|
||||
}
|
||||
|
||||
// perform_request() does not cleanup the connection on its own.
|
||||
// We do the cleanup here, and make sure to rethrow any exception that might've occurred.
|
||||
try {
|
||||
HttpResponse response = perform_request(connection, "", "GET", path);
|
||||
curl_easy_cleanup(connection);
|
||||
return response;
|
||||
} catch (std::exception& e) {
|
||||
curl_easy_cleanup(connection);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
HttpResponse HttpClient::post(const std::string& path, const std::string& body) const {
|
||||
CURL* connection = this->create_curl_handle_and_setup_url(path);
|
||||
|
||||
if (curl_easy_setopt(connection, CURLOPT_POST, 1) != CURLE_OK) {
|
||||
curl_easy_cleanup(connection);
|
||||
throw std::runtime_error(
|
||||
"libcurl signals that HTTP is unsupported. Your build or linkage might be misconfigured.");
|
||||
}
|
||||
|
||||
// perform_request() does not cleanup the connection on its own.
|
||||
// We do the cleanup here, and make sure to rethrow any exception that might've occurred.
|
||||
try {
|
||||
HttpResponse response = perform_request(connection, body, "POST", path);
|
||||
curl_easy_cleanup(connection);
|
||||
return response;
|
||||
} catch (std::exception& e) {
|
||||
curl_easy_cleanup(connection);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
} // namespace module::main
|
||||
@@ -0,0 +1,50 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
#ifndef EVEREST_CORE_MODULE_HTTPCLIENT_H
|
||||
#define EVEREST_CORE_MODULE_HTTPCLIENT_H
|
||||
|
||||
#include "fmt/format.h"
|
||||
#include "httpClientInterface.hpp"
|
||||
#include <curl/curl.h>
|
||||
#include <everest/logging.hpp>
|
||||
#include <regex>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace module::main {
|
||||
|
||||
class HttpClient : public HttpClientInterface {
|
||||
|
||||
public:
|
||||
HttpClient() = delete;
|
||||
|
||||
HttpClient(const std::string& host_arg, int port_arg) {
|
||||
// initialize libcurl - this is safe to do multiple times, if there are multiple HttpClients
|
||||
// Note: This is only thread-safe after libcurl 7.84.0, but we use 8.4.0, so it should be fine
|
||||
curl_global_init(CURL_GLOBAL_DEFAULT);
|
||||
// These are saved in the client to avoid making the controller pass them at every call
|
||||
host = host_arg;
|
||||
port = port_arg;
|
||||
}
|
||||
~HttpClient() override {
|
||||
// release the libcurl resources - this must be done once for every call to curl_global_init().
|
||||
// Note: This is only thread-safe after libcurl 7.84.0, but we use 8.4.0, so it should be fine
|
||||
curl_global_cleanup();
|
||||
}
|
||||
|
||||
[[nodiscard]] HttpResponse get(const std::string& path) const override;
|
||||
[[nodiscard]] HttpResponse post(const std::string& path, const std::string& body) const override;
|
||||
|
||||
private:
|
||||
std::string host;
|
||||
int port;
|
||||
|
||||
[[nodiscard]] CURL* create_curl_handle_and_setup_url(const std::string& path) const;
|
||||
HttpResponse perform_request(CURL* connection, const std::string& request_body, const char* method_name,
|
||||
const std::string& path) const;
|
||||
};
|
||||
|
||||
} // namespace module::main
|
||||
|
||||
#endif // EVEREST_CORE_MODULE_HTTPCLIENT_H
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
#ifndef EVEREST_CORE_MODULE_HTTP_CLIENT_INTERFACE_H
|
||||
#define EVEREST_CORE_MODULE_HTTP_CLIENT_INTERFACE_H
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace module::main {
|
||||
|
||||
class HttpClientError : public std::exception {
|
||||
public:
|
||||
[[nodiscard]] const char* what() const noexcept override {
|
||||
return this->reason.c_str();
|
||||
}
|
||||
explicit HttpClientError(std::string msg) {
|
||||
this->reason = std::move(msg);
|
||||
}
|
||||
explicit HttpClientError(const char* msg) {
|
||||
this->reason = std::string(msg);
|
||||
}
|
||||
|
||||
private:
|
||||
std::string reason;
|
||||
};
|
||||
|
||||
struct HttpResponse {
|
||||
unsigned int status_code;
|
||||
std::string body;
|
||||
};
|
||||
|
||||
struct HttpClientInterface {
|
||||
|
||||
virtual ~HttpClientInterface() = default;
|
||||
|
||||
[[nodiscard]] virtual HttpResponse get(const std::string& path) const = 0;
|
||||
[[nodiscard]] virtual HttpResponse post(const std::string& path, const std::string& body) const = 0;
|
||||
};
|
||||
|
||||
} // namespace module::main
|
||||
|
||||
#endif // EVEREST_CORE_MODULE_HTTP_CLIENT_INTERFACE_H
|
||||
@@ -0,0 +1,480 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
#include "isabellenhuetteIemDcrController.hpp"
|
||||
#include <stdexcept>
|
||||
namespace module::main {
|
||||
|
||||
IsaIemDcrController::IsaIemDcrController(std::unique_ptr<HttpClientInterface> http_client,
|
||||
const SnapshotConfig& snap_config) :
|
||||
http_client(std::move(http_client)), snapshot_config(snap_config) {
|
||||
// Member Initializer List is used
|
||||
|
||||
// Further initialization
|
||||
zone_time_offset = helper_convert_timezone(snapshot_config.timezone);
|
||||
last_datetime_sync.store(std::chrono::steady_clock::now() - std::chrono::hours(48));
|
||||
}
|
||||
|
||||
bool IsaIemDcrController::init() {
|
||||
try {
|
||||
EVLOG_info << "Isabellenhuette IEM-DCR: Connecting to module...";
|
||||
// Check connection with polling REST node gw
|
||||
this->get_gw();
|
||||
// Send gw information
|
||||
try {
|
||||
this->post_gw();
|
||||
} catch (IsaIemDcrController::UnexpectedIemDcrResponseCode& error) {
|
||||
EVLOG_warning << "Node /gw seems to be already set. If those values should be updated, "
|
||||
"please restart IEM-DCR and then also this system.";
|
||||
}
|
||||
// Send initial tariff information
|
||||
try {
|
||||
if (snapshot_config.TT_initial.length() > 0) {
|
||||
this->post_tariff(snapshot_config.TT_initial);
|
||||
}
|
||||
} catch (IsaIemDcrController::UnexpectedIemDcrResponseCode& error) {
|
||||
EVLOG_warning << "Incorrect config: Value TT_initial could not be set. Please check its value.";
|
||||
}
|
||||
EVLOG_info << "Isabellenhuette IEM-DCR: Connected.";
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
json IsaIemDcrController::get_gw() {
|
||||
const std::string endpoint = "/counter/v1/ocmf/gw";
|
||||
auto response = this->http_client->get(endpoint);
|
||||
if (response.status_code == 200) {
|
||||
try {
|
||||
json data = json::parse(response.body);
|
||||
return data;
|
||||
} catch (json::exception& json_error) {
|
||||
throw UnexpectedIemDcrResponseBody(
|
||||
endpoint, fmt::format("Json error {} for body {}", json_error.what(), response.body));
|
||||
}
|
||||
} else {
|
||||
throw UnexpectedIemDcrResponseCode(endpoint, 200, response);
|
||||
}
|
||||
}
|
||||
|
||||
bool IsaIemDcrController::check_gw_is_empty() {
|
||||
json gw_result = this->get_gw();
|
||||
return gw_result.at("CT").empty();
|
||||
}
|
||||
|
||||
void IsaIemDcrController::post_gw() {
|
||||
const std::string endpoint = "/counter/v1/ocmf/gw";
|
||||
const std::string payload = nlohmann::ordered_json{{"CT", snapshot_config.CT},
|
||||
{"CI", snapshot_config.CI},
|
||||
{"TM", helper_get_current_datetime()}}
|
||||
.dump();
|
||||
auto response = this->http_client->post(endpoint, payload);
|
||||
if (response.status_code == 200) {
|
||||
last_datetime_sync.store(std::chrono::steady_clock::now());
|
||||
} else {
|
||||
throw UnexpectedIemDcrResponseCode(endpoint, 200, response);
|
||||
}
|
||||
}
|
||||
|
||||
void IsaIemDcrController::post_tariff(std::string tariff_info) {
|
||||
const std::string endpoint = "/counter/v1/ocmf/tariff";
|
||||
const std::string payload = nlohmann::ordered_json{{"TT", tariff_info}}.dump();
|
||||
auto response = this->http_client->post(endpoint, payload);
|
||||
if (response.status_code != 200) {
|
||||
throw UnexpectedIemDcrResponseCode(endpoint, 200, response);
|
||||
}
|
||||
}
|
||||
|
||||
std::tuple<types::powermeter::Powermeter, std::string, bool> IsaIemDcrController::get_metervalue() {
|
||||
const std::string endpoint = "/counter/v1/ocmf/metervalue";
|
||||
auto response = this->http_client->get(endpoint);
|
||||
if (response.status_code != 200) {
|
||||
throw UnexpectedIemDcrResponseCode(endpoint, 200, response);
|
||||
}
|
||||
try {
|
||||
json data = json::parse(response.body);
|
||||
|
||||
types::powermeter::Powermeter powermeter;
|
||||
bool tmp_transaction_active = data.at("XT");
|
||||
powermeter.timestamp = data.at("TM");
|
||||
// Remove format specifier at the end (if available)
|
||||
if (powermeter.timestamp.length() > 28) {
|
||||
powermeter.timestamp = powermeter.timestamp.substr(0, 28);
|
||||
}
|
||||
powermeter.meter_id = data.at("MS");
|
||||
auto current = types::units::Current{};
|
||||
current.DC = data.at("I");
|
||||
powermeter.current_A.emplace(current);
|
||||
auto voltageU2 = types::units::Voltage{};
|
||||
voltageU2.DC = data.at("U2");
|
||||
powermeter.voltage_V.emplace(voltageU2);
|
||||
powermeter.power_W.emplace(types::units::Power{data.at("P").get<float>()});
|
||||
// Remove quotes before casting to float
|
||||
auto energy_kWh_import = helper_remove_first_and_last_char(data.at("RD").at(2).at("WV"));
|
||||
powermeter.energy_Wh_import = {std::stof(energy_kWh_import) * 1000.0f};
|
||||
// Remove quotes before casting to float
|
||||
auto energy_kWh_export = helper_remove_first_and_last_char(data.at("RD").at(3).at("WV"));
|
||||
powermeter.energy_Wh_export = {std::stof(energy_kWh_export) * 1000.0f};
|
||||
// Get status
|
||||
std::string status = data.at("XC");
|
||||
|
||||
return std::make_tuple(powermeter, status, tmp_transaction_active);
|
||||
} catch (json::exception& json_error) {
|
||||
throw UnexpectedIemDcrResponseBody(endpoint,
|
||||
fmt::format("Json error {} for body {}", json_error.what(), response.body));
|
||||
}
|
||||
}
|
||||
|
||||
std::string IsaIemDcrController::get_publickey(bool allow_cached_value) {
|
||||
if (allow_cached_value && cached_public_key.length() > 0) {
|
||||
return cached_public_key;
|
||||
} else {
|
||||
const std::string endpoint = "/counter/v1/ocmf/publickey";
|
||||
auto response = this->http_client->get(endpoint);
|
||||
if (response.status_code != 200) {
|
||||
EVLOG_warning << "Response to retrieval of public key is not 200." << std::endl;
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
json data = json::parse(response.body);
|
||||
cached_public_key = data.at("PK");
|
||||
return cached_public_key;
|
||||
} catch (json::exception& json_error) {
|
||||
EVLOG_warning << "JSON error during parsing of public key" << std::endl;
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string IsaIemDcrController::get_datetime() {
|
||||
const std::string endpoint = "/counter/v1/ocmf/datetime";
|
||||
auto response = this->http_client->get(endpoint);
|
||||
if (response.status_code != 200) {
|
||||
throw UnexpectedIemDcrResponseCode(endpoint, 200, response);
|
||||
}
|
||||
try {
|
||||
json data = json::parse(response.body);
|
||||
return data.at("TM");
|
||||
} catch (json::exception& json_error) {
|
||||
throw UnexpectedIemDcrResponseBody(endpoint,
|
||||
fmt::format("Json error {} for body {}", json_error.what(), response.body));
|
||||
}
|
||||
}
|
||||
|
||||
void IsaIemDcrController::post_datetime() {
|
||||
const std::string endpoint = "/counter/v1/ocmf/datetime";
|
||||
const std::string payload = nlohmann::ordered_json{{"TM", helper_get_current_datetime()}}.dump();
|
||||
auto response = this->http_client->post(endpoint, payload);
|
||||
if (response.status_code == 200) {
|
||||
last_datetime_sync.store(std::chrono::steady_clock::now());
|
||||
} else {
|
||||
throw UnexpectedIemDcrResponseCode(endpoint, 200, response);
|
||||
}
|
||||
}
|
||||
|
||||
void IsaIemDcrController::refresh_datetime_if_required() {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
const auto elapsed = std::chrono::duration_cast<std::chrono::hours>(now - last_datetime_sync.load());
|
||||
if (elapsed.count() >= snapshot_config.datetime_resync_interval) {
|
||||
try {
|
||||
this->post_datetime();
|
||||
EVLOG_info << "DateTime resynchronized.";
|
||||
} catch (...) {
|
||||
// On error: just retry on next call
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void IsaIemDcrController::post_user(const types::powermeter::OCMFUserIdentificationStatus IS,
|
||||
const std::optional<types::powermeter::OCMFIdentificationLevel> IL,
|
||||
const std::vector<types::powermeter::OCMFIdentificationFlags>& IF,
|
||||
const types::powermeter::OCMFIdentificationType& IT,
|
||||
const std::optional<std::__cxx11::basic_string<char>>& ID,
|
||||
const std::optional<std::__cxx11::basic_string<char>>& TT) {
|
||||
|
||||
const std::string endpoint = "/counter/v1/ocmf/user";
|
||||
bool boolIS = helper_get_bool_from_OCMFUserIdentificationStatus(IS);
|
||||
std::string strIL = helper_get_string_from_OCMFIdentificationLevel(IL);
|
||||
std::string strIT = helper_get_string_from_OCMFIdentificationType(IT);
|
||||
std::string strID = static_cast<std::string>(ID.value_or(""));
|
||||
std::string strTT = static_cast<std::string>(TT.value_or(""));
|
||||
std::string payload = "";
|
||||
std::vector<std::string> vectIF;
|
||||
|
||||
// Fill vectIF
|
||||
for (const types::powermeter::OCMFIdentificationFlags& id_flag : IF) {
|
||||
vectIF.push_back(helper_get_string_from_OCMFIdentificationFlags(id_flag));
|
||||
}
|
||||
|
||||
if (strTT.length() > 0) {
|
||||
payload = nlohmann::ordered_json{{"IS", boolIS}, {"IL", strIL}, {"IF", vectIF},
|
||||
{"IT", strIT}, {"ID", strID}, {"US", snapshot_config.US},
|
||||
{"TT", strTT}}
|
||||
.dump();
|
||||
} else {
|
||||
payload = nlohmann::ordered_json{{"IS", boolIS}, {"IL", strIL}, {"IF", vectIF},
|
||||
{"IT", strIT}, {"ID", strID}, {"US", snapshot_config.US}}
|
||||
.dump();
|
||||
}
|
||||
auto response = this->http_client->post(endpoint, payload);
|
||||
if (response.status_code != 200) {
|
||||
throw UnexpectedIemDcrResponseCode(endpoint, 200, response);
|
||||
}
|
||||
}
|
||||
|
||||
types::units_signed::SignedMeterValue IsaIemDcrController::get_receipt() {
|
||||
const std::string endpoint = "/counter/v1/ocmf/receipt";
|
||||
return helper_get_signed_datatuple(endpoint);
|
||||
}
|
||||
|
||||
types::units_signed::SignedMeterValue IsaIemDcrController::get_transaction() {
|
||||
try {
|
||||
const std::string endpoint = "/counter/v1/ocmf/transaction";
|
||||
return helper_get_signed_datatuple(endpoint);
|
||||
} catch (UnexpectedIemDcrResponseCode& resp_error) {
|
||||
// Retry with newer api endpoint
|
||||
const std::string endpoint_v2 = "/counter/v2/ocmf/transaction";
|
||||
return helper_get_signed_datatuple(endpoint_v2);
|
||||
}
|
||||
}
|
||||
|
||||
void IsaIemDcrController::post_receipt(const std::string& TX) {
|
||||
const std::string endpoint = "/counter/v1/ocmf/receipt";
|
||||
const std::string payload = nlohmann::ordered_json{{"TX", TX}}.dump();
|
||||
auto response = this->http_client->post(endpoint, payload);
|
||||
if (response.status_code != 200) {
|
||||
throw UnexpectedIemDcrResponseCode(endpoint, 200, response);
|
||||
}
|
||||
}
|
||||
|
||||
bool IsaIemDcrController::helper_get_bool_from_OCMFUserIdentificationStatus(
|
||||
types::powermeter::OCMFUserIdentificationStatus IS) {
|
||||
return (IS == types::powermeter::OCMFUserIdentificationStatus::ASSIGNED);
|
||||
}
|
||||
|
||||
std::string IsaIemDcrController::helper_get_string_from_OCMFIdentificationLevel(
|
||||
std::optional<types::powermeter::OCMFIdentificationLevel> IL) {
|
||||
std::string result;
|
||||
types::powermeter::OCMFIdentificationLevel value_IL =
|
||||
IL.value_or(types::powermeter::OCMFIdentificationLevel::UNKNOWN);
|
||||
switch (value_IL) {
|
||||
case types::powermeter::OCMFIdentificationLevel::NONE:
|
||||
result = "NONE";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationLevel::HEARSAY:
|
||||
result = "HEARSAY";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationLevel::TRUSTED:
|
||||
result = "TRUSTED";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationLevel::VERIFIED:
|
||||
result = "VERIFIED";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationLevel::CERTIFIED:
|
||||
result = "CERTIFIED";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationLevel::SECURE:
|
||||
result = "SECURE";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationLevel::MISMATCH:
|
||||
result = "MISMATCH";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationLevel::INVALID:
|
||||
result = "INVALID";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationLevel::OUTDATED:
|
||||
result = "OUTDATED";
|
||||
break;
|
||||
default:
|
||||
result = "UNKNOWN";
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string IsaIemDcrController::helper_get_string_from_OCMFIdentificationFlags(
|
||||
types::powermeter::OCMFIdentificationFlags id_flag) {
|
||||
std::string result;
|
||||
switch (id_flag) {
|
||||
case types::powermeter::OCMFIdentificationFlags::RFID_NONE:
|
||||
result = "RFID_NONE";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationFlags::RFID_PLAIN:
|
||||
result = "RFID_PLAIN";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationFlags::RFID_RELATED:
|
||||
result = "RFID_RELATED";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationFlags::RFID_PSK:
|
||||
result = "RFID_PSK";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationFlags::OCPP_NONE:
|
||||
result = "OCPP_NONE";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationFlags::OCPP_RS:
|
||||
result = "OCPP_RS";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationFlags::OCPP_AUTH:
|
||||
result = "OCPP_AUTH";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationFlags::OCPP_RS_TLS:
|
||||
result = "OCPP_RS_TLS";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationFlags::OCPP_AUTH_TLS:
|
||||
result = "OCPP_AUTH_TLS";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationFlags::OCPP_CACHE:
|
||||
result = "OCPP_CACHE";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationFlags::OCPP_WHITELIST:
|
||||
result = "OCPP_WHITELIST";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationFlags::OCPP_CERTIFIED:
|
||||
result = "OCPP_CERTIFIED";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationFlags::ISO15118_NONE:
|
||||
result = "ISO15118_NONE";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationFlags::ISO15118_PNC:
|
||||
result = "ISO15118_PNC";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationFlags::PLMN_NONE:
|
||||
result = "PLMN_NONE";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationFlags::PLMN_RING:
|
||||
result = "PLMN_RING";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationFlags::PLMN_SMS:
|
||||
result = "PLMN_SMS";
|
||||
break;
|
||||
default:
|
||||
result = "UNKNOWN";
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string
|
||||
IsaIemDcrController::helper_get_string_from_OCMFIdentificationType(types::powermeter::OCMFIdentificationType IT) {
|
||||
std::string result;
|
||||
switch (IT) {
|
||||
case types::powermeter::OCMFIdentificationType::DENIED:
|
||||
result = "DENIED";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationType::UNDEFINED:
|
||||
result = "UNDEFINED";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationType::ISO14443:
|
||||
result = "ISO14443";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationType::ISO15693:
|
||||
result = "ISO15693";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationType::EMAID:
|
||||
result = "EMAID";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationType::EVCCID:
|
||||
result = "EVCCID";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationType::EVCOID:
|
||||
result = "EVCOID";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationType::ISO7812:
|
||||
result = "ISO7812";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationType::CARD_TXN_NR:
|
||||
result = "CARD_TXN_NR";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationType::CENTRAL:
|
||||
result = "CENTRAL";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationType::CENTRAL_1:
|
||||
result = "CENTRAL_1";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationType::CENTRAL_2:
|
||||
result = "CENTRAL_2";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationType::LOCAL:
|
||||
result = "LOCAL";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationType::LOCAL_1:
|
||||
result = "LOCAL_1";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationType::LOCAL_2:
|
||||
result = "LOCAL_2";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationType::PHONE_NUMBER:
|
||||
result = "PHONE_NUMBER";
|
||||
break;
|
||||
case types::powermeter::OCMFIdentificationType::KEY_CODE:
|
||||
result = "KEY_CODE";
|
||||
break;
|
||||
default:
|
||||
result = "NONE";
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::chrono::minutes IsaIemDcrController::helper_convert_timezone(std::string& timezone) {
|
||||
const char sign_char = timezone[0];
|
||||
const int offset_hours = std::stoi(timezone.substr(1, 2));
|
||||
const int offset_minutes = std::stoi(timezone.substr(3, 2));
|
||||
const std::chrono::minutes time_offset = std::chrono::hours(offset_hours) + std::chrono::minutes(offset_minutes);
|
||||
if (sign_char == '+') {
|
||||
return time_offset;
|
||||
} else {
|
||||
return -time_offset;
|
||||
}
|
||||
}
|
||||
|
||||
bool IsaIemDcrController::helper_is_daylight_saving_time() {
|
||||
const std::time_t now = std::time(nullptr);
|
||||
const std::tm* localTime = std::localtime(&now);
|
||||
return localTime->tm_isdst > 0;
|
||||
}
|
||||
|
||||
std::string IsaIemDcrController::helper_get_current_datetime() {
|
||||
// Get UTC time
|
||||
auto now = std::chrono::system_clock::now();
|
||||
// Add configured timezone information
|
||||
std::time_t now_with_offset = std::chrono::system_clock::to_time_t(now + zone_time_offset);
|
||||
// Add DST offset if configured
|
||||
if (snapshot_config.timezone_handle_DST && helper_is_daylight_saving_time()) {
|
||||
now_with_offset = now_with_offset + 3600;
|
||||
}
|
||||
// Generate and return time in correct format
|
||||
std::ostringstream oss;
|
||||
oss << std::put_time(gmtime(&now_with_offset), "%FT%T,000") << snapshot_config.timezone;
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
std::string IsaIemDcrController::helper_remove_first_and_last_char(const std::string& input) {
|
||||
if (input.length() <= 1) {
|
||||
return "";
|
||||
}
|
||||
return input.substr(1, input.length() - 1);
|
||||
}
|
||||
|
||||
types::units_signed::SignedMeterValue IsaIemDcrController::helper_get_signed_datatuple(const std::string& endpoint) {
|
||||
auto response = this->http_client->get(endpoint);
|
||||
types::units_signed::SignedMeterValue return_value;
|
||||
if (response.status_code == 200) {
|
||||
try {
|
||||
return_value.signed_meter_data = response.body;
|
||||
return_value.signing_method = "";
|
||||
return_value.encoding_method = "OCMF";
|
||||
return_value.public_key = get_publickey(true);
|
||||
|
||||
return return_value;
|
||||
} catch (json::exception& json_error) {
|
||||
throw UnexpectedIemDcrResponseBody(
|
||||
endpoint, fmt::format("Json error {} for body {}", json_error.what(), response.body));
|
||||
}
|
||||
} else {
|
||||
throw UnexpectedIemDcrResponseCode(endpoint, 200, response);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace module::main
|
||||
@@ -0,0 +1,170 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
#ifndef EVEREST_CORE_MODULE_ISAIEMDCRCONTROLLER_H
|
||||
#define EVEREST_CORE_MODULE_ISAIEMDCRCONTROLLER_H
|
||||
|
||||
#include "httpClientInterface.hpp"
|
||||
#include <functional>
|
||||
#include <generated/interfaces/powermeter/Implementation.hpp>
|
||||
#include <mutex>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
|
||||
namespace module::main {
|
||||
|
||||
class IsaIemDcrController {
|
||||
|
||||
public:
|
||||
struct SnapshotConfig {
|
||||
std::string timezone;
|
||||
bool timezone_handle_DST;
|
||||
int datetime_resync_interval;
|
||||
int resilience_initial_connection_retry_delay;
|
||||
int resilience_transaction_request_retries;
|
||||
int resilience_transaction_request_retry_delay;
|
||||
std::string CT;
|
||||
std::string CI;
|
||||
std::string TT_initial;
|
||||
bool US;
|
||||
};
|
||||
|
||||
class IemDcrUnexpectedResponseException : public std::exception {
|
||||
public:
|
||||
const char* what() {
|
||||
return this->reason.c_str();
|
||||
}
|
||||
|
||||
explicit IemDcrUnexpectedResponseException(std::string reason) : reason(std::move(reason)) {
|
||||
}
|
||||
|
||||
private:
|
||||
std::string reason;
|
||||
};
|
||||
|
||||
class UnexpectedIemDcrResponseBody : public IemDcrUnexpectedResponseException {
|
||||
public:
|
||||
UnexpectedIemDcrResponseBody(std::string endpoint, std::string error) :
|
||||
IemDcrUnexpectedResponseException(
|
||||
fmt::format("Received unexpected response body from endpoint {}: {}", endpoint, error)),
|
||||
endpoint(std::move(endpoint)),
|
||||
error(std::move(error)) {
|
||||
}
|
||||
|
||||
private:
|
||||
std::string endpoint;
|
||||
std::string error;
|
||||
};
|
||||
|
||||
class UnexpectedIemDcrResponseCode : public IemDcrUnexpectedResponseException {
|
||||
public:
|
||||
const std::string endpoint;
|
||||
const HttpResponse response;
|
||||
const std::string body;
|
||||
|
||||
UnexpectedIemDcrResponseCode(const std::string& endpoint, unsigned int expected_code,
|
||||
const HttpResponse& response) :
|
||||
IemDcrUnexpectedResponseException(fmt::format(
|
||||
"Received unexpected response from endpoint '{}': {} (expected {}) {}", endpoint, response.status_code,
|
||||
expected_code, !response.body.empty() ? " - body: " + response.body : "")),
|
||||
endpoint(endpoint),
|
||||
response(response) {
|
||||
}
|
||||
};
|
||||
|
||||
class ThreadSafeString {
|
||||
public:
|
||||
ThreadSafeString() : value("") {
|
||||
}
|
||||
|
||||
void store(const std::string& new_value) {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
value = new_value;
|
||||
}
|
||||
|
||||
std::string load() const {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
return value;
|
||||
}
|
||||
|
||||
private:
|
||||
mutable std::mutex mutex;
|
||||
std::string value;
|
||||
};
|
||||
|
||||
bool init();
|
||||
json get_gw();
|
||||
bool check_gw_is_empty();
|
||||
void post_gw();
|
||||
void post_tariff(std::string tariff_info);
|
||||
std::tuple<types::powermeter::Powermeter, std::string, bool> get_metervalue();
|
||||
std::string get_publickey(bool allow_cached_value);
|
||||
std::string get_datetime();
|
||||
void post_datetime();
|
||||
void refresh_datetime_if_required();
|
||||
void post_user(const types::powermeter::OCMFUserIdentificationStatus IS,
|
||||
const std::optional<types::powermeter::OCMFIdentificationLevel> IL,
|
||||
const std::vector<types::powermeter::OCMFIdentificationFlags>& IF,
|
||||
const types::powermeter::OCMFIdentificationType& IT,
|
||||
const std::optional<std::__cxx11::basic_string<char>>& ID,
|
||||
const std::optional<std::__cxx11::basic_string<char>>& TT);
|
||||
types::units_signed::SignedMeterValue get_receipt();
|
||||
types::units_signed::SignedMeterValue get_transaction();
|
||||
void post_receipt(const std::string& TX);
|
||||
|
||||
IsaIemDcrController() = delete;
|
||||
IsaIemDcrController(std::unique_ptr<HttpClientInterface> http_client, const SnapshotConfig& snap_config);
|
||||
|
||||
template <typename Callable>
|
||||
static auto call_with_retry(Callable func, int number_of_retries, int retry_wait_in_milliseconds,
|
||||
bool retry_on_http_client_error = true, bool retry_on_iemdcr_reponse_error = true)
|
||||
-> decltype(func()) {
|
||||
std::exception_ptr last_exception = nullptr;
|
||||
for (int attempt = 0; attempt < 1 + number_of_retries; ++attempt) {
|
||||
try {
|
||||
return func();
|
||||
} catch (HttpClientError& http_client_error) {
|
||||
last_exception = std::current_exception();
|
||||
if (!retry_on_http_client_error) {
|
||||
std::rethrow_exception(last_exception);
|
||||
}
|
||||
EVLOG_warning << "HTTPClient request failed: " << http_client_error.what() << "; retry in "
|
||||
<< retry_wait_in_milliseconds << " milliseconds";
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(retry_wait_in_milliseconds));
|
||||
} catch (IemDcrUnexpectedResponseException& iemdcr_error) {
|
||||
last_exception = std::current_exception();
|
||||
if (!retry_on_iemdcr_reponse_error) {
|
||||
std::rethrow_exception(last_exception);
|
||||
}
|
||||
EVLOG_warning << "Unexpected IEM-DCR response: " << iemdcr_error.what() << "; retry in "
|
||||
<< retry_wait_in_milliseconds << " milliseconds";
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(retry_wait_in_milliseconds));
|
||||
}
|
||||
}
|
||||
std::rethrow_exception(last_exception);
|
||||
}
|
||||
|
||||
private:
|
||||
const std::unique_ptr<HttpClientInterface> http_client;
|
||||
SnapshotConfig snapshot_config;
|
||||
std::string cached_public_key = "";
|
||||
std::chrono::minutes zone_time_offset;
|
||||
std::atomic<std::chrono::time_point<std::chrono::steady_clock>> last_datetime_sync;
|
||||
|
||||
std::chrono::minutes helper_convert_timezone(std::string& timezone);
|
||||
bool helper_is_daylight_saving_time();
|
||||
std::string helper_get_current_datetime();
|
||||
std::string helper_remove_first_and_last_char(const std::string& input);
|
||||
bool helper_get_bool_from_OCMFUserIdentificationStatus(types::powermeter::OCMFUserIdentificationStatus IS);
|
||||
std::string
|
||||
helper_get_string_from_OCMFIdentificationLevel(std::optional<types::powermeter::OCMFIdentificationLevel> IL);
|
||||
std::string helper_get_string_from_OCMFIdentificationFlags(types::powermeter::OCMFIdentificationFlags id_flag);
|
||||
std::string helper_get_string_from_OCMFIdentificationType(types::powermeter::OCMFIdentificationType IT);
|
||||
types::units_signed::SignedMeterValue helper_get_signed_datatuple(const std::string& endpoint);
|
||||
};
|
||||
|
||||
} // namespace module::main
|
||||
|
||||
#endif // EVEREST_CORE_MODULE_ISAIEMDCRCONTROLLER_H
|
||||
@@ -0,0 +1,272 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
#include "powermeterImpl.hpp"
|
||||
#include "httpClient.hpp"
|
||||
#include <chrono>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
namespace module {
|
||||
namespace main {
|
||||
|
||||
void powermeterImpl::init() {
|
||||
// Check Config values (essential plausibility checks)
|
||||
check_config();
|
||||
|
||||
// Dependency injection pattern: Create the HTTP client first,
|
||||
// then move it into the controller as a constructor argument
|
||||
auto http_client = std::make_unique<HttpClient>(mod->config.ip_address, mod->config.port_http);
|
||||
|
||||
// Create controller object
|
||||
this->controller = std::make_unique<IsaIemDcrController>(
|
||||
std::move(http_client),
|
||||
IsaIemDcrController::SnapshotConfig{
|
||||
mod->config.timezone, mod->config.timezone_handle_DST, mod->config.datetime_resync_interval,
|
||||
mod->config.resilience_initial_connection_retry_delay, mod->config.resilience_transaction_request_retries,
|
||||
mod->config.resilience_transaction_request_retry_delay, mod->config.CT, mod->config.CI,
|
||||
mod->config.TT_initial, mod->config.US});
|
||||
}
|
||||
|
||||
void powermeterImpl::ready() {
|
||||
// Start the live_measure_publisher thread, which periodically publishes the live measurements of the device
|
||||
this->live_measure_publisher_thread = std::thread([this] {
|
||||
while (true) {
|
||||
try {
|
||||
// Wait for at least one second (more if handle_start_transaction() or handle_stop_transaction() active)
|
||||
do {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
|
||||
} while (start_transaction_running.load() == true || stop_transaction_running.load() == true);
|
||||
// Init if needed
|
||||
if (is_initialized.load() == false) {
|
||||
is_initialized.store(this->controller->init());
|
||||
if (is_initialized.load() == true) {
|
||||
// Publish public key once after init
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
|
||||
this->publish_public_key_ocmf(this->controller->get_publickey(false));
|
||||
} else {
|
||||
if (!this->error_state_monitor->is_error_active("powermeter/CommunicationFault",
|
||||
"Communication timed out")) {
|
||||
auto error = this->error_factory->create_error(
|
||||
"powermeter/CommunicationFault", "Communication timed out",
|
||||
"This error is raised due to communication timeout");
|
||||
raise_error(error);
|
||||
}
|
||||
EVLOG_warning << "Connecting to IEM-DCR failed. Retry in "
|
||||
<< mod->config.resilience_initial_connection_retry_delay << " milliseconds";
|
||||
std::this_thread::sleep_for(
|
||||
std::chrono::milliseconds(mod->config.resilience_initial_connection_retry_delay));
|
||||
}
|
||||
} else {
|
||||
// Publish metervalue node (named powermeter in EVerest) and update status information
|
||||
auto meter_value_response = this->controller->get_metervalue();
|
||||
types::powermeter::Powermeter tmp_powermeter;
|
||||
std::string tmp_status;
|
||||
bool tmp_transaction_active;
|
||||
std::tie(tmp_powermeter, tmp_status, tmp_transaction_active) = meter_value_response;
|
||||
this->publish_powermeter(tmp_powermeter);
|
||||
dcr_status.store(tmp_status);
|
||||
transaction_active.store(tmp_transaction_active);
|
||||
|
||||
// Debug output :)
|
||||
// EVLOG_info << this->controller->get_datetime();
|
||||
|
||||
// Update datetime in specified interval
|
||||
if (transaction_active.load() == false) {
|
||||
this->controller->refresh_datetime_if_required();
|
||||
}
|
||||
|
||||
// Reset previous error (if active)
|
||||
if (this->error_state_monitor->is_error_active("powermeter/CommunicationFault",
|
||||
"Communication timed out")) {
|
||||
clear_error("powermeter/CommunicationFault", "Communication timed out");
|
||||
}
|
||||
}
|
||||
} catch (HttpClientError& client_error) {
|
||||
is_initialized.store(false);
|
||||
if (!this->error_state_monitor->is_error_active("powermeter/CommunicationFault",
|
||||
"Communication timed out")) {
|
||||
EVLOG_error << "Failed to communicate with the powermeter due to http error: "
|
||||
<< client_error.what();
|
||||
auto error =
|
||||
this->error_factory->create_error("powermeter/CommunicationFault", "Communication timed out",
|
||||
"This error is raised due to communication timeout");
|
||||
raise_error(error);
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
is_initialized.store(false);
|
||||
EVLOG_error << "Exception in cyclic IEM-DCR communication: " << e.what();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
types::powermeter::TransactionStartResponse
|
||||
powermeterImpl::handle_start_transaction(types::powermeter::TransactionReq& value) {
|
||||
// your code for cmd start_transaction goes here
|
||||
types::powermeter::TransactionStartResponse return_value;
|
||||
|
||||
start_transaction_running.store(true);
|
||||
|
||||
// Check preconditions
|
||||
if (value.evse_id != mod->config.CI && value.evse_id.length() > 0) {
|
||||
return_value.status = types::powermeter::TransactionRequestStatus::NOT_SUPPORTED;
|
||||
return_value.error = "config: CI does not match evse_id. This is not allowed.";
|
||||
EVLOG_error << "Aborted: " << *return_value.error;
|
||||
return return_value;
|
||||
}
|
||||
if (is_initialized.load() == false) {
|
||||
return_value.status = types::powermeter::TransactionRequestStatus::UNEXPECTED_ERROR;
|
||||
return_value.error = "Init of communication not finished yet. Please try again later.";
|
||||
EVLOG_error << "Aborted: " << *return_value.error;
|
||||
return return_value;
|
||||
}
|
||||
if (dcr_status.load() != "0x0000, 0x00000000, 0x00, 0x00") {
|
||||
return_value.status = types::powermeter::TransactionRequestStatus::UNEXPECTED_ERROR;
|
||||
return_value.error = "IEM-DCR is in error state. XC: " + dcr_status.load();
|
||||
EVLOG_error << "Aborted: " << *return_value.error;
|
||||
return return_value;
|
||||
}
|
||||
|
||||
// Perform action
|
||||
try {
|
||||
// Check if gw information is already set
|
||||
if (this->controller->check_gw_is_empty()) {
|
||||
return_value.status = types::powermeter::TransactionRequestStatus::UNEXPECTED_ERROR;
|
||||
return_value.error = "Init seems to be missing. Re-Init triggered. Please try again later.";
|
||||
is_initialized.store(false);
|
||||
} else {
|
||||
// Stop transaction (if a transaction is still running)
|
||||
if (transaction_active.load() == true) {
|
||||
this->controller->call_with_retry([this]() { this->controller->post_receipt("E"); },
|
||||
mod->config.resilience_transaction_request_retries,
|
||||
mod->config.resilience_transaction_request_retry_delay);
|
||||
} else {
|
||||
// Try to end transaction at least once.
|
||||
try {
|
||||
this->controller->post_receipt("E");
|
||||
} catch (...) {
|
||||
// Nothing to do here
|
||||
}
|
||||
}
|
||||
// Wait for being surely in idle mode
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(250));
|
||||
// Create user
|
||||
if ((static_cast<std::string>(value.identification_data.value_or(""))).length() <= 0) {
|
||||
this->controller->call_with_retry(
|
||||
[this, value]() {
|
||||
this->controller->post_user(value.identification_status, value.identification_level,
|
||||
value.identification_flags, value.identification_type,
|
||||
value.transaction_id, value.tariff_text);
|
||||
},
|
||||
mod->config.resilience_transaction_request_retries,
|
||||
mod->config.resilience_transaction_request_retry_delay);
|
||||
} else {
|
||||
this->controller->call_with_retry(
|
||||
[this, value]() {
|
||||
this->controller->post_user(value.identification_status, value.identification_level,
|
||||
value.identification_flags, value.identification_type,
|
||||
value.identification_data, value.tariff_text);
|
||||
},
|
||||
mod->config.resilience_transaction_request_retries,
|
||||
mod->config.resilience_transaction_request_retry_delay);
|
||||
}
|
||||
// Start transaction
|
||||
this->controller->call_with_retry([this]() { this->controller->post_receipt("B"); },
|
||||
mod->config.resilience_transaction_request_retries,
|
||||
mod->config.resilience_transaction_request_retry_delay);
|
||||
// Prepare positive response
|
||||
transaction_active.store(true);
|
||||
return_value.status = types::powermeter::TransactionRequestStatus::OK;
|
||||
return_value.error = "";
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
return_value.status = types::powermeter::TransactionRequestStatus::UNEXPECTED_ERROR;
|
||||
return_value.error = e.what();
|
||||
EVLOG_error << "Aborted: " << return_value.error.value_or("");
|
||||
}
|
||||
|
||||
start_transaction_running.store(false);
|
||||
|
||||
return return_value;
|
||||
}
|
||||
|
||||
types::powermeter::TransactionStopResponse powermeterImpl::handle_stop_transaction(std::string& transaction_id) {
|
||||
// your code for cmd stop_transaction goes here
|
||||
types::powermeter::TransactionStopResponse return_value;
|
||||
|
||||
stop_transaction_running.store(true);
|
||||
|
||||
if (is_initialized.load() == false) {
|
||||
return_value.status = types::powermeter::TransactionRequestStatus::UNEXPECTED_ERROR;
|
||||
return_value.error = "Init of communication not finished yet.";
|
||||
EVLOG_error << "Aborted: " << *return_value.error;
|
||||
} else if (transaction_active.load() == true) {
|
||||
try {
|
||||
// Stop transaction
|
||||
this->controller->call_with_retry([this]() { this->controller->post_receipt("E"); },
|
||||
mod->config.resilience_transaction_request_retries,
|
||||
mod->config.resilience_transaction_request_retry_delay);
|
||||
// Wait for signature calculation
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(250));
|
||||
// Read receipt
|
||||
return_value.signed_meter_value =
|
||||
this->controller->call_with_retry([this]() { return this->controller->get_receipt(); },
|
||||
mod->config.resilience_transaction_request_retries,
|
||||
mod->config.resilience_transaction_request_retry_delay);
|
||||
// Prepare positive response
|
||||
transaction_active.store(false);
|
||||
return_value.status = types::powermeter::TransactionRequestStatus::OK;
|
||||
return_value.error = "";
|
||||
} catch (const std::exception& e) {
|
||||
return_value.status = types::powermeter::TransactionRequestStatus::UNEXPECTED_ERROR;
|
||||
return_value.error = e.what();
|
||||
EVLOG_error << "Aborted: " << return_value.error.value_or("");
|
||||
}
|
||||
} else {
|
||||
// No transaction running. So return last transaction (if available)
|
||||
try {
|
||||
return_value.signed_meter_value = this->controller->get_transaction();
|
||||
return_value.status = types::powermeter::TransactionRequestStatus::OK;
|
||||
return_value.error = "";
|
||||
} catch (std::exception& e) {
|
||||
return_value.status = types::powermeter::TransactionRequestStatus::UNEXPECTED_ERROR;
|
||||
return_value.error = std::string(e.what()) + " Maybe no transaction to return?";
|
||||
EVLOG_warning << "Aborted: " << return_value.error.value_or("");
|
||||
}
|
||||
}
|
||||
|
||||
stop_transaction_running.store(false);
|
||||
|
||||
return return_value;
|
||||
}
|
||||
|
||||
void powermeterImpl::check_config() {
|
||||
// Numeric range checks are aready covered by manifest minimum and maximum declaration
|
||||
if (mod->config.ip_address.length() <= 0) {
|
||||
EVLOG_error << "Incorrect module config: parameter ip_address is empty." << std::endl;
|
||||
throw std::runtime_error("ip_address invalid. Please check configuration.");
|
||||
}
|
||||
if (mod->config.timezone.length() != 5) {
|
||||
EVLOG_error
|
||||
<< "Incorrect module config: parameter timezone has invalid length. 5 characters expected, e.g. +0200"
|
||||
<< std::endl;
|
||||
throw std::runtime_error("Timezone invalid. Please check configuration.");
|
||||
}
|
||||
if (mod->config.timezone[0] != '+' && mod->config.timezone[0] != '-') {
|
||||
EVLOG_error << "Incorrect module config: parameter timezone has invalid format. It must begin with + or - char."
|
||||
<< std::endl;
|
||||
throw std::runtime_error("Timezone invalid. Please check configuration.");
|
||||
}
|
||||
if (mod->config.CT.length() <= 0) {
|
||||
EVLOG_error << "Incorrect module config: parameter CT is empty." << std::endl;
|
||||
throw std::runtime_error("CT invalid. Please check configuration.");
|
||||
}
|
||||
if (mod->config.CI.length() <= 0) {
|
||||
EVLOG_error << "Incorrect module config: parameter CI is empty." << std::endl;
|
||||
throw std::runtime_error("CI invalid. Please check configuration.");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace main
|
||||
} // namespace module
|
||||
@@ -0,0 +1,79 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
#ifndef MAIN_POWERMETER_IMPL_HPP
|
||||
#define MAIN_POWERMETER_IMPL_HPP
|
||||
|
||||
//
|
||||
// AUTO GENERATED - MARKED REGIONS WILL BE KEPT
|
||||
// template version 3
|
||||
//
|
||||
|
||||
#include <generated/interfaces/powermeter/Implementation.hpp>
|
||||
|
||||
#include "../IsabellenhuetteIemDcr.hpp"
|
||||
|
||||
// ev@75ac1216-19eb-4182-a85c-820f1fc2c091:v1
|
||||
// insert your custom include headers here
|
||||
#include "httpClientInterface.hpp"
|
||||
#include "isabellenhuetteIemDcrController.hpp"
|
||||
// ev@75ac1216-19eb-4182-a85c-820f1fc2c091:v1
|
||||
|
||||
namespace module {
|
||||
namespace main {
|
||||
|
||||
struct Conf {};
|
||||
|
||||
class powermeterImpl : public powermeterImplBase {
|
||||
public:
|
||||
powermeterImpl() = delete;
|
||||
powermeterImpl(Everest::ModuleAdapter* ev, const Everest::PtrContainer<IsabellenhuetteIemDcr>& mod, Conf& config) :
|
||||
powermeterImplBase(ev, "main"), mod(mod), config(config){};
|
||||
|
||||
// ev@8ea32d28-373f-4c90-ae5e-b4fcc74e2a61:v1
|
||||
// insert your public definitions here
|
||||
// ev@8ea32d28-373f-4c90-ae5e-b4fcc74e2a61:v1
|
||||
|
||||
protected:
|
||||
// command handler functions (virtual)
|
||||
virtual types::powermeter::TransactionStartResponse
|
||||
handle_start_transaction(types::powermeter::TransactionReq& value) override;
|
||||
virtual types::powermeter::TransactionStopResponse handle_stop_transaction(std::string& transaction_id) override;
|
||||
|
||||
// ev@d2d1847a-7b88-41dd-ad07-92785f06f5c4:v1
|
||||
// insert your protected definitions here
|
||||
// ev@d2d1847a-7b88-41dd-ad07-92785f06f5c4:v1
|
||||
|
||||
private:
|
||||
const Everest::PtrContainer<IsabellenhuetteIemDcr>& mod;
|
||||
const Conf& config;
|
||||
|
||||
virtual void init() override;
|
||||
virtual void ready() override;
|
||||
|
||||
// ev@3370e4dd-95f4-47a9-aaec-ea76f34a66c9:v1
|
||||
// insert your private definitions here
|
||||
IsaIemDcrController::ThreadSafeString dcr_status;
|
||||
std::atomic<bool> is_initialized = false;
|
||||
std::atomic<bool> transaction_active = true;
|
||||
std::atomic<bool> start_transaction_running = false;
|
||||
std::atomic<bool> stop_transaction_running = false;
|
||||
// At construction time, there is no controller and no HTTP client, so these are null pointers.
|
||||
// When init() is called, the controller is initialized.
|
||||
std::unique_ptr<IsaIemDcrController> controller = nullptr;
|
||||
// The live_measure_publisher thread handles the periodic (1/s) publication of the live measurements
|
||||
// Initially it's a default-constructed thread (which is valid, but doesn't represent an actual running thread)
|
||||
// In ready(), the live_measure_publisher thread is started and placed in this field.
|
||||
std::thread live_measure_publisher_thread;
|
||||
// private functions
|
||||
void check_config();
|
||||
// ev@3370e4dd-95f4-47a9-aaec-ea76f34a66c9:v1
|
||||
};
|
||||
|
||||
// ev@3d7da0ad-02c2-493d-9920-0bbbd56b9876:v1
|
||||
// insert other definitions here
|
||||
// ev@3d7da0ad-02c2-493d-9920-0bbbd56b9876:v1
|
||||
|
||||
} // namespace main
|
||||
} // namespace module
|
||||
|
||||
#endif // MAIN_POWERMETER_IMPL_HPP
|
||||
@@ -0,0 +1,68 @@
|
||||
description: Module implements Isabellenhuette IEM-DCR power meter driver, connecting via HTTP/REST
|
||||
config:
|
||||
ip_address:
|
||||
description: IPv4 Address of the power meter API.
|
||||
type: string
|
||||
default: "192.168.60.12"
|
||||
port_http:
|
||||
description: HTTP-Port of the power meter API.
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 65535
|
||||
default: 80
|
||||
timezone:
|
||||
description: The timezone offset information according to ISO8601 (version without colon) for normal time.
|
||||
type: string
|
||||
default: "+0100"
|
||||
timezone_handle_DST:
|
||||
description: Controls whether daylight saving time (DST) is handled or normal time is used continuously.
|
||||
type: boolean
|
||||
default: true
|
||||
datetime_resync_interval:
|
||||
description: Interval for cyclic time resync in hours.
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 24
|
||||
default: 2
|
||||
resilience_initial_connection_retry_delay:
|
||||
description: For the controller resilience, the delay in milliseconds before a retry attempt at module initialization.
|
||||
type: integer
|
||||
minimum: 1000
|
||||
maximum: 65535
|
||||
default: 10000
|
||||
resilience_transaction_request_retries:
|
||||
description: For the controller resilience, the number of retries to connect to the powermeter at a transaction start or stop request.
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 5
|
||||
default: 3
|
||||
resilience_transaction_request_retry_delay:
|
||||
description: For the controller resilience, the delay in milliseconds before a retry attempt at a transaction start or stop request.
|
||||
type: integer
|
||||
minimum: 200
|
||||
maximum: 1000
|
||||
default: 250
|
||||
CT:
|
||||
description: Charge point identification type (part of the signed OCMF data tuple).
|
||||
type: string
|
||||
default: "EVSEID"
|
||||
CI:
|
||||
description: Charge point identification (part of the signed OCMF data tuple).
|
||||
type: string
|
||||
default: "1234"
|
||||
TT_initial:
|
||||
description: Initial tariff text. (Its current value is part of signed OCMF data tuple).
|
||||
type: string
|
||||
default: ""
|
||||
US:
|
||||
description: Controls whether UserID is shown on display or not.
|
||||
type: boolean
|
||||
default: false
|
||||
provides:
|
||||
main:
|
||||
description: This is the main unit of the module
|
||||
interface: powermeter
|
||||
metadata:
|
||||
license: https://opensource.org/licenses/Apache-2.0
|
||||
authors:
|
||||
- Josef Herbert <josef.herbert@isabellenhuette.com>
|
||||
@@ -0,0 +1,35 @@
|
||||
set(TEST_TARGET_NAME ${PROJECT_NAME}_isabellenhuetteIemDcrController_tests)
|
||||
|
||||
set(MODULE_DIR "${PROJECT_SOURCE_DIR}/modules/HardwareDrivers/PowerMeters/IsabellenhuetteIemDcr")
|
||||
|
||||
set(TEST_SOURCES ${MODULE_DIR}/main/isabellenhuetteIemDcrController.cpp)
|
||||
|
||||
add_executable(${TEST_TARGET_NAME} test_isabellenhuetteIemDcrController.cpp ${TEST_SOURCES})
|
||||
|
||||
add_dependencies(${TEST_TARGET_NAME} ${MODULE_NAME})
|
||||
|
||||
set(INCLUDE_DIR
|
||||
"main"
|
||||
"tests"
|
||||
"${MODULE_DIR}/main"
|
||||
"${MODULE_DIR}/tests"
|
||||
)
|
||||
|
||||
get_target_property(GENERATED_INCLUDE_DIR generate_cpp_files EVEREST_GENERATED_INCLUDE_DIR)
|
||||
|
||||
target_include_directories(${TEST_TARGET_NAME} PRIVATE
|
||||
tests
|
||||
${INCLUDE_DIR}
|
||||
${GENERATED_INCLUDE_DIR}
|
||||
)
|
||||
|
||||
target_link_libraries(${TEST_TARGET_NAME} PRIVATE
|
||||
GTest::gmock
|
||||
GTest::gtest_main
|
||||
everest::timer
|
||||
everest::framework
|
||||
nlohmann_json::nlohmann_json
|
||||
)
|
||||
|
||||
add_test(${TEST_TARGET_NAME} ${TEST_TARGET_NAME})
|
||||
ev_register_test_target(${TEST_TARGET_NAME})
|
||||
@@ -0,0 +1,10 @@
|
||||
## Unit tests with GTest
|
||||
A series of unit tests checks the implemented business logic of the controller.
|
||||
The unit tests require GoogleTest (GTest). This can be installed via
|
||||
```bash
|
||||
apt install libgtest-dev
|
||||
```
|
||||
Please do not forget to compile GTest library.
|
||||
|
||||
To run the tests, please issue compiler switch -DBUILD_TESTING=ON which will
|
||||
enable EVEREST_CORE_BUILD_TESTING on which these unit tests relate.
|
||||
@@ -0,0 +1,470 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Copyright Pionix GmbH and Contributors to EVerest
|
||||
|
||||
#include "httpClientInterface.hpp"
|
||||
#include "isabellenhuetteIemDcrController.hpp"
|
||||
#include <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
namespace module::main {
|
||||
|
||||
class HTTPClientMock : public HttpClientInterface {
|
||||
public:
|
||||
MOCK_METHOD(HttpResponse, get, (const std::string& path), (override, const));
|
||||
MOCK_METHOD(HttpResponse, post, (const std::string& path, const std::string& body), (override, const));
|
||||
};
|
||||
|
||||
// Fixture class providing
|
||||
// - a http client mock
|
||||
// - default responses & request objects
|
||||
class IsabellenhuetteIemDcrControllerTest : public ::testing::Test {
|
||||
|
||||
protected:
|
||||
std::unique_ptr<HTTPClientMock> http_client;
|
||||
|
||||
const std::string gw_response{R"({
|
||||
"CT": "",
|
||||
"CI": "",
|
||||
"TM": "2024-12-16T19:00:22,000+0200 U"
|
||||
})"};
|
||||
|
||||
const std::string metervalue_response{R"({
|
||||
"MS": "1ISA0200000001",
|
||||
"TM": "2024-12-15T22:42:28,000+0200 U",
|
||||
"EF": "t",
|
||||
"ST": "G",
|
||||
"XT": false,
|
||||
"RD": [
|
||||
{
|
||||
"RV": "00000446.540",
|
||||
"RI": "1-b:1.8.e",
|
||||
"RU": "kWh",
|
||||
"RT": "DC"
|
||||
},
|
||||
{
|
||||
"RV": "00000004.881",
|
||||
"RI": "1-b:2.8.e",
|
||||
"RU": "kWh",
|
||||
"RT": "DC"
|
||||
},
|
||||
{
|
||||
"WV": "00000001.040",
|
||||
"WI": "transaction_1-b:1.8.e",
|
||||
"WU": "kWh",
|
||||
"WT": "DC"
|
||||
},
|
||||
{
|
||||
"WV": "00000002.503",
|
||||
"WI": "transaction_1-b:2.8.e",
|
||||
"WU": "kWh",
|
||||
"WT": "DC"
|
||||
}
|
||||
],
|
||||
"U1": 1.089,
|
||||
"U2": 60.1,
|
||||
"U3": 0,
|
||||
"U4": 0,
|
||||
"I": 5,
|
||||
"P": 300,
|
||||
"XS": "0x0000004000000004",
|
||||
"XC": "0x0000, 0x00000000, 0x00, 0x00"
|
||||
})"};
|
||||
|
||||
const std::string publickey = "3059301306072a8648ce3d020106082a8648ce3d"
|
||||
"03010703420004A97A28BE22DEDF619A497288FF"
|
||||
"F217832B37E44B8B1F8918C48EB5FBF5CB8B5FBB"
|
||||
"717D32CD2211534D968CA4425B9FCBF5A93E60F2"
|
||||
"CE97BCD63F9CAD287F5E08";
|
||||
|
||||
const std::string publickey_response{R"({
|
||||
"SA": "ECDSA-secp256r1-SHA256",
|
||||
"PK": ")" + publickey + R"("
|
||||
})"};
|
||||
|
||||
const std::string publickey_response_alt{R"({
|
||||
"SA": "ECDSA-secp256r1-SHA256",
|
||||
"PK": "abc"
|
||||
})"};
|
||||
|
||||
const std::string receipt_response{R"({
|
||||
"OCMF|{"FV":"1.0","GI":"Isabellenhuette IEM-DCR-125-1000-32-00-006-B_000",
|
||||
"GS":"1ISA0200001132","GV":"DU-02.00.12_SU-02.00.08","MV":"Isabellenhuette",
|
||||
"MM":"IEM-DCR-125-1000-32-00-006-B_000","MS":"1ISA0200001132",
|
||||
"CT":"DC-Test-Charger","CI":"CP-DE-4711","IS":true,"IL":"CERTIFIED",
|
||||
"IF":["RFID_PSK","OCPP_CERTIFIED","ISO15118_NONE","PLMN_NONE"],
|
||||
"IT":"ISO15693","ID":"9109543224","PG":"T4","TT":"0,20 EUR/kWh",
|
||||
"RD":[{"TM":"2025-06-06T09:26:24,000+0200 I","TX":"B","EF":"",
|
||||
"ST":"G","RV":"00000446.540","RI":"1-b:1.8.e","RU":"kWh","RT":"DC"}
|
||||
,{"TM":"2025-06-06T09:26:45,000+0200 I","TX":"E","EF":"","ST":"G",
|
||||
"RV":"00000446.540","RI":"1-b:1.8.e","RU":"kWh","RT":"DC"}]}|
|
||||
{"SA":"ECDSA-secp256r1-SHA256","SD":"304402203DE5BF9E3A5960935
|
||||
47AAEDA8CCEAFD88CAB59AC65FE616BD33158F2002545960220B3ED5B4A32E
|
||||
F8028577EF0E4F823DACF30DE75CA744C9FF9560FAE0134D19ABF"}
|
||||
})"};
|
||||
|
||||
const IsaIemDcrController::SnapshotConfig controller_config{"+0100", true, 12, 10000, 2,
|
||||
250, "ctVal", "ciVal", "ttInitial", true};
|
||||
|
||||
void SetUp() override {
|
||||
this->http_client = std::make_unique<HTTPClientMock>();
|
||||
}
|
||||
};
|
||||
|
||||
//****************************************************************
|
||||
// Test init behavior
|
||||
|
||||
/// \brief Test init() interacts propely with HttpClient
|
||||
TEST_F(IsabellenhuetteIemDcrControllerTest, test_init) {
|
||||
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->http_client, get("/counter/v1/ocmf/gw"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->gw_response}));
|
||||
EXPECT_CALL(
|
||||
*this->http_client,
|
||||
post("/counter/v1/ocmf/gw", testing::MatchesRegex(R"(\{.*"CT":")" + this->controller_config.CT + R"(","CI":")" +
|
||||
this->controller_config.CI + R"(","TM":.*\}.*)")))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->gw_response}));
|
||||
EXPECT_CALL(*this->http_client,
|
||||
post("/counter/v1/ocmf/tariff",
|
||||
testing::MatchesRegex(R"(\{.*"TT":")" + this->controller_config.TT_initial + R"("\}.*)")))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, ""}));
|
||||
IsaIemDcrController controller(std::move(this->http_client), this->controller_config);
|
||||
|
||||
// Act
|
||||
bool retVal = controller.init();
|
||||
|
||||
// Verify
|
||||
EXPECT_EQ(retVal, true);
|
||||
}
|
||||
|
||||
/// \brief Test init() interacts propely with HttpClient with gw already set
|
||||
TEST_F(IsabellenhuetteIemDcrControllerTest, test_init_with_gw_already_set) {
|
||||
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->http_client, get("/counter/v1/ocmf/gw"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->gw_response}));
|
||||
EXPECT_CALL(
|
||||
*this->http_client,
|
||||
post("/counter/v1/ocmf/gw", testing::MatchesRegex(R"(\{.*"CT":")" + this->controller_config.CT + R"(","CI":")" +
|
||||
this->controller_config.CI + R"(","TM":.*\}.*)")))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{428, ""}));
|
||||
EXPECT_CALL(*this->http_client,
|
||||
post("/counter/v1/ocmf/tariff",
|
||||
testing::MatchesRegex(R"(\{.*"TT":")" + this->controller_config.TT_initial + R"("\}.*)")))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, ""}));
|
||||
IsaIemDcrController controller(std::move(this->http_client), this->controller_config);
|
||||
|
||||
// Act
|
||||
bool retVal = controller.init();
|
||||
|
||||
// Verify
|
||||
EXPECT_EQ(retVal, true);
|
||||
}
|
||||
|
||||
/// \brief Test init() returns false on missing connection
|
||||
TEST_F(IsabellenhuetteIemDcrControllerTest, test_init_timeout) {
|
||||
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->http_client, get("/counter/v1/ocmf/gw"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{408, ""})); // 408 Request timeout
|
||||
IsaIemDcrController controller(std::move(this->http_client), this->controller_config);
|
||||
|
||||
// Act
|
||||
bool retVal = controller.init();
|
||||
|
||||
// Verify
|
||||
EXPECT_EQ(retVal, false);
|
||||
}
|
||||
|
||||
//****************************************************************
|
||||
// Test get powermeter behavior
|
||||
|
||||
/// \brief Test get_metervalue() returns correct values
|
||||
TEST_F(IsabellenhuetteIemDcrControllerTest, test_get_metervalue) {
|
||||
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->http_client, get("/counter/v1/ocmf/metervalue"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->metervalue_response}));
|
||||
IsaIemDcrController controller(std::move(this->http_client), this->controller_config);
|
||||
|
||||
// Act
|
||||
auto meter_value_response = controller.get_metervalue();
|
||||
types::powermeter::Powermeter tmp_powermeter;
|
||||
std::string tmp_status;
|
||||
bool tmp_transaction_active;
|
||||
std::tie(tmp_powermeter, tmp_status, tmp_transaction_active) = meter_value_response;
|
||||
|
||||
// Verify
|
||||
EXPECT_EQ(tmp_transaction_active, false);
|
||||
EXPECT_EQ(tmp_status, "0x0000, 0x00000000, 0x00, 0x00");
|
||||
EXPECT_EQ(tmp_powermeter.timestamp, "2024-12-15T22:42:28,000+0200");
|
||||
EXPECT_THAT(tmp_powermeter.energy_Wh_import.total, testing::FloatEq(1040));
|
||||
EXPECT_THAT(tmp_powermeter.energy_Wh_export->total, testing::FloatEq(2503));
|
||||
EXPECT_THAT(tmp_powermeter.power_W->total, testing::FloatEq(300.0));
|
||||
EXPECT_THAT(tmp_powermeter.current_A->DC.value(), testing::FloatEq(5.0));
|
||||
EXPECT_THAT(tmp_powermeter.voltage_V->DC.value(), testing::FloatEq(60.1));
|
||||
EXPECT_THAT(tmp_powermeter.meter_id.value(), "1ISA0200000001");
|
||||
}
|
||||
|
||||
/// \brief Test get_metervalue() fails due to an invalid response status code
|
||||
TEST_F(IsabellenhuetteIemDcrControllerTest, test_get_metervalue_invalid_response_code) {
|
||||
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->http_client, get("/counter/v1/ocmf/metervalue"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{403, this->metervalue_response}));
|
||||
IsaIemDcrController controller(std::move(this->http_client), this->controller_config);
|
||||
|
||||
// Act & Verify
|
||||
EXPECT_THROW(controller.get_metervalue(), IsaIemDcrController::UnexpectedIemDcrResponseCode);
|
||||
}
|
||||
|
||||
/// \brief Test get_metervalue() fails due to an invalid response status body
|
||||
TEST_F(IsabellenhuetteIemDcrControllerTest, test_get_metervalue_fail_invalid_response_body) {
|
||||
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->http_client, get("/counter/v1/ocmf/metervalue"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, "invalid"}));
|
||||
IsaIemDcrController controller(std::move(this->http_client), this->controller_config);
|
||||
|
||||
// Act & Verify
|
||||
EXPECT_THROW(controller.get_metervalue(), IsaIemDcrController::UnexpectedIemDcrResponseBody);
|
||||
}
|
||||
|
||||
/// \brief get_metervalue() fails due to an http client error
|
||||
TEST_F(IsabellenhuetteIemDcrControllerTest, test_get_metervalue_fail_http_error) {
|
||||
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->http_client, get("/counter/v1/ocmf/metervalue"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Throw(HttpClientError("http client mock error")));
|
||||
IsaIemDcrController controller(std::move(this->http_client), this->controller_config);
|
||||
|
||||
// Act & Verify
|
||||
EXPECT_THROW(controller.get_metervalue(), HttpClientError);
|
||||
}
|
||||
|
||||
//****************************************************************
|
||||
// Test get publickey behavior
|
||||
|
||||
/// \brief Test get_publickey() returns correct value with enabled caching
|
||||
TEST_F(IsabellenhuetteIemDcrControllerTest, test_get_publickey) {
|
||||
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->http_client, get("/counter/v1/ocmf/publickey"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->publickey_response}));
|
||||
IsaIemDcrController controller(std::move(this->http_client), this->controller_config);
|
||||
|
||||
// Act & Verify
|
||||
auto retVal = controller.get_publickey(true);
|
||||
EXPECT_EQ(retVal, this->publickey);
|
||||
retVal = controller.get_publickey(true);
|
||||
EXPECT_EQ(retVal, this->publickey); // Expect previous value (only one GET request allowed above)
|
||||
}
|
||||
|
||||
/// \brief Test get_publickey() returns correct value with disabled caching
|
||||
TEST_F(IsabellenhuetteIemDcrControllerTest, test_get_publickey_no_cache) {
|
||||
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->http_client, get("/counter/v1/ocmf/publickey"))
|
||||
.Times(2)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->publickey_response}))
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->publickey_response_alt}));
|
||||
IsaIemDcrController controller(std::move(this->http_client), this->controller_config);
|
||||
|
||||
// Act & Verify
|
||||
auto retVal = controller.get_publickey(false);
|
||||
EXPECT_EQ(retVal, this->publickey);
|
||||
retVal = controller.get_publickey(false);
|
||||
EXPECT_EQ(retVal, "abc"); // Expect fresh value of 2nd call (publickey_response_alt)
|
||||
}
|
||||
|
||||
/// \brief get_publickey() fails due to an http client error
|
||||
TEST_F(IsabellenhuetteIemDcrControllerTest, test_get_publickey_fail_http_error) {
|
||||
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->http_client, get("/counter/v1/ocmf/publickey"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Throw(HttpClientError("http client mock error")));
|
||||
IsaIemDcrController controller(std::move(this->http_client), this->controller_config);
|
||||
|
||||
// Act & Verify
|
||||
EXPECT_THROW(controller.get_publickey(false), HttpClientError);
|
||||
}
|
||||
|
||||
//****************************************************************
|
||||
// Test start transaction behavior
|
||||
|
||||
/// \brief Test post_receipt("B") starts transaction properly
|
||||
TEST_F(IsabellenhuetteIemDcrControllerTest, test_post_receipt_B) {
|
||||
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->http_client, post("/counter/v1/ocmf/receipt", testing::MatchesRegex(R"(\{.*"TX":"B"*\}.*)")))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, ""}));
|
||||
IsaIemDcrController controller(std::move(this->http_client), this->controller_config);
|
||||
|
||||
// Act & Verify
|
||||
controller.post_receipt("B");
|
||||
}
|
||||
|
||||
/// \brief Test post_receipt("B") fails due to an invalid response status code
|
||||
TEST_F(IsabellenhuetteIemDcrControllerTest, test_post_receipt_B_invalid_response_code) {
|
||||
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->http_client, post("/counter/v1/ocmf/receipt", testing::MatchesRegex(R"(\{.*"TX":"B"*\}.*)")))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{428, ""}));
|
||||
IsaIemDcrController controller(std::move(this->http_client), this->controller_config);
|
||||
|
||||
// Act & Verify
|
||||
EXPECT_THROW(controller.post_receipt("B"), IsaIemDcrController::UnexpectedIemDcrResponseCode);
|
||||
}
|
||||
|
||||
/// \brief Test post_receipt("B") fails due to an http client error
|
||||
TEST_F(IsabellenhuetteIemDcrControllerTest, test_post_receipt_B_fail_http_error) {
|
||||
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->http_client, post("/counter/v1/ocmf/receipt", testing::MatchesRegex(R"(\{.*"TX":"B"*\}.*)")))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Throw(HttpClientError("http client mock error")));
|
||||
IsaIemDcrController controller(std::move(this->http_client), this->controller_config);
|
||||
|
||||
// Act & Verify
|
||||
EXPECT_THROW(controller.post_receipt("B"), HttpClientError);
|
||||
}
|
||||
|
||||
//****************************************************************
|
||||
// Test end transaction behavior
|
||||
|
||||
/// \brief Test post_receipt("E") ends transaction properly
|
||||
TEST_F(IsabellenhuetteIemDcrControllerTest, test_post_receipt_E) {
|
||||
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->http_client, post("/counter/v1/ocmf/receipt", testing::MatchesRegex(R"(\{.*"TX":"E"*\}.*)")))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{200, ""}));
|
||||
IsaIemDcrController controller(std::move(this->http_client), this->controller_config);
|
||||
|
||||
// Act & Verify
|
||||
controller.post_receipt("E");
|
||||
}
|
||||
|
||||
/// \brief Test post_receipt("E") fails due to an invalid response status code
|
||||
TEST_F(IsabellenhuetteIemDcrControllerTest, test_post_receipt_E_invalid_response_code) {
|
||||
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->http_client, post("/counter/v1/ocmf/receipt", testing::MatchesRegex(R"(\{.*"TX":"E"*\}.*)")))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Return(HttpResponse{428, ""}));
|
||||
IsaIemDcrController controller(std::move(this->http_client), this->controller_config);
|
||||
|
||||
// Act & Verify
|
||||
EXPECT_THROW(controller.post_receipt("E"), IsaIemDcrController::UnexpectedIemDcrResponseCode);
|
||||
}
|
||||
|
||||
/// \brief Test post_receipt("B") fails due to an http client error
|
||||
TEST_F(IsabellenhuetteIemDcrControllerTest, test_post_receipt_E_fail_http_error) {
|
||||
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->http_client, post("/counter/v1/ocmf/receipt", testing::MatchesRegex(R"(\{.*"TX":"E"*\}.*)")))
|
||||
.Times(1)
|
||||
.WillOnce(testing::Throw(HttpClientError("http client mock error")));
|
||||
IsaIemDcrController controller(std::move(this->http_client), this->controller_config);
|
||||
|
||||
// Act & Verify
|
||||
EXPECT_THROW(controller.post_receipt("E"), HttpClientError);
|
||||
}
|
||||
|
||||
/// \brief Test get_receipt() returns correct values
|
||||
TEST_F(IsabellenhuetteIemDcrControllerTest, test_get_receipt) {
|
||||
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->http_client, get("/counter/v1/ocmf/receipt"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->receipt_response}));
|
||||
EXPECT_CALL(*this->http_client, get("/counter/v1/ocmf/publickey"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{200, this->publickey_response}));
|
||||
IsaIemDcrController controller(std::move(this->http_client), this->controller_config);
|
||||
|
||||
// Act
|
||||
auto signed_meter_value = controller.get_receipt();
|
||||
|
||||
// Verify
|
||||
EXPECT_EQ(signed_meter_value.signed_meter_data, this->receipt_response);
|
||||
EXPECT_EQ(signed_meter_value.signing_method, "");
|
||||
EXPECT_EQ(signed_meter_value.encoding_method, "OCMF");
|
||||
EXPECT_EQ(signed_meter_value.public_key, this->publickey);
|
||||
}
|
||||
|
||||
/// \brief Test get_receipt() fails due to an invalid response status code
|
||||
TEST_F(IsabellenhuetteIemDcrControllerTest, test_get_receipt_invalid_response_code) {
|
||||
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->http_client, get("/counter/v1/ocmf/receipt"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Return(HttpResponse{403, this->receipt_response}));
|
||||
IsaIemDcrController controller(std::move(this->http_client), this->controller_config);
|
||||
|
||||
// Act & Verify
|
||||
EXPECT_THROW(controller.get_receipt(), IsaIemDcrController::UnexpectedIemDcrResponseCode);
|
||||
}
|
||||
|
||||
/// \brief get_receipt() fails due to an http client error
|
||||
TEST_F(IsabellenhuetteIemDcrControllerTest, test_get_receipt_fail_http_error) {
|
||||
|
||||
// Setup
|
||||
testing::Sequence seq;
|
||||
EXPECT_CALL(*this->http_client, get("/counter/v1/ocmf/receipt"))
|
||||
.Times(1)
|
||||
.InSequence(seq)
|
||||
.WillOnce(testing::Throw(HttpClientError("http client mock error")));
|
||||
IsaIemDcrController controller(std::move(this->http_client), this->controller_config);
|
||||
|
||||
// Act & Verify
|
||||
EXPECT_THROW(controller.get_receipt(), HttpClientError);
|
||||
}
|
||||
|
||||
} // namespace module::main
|
||||
Reference in New Issue
Block a user